1 Commits
Author SHA1 Message Date
david b03df96c9d chore: Release v0.1.4 2024-03-13 09:05:41 +01:00
60 changed files with 2721 additions and 5025 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"root": true,
"extends": ["@raycast", "plugin:import/recommended", "plugin:import/typescript"],
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
}
}
-23
View File
@@ -2,29 +2,6 @@
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2026-06-08
### Added
- Add support for TickTick, Google Calendar, Google Drive and API (web page) notifications
- Fill notification previews with rich content (metadata sidebar, body and comment/message threads) modeled on the web app
- Show the notification source type (e.g. Linear Issue, GitHub Pull Request) in the preview metadata
### Changed
- Swap notification list shortcuts: `Enter` shows details, `Cmd+Enter` opens in browser
### Fixed
- Fix "could not open app" error when opening Google Calendar notifications
## [0.2.0] - 2024-12-16
### Added
- Add support for Slack reaction notifications
- Add support for Slack message notifications
## [0.1.4] - 2024-03-13 ## [0.1.4] - 2024-03-13
### Added ### Added
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 51 KiB

-1001
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"packages": [ "packages": [
"nodejs@24", "nodejs@latest",
"nodePackages.prettier@latest", "nodePackages.prettier@latest",
"pre-commit@latest", "pre-commit@latest",
"just@latest" "just@latest"
+8 -73
View File
@@ -1,10 +1,6 @@
{ {
"lockfile_version": "1", "lockfile_version": "1",
"packages": { "packages": {
"github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2026-06-01T17:55:45Z",
"resolved": "github:NixOS/nixpkgs/4df1b885d76a54e1aa1a318f8d16fd6005b6401f?lastModified=1780336545&narHash=sha256-vhVhuXzFrIOfcssC%2F9hDHx7MHzDKjF3keHuREOQqQiQ%3D"
},
"just@latest": { "just@latest": {
"last_modified": "2024-01-14T03:55:27Z", "last_modified": "2024-01-14T03:55:27Z",
"resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#just", "resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#just",
@@ -45,84 +41,23 @@
} }
} }
}, },
"nodejs@24": { "nodejs@latest": {
"last_modified": "2025-12-27T12:56:01Z", "last_modified": "2024-01-14T03:55:27Z",
"plugin_version": "0.0.2", "resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#nodejs_21",
"resolved": "github:NixOS/nixpkgs/3edc4a30ed3903fdf6f90c837f961fa6b49582d1#nodejs_24",
"source": "devbox-search", "source": "devbox-search",
"version": "24.12.0", "version": "21.5.0",
"systems": { "systems": {
"aarch64-darwin": { "aarch64-darwin": {
"outputs": [ "store_path": "/nix/store/ybpqk26vz7k9grapsgx0sd900s0sp4sa-nodejs-21.5.0"
{
"name": "out",
"path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/ksn4nj0nbp0ikmh4scy9x4biqqdswwhy-nodejs-24.12.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/3x72bm7l30ib7l7cximrp0sklcq8jqwy-nodejs-24.12.0-libv8"
}
],
"store_path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0"
}, },
"aarch64-linux": { "aarch64-linux": {
"outputs": [ "store_path": "/nix/store/brnzb5xxgdx6bbicygz83ybi5inqp09v-nodejs-21.5.0"
{
"name": "out",
"path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/7izhv9x1yvsmyrhmmyps6bxjjclajm80-nodejs-24.12.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/8hl74pnc1qyz68rg10ixw7fz32hcxqkk-nodejs-24.12.0-libv8"
}
],
"store_path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0"
}, },
"x86_64-darwin": { "x86_64-darwin": {
"outputs": [ "store_path": "/nix/store/yvgnx3lj8am9mqn30yr09sb4ia7qy3w8-nodejs-21.5.0"
{
"name": "out",
"path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/kzia3rkaf9vwpn3py23q9v49jpjm6zx7-nodejs-24.12.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/xszji81xj7ghjhz7jk0jkhb84nfwyd8f-nodejs-24.12.0-libv8"
}
],
"store_path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0"
}, },
"x86_64-linux": { "x86_64-linux": {
"outputs": [ "store_path": "/nix/store/nxfirpvaycr7wqzwl6wqifpdrqn7is7x-nodejs-21.5.0"
{
"name": "out",
"path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/5yvx909wlis13qd2y1ahwl7ij21ma69a-nodejs-24.12.0-libv8"
},
{
"name": "dev",
"path": "/nix/store/7ll99p08gmpg0n048669p80zjibl5akr-nodejs-24.12.0-dev"
}
],
"store_path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0"
} }
} }
}, },
-3
View File
@@ -1,3 +0,0 @@
const raycast = require("@raycast/eslint-config");
module.exports = [...raycast.flat()];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

+2315 -1941
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -5,8 +5,10 @@
"description": "Manage your notifications in a single Universal Inbox", "description": "Manage your notifications in a single Universal Inbox",
"icon": "ui-logo-transparent.png", "icon": "ui-logo-transparent.png",
"author": "dax42", "author": "dax42",
"version": "0.3.0", "version": "0.1.4",
"categories": ["Productivity"], "categories": [
"Productivity"
],
"license": "MIT", "license": "MIT",
"preferences": [ "preferences": [
{ {
@@ -35,24 +37,23 @@
} }
], ],
"dependencies": { "dependencies": {
"@raycast/api": "^1.104.19", "@raycast/api": "^1.65.1",
"@raycast/utils": "^2.2.6", "@raycast/utils": "^1.10.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.10",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"ts-pattern": "^5.9.0" "ts-pattern": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@raycast/eslint-config": "^2.1.1", "@raycast/eslint-config": "^1.0.6",
"@types/node": "^25.9.1", "@types/node": "20.8.10",
"@types/react": "19.0.10", "@types/react": "18.2.27",
"@typescript-eslint/parser": "^8.60.1", "@typescript-eslint/parser": "^6.19.1",
"eslint": "^9", "eslint": "^8.56.0",
"prettier": "^3.8.3", "eslint-import-resolver-typescript": "^3.6.1",
"prettier-plugin-sort-imports": "^1.8.11", "eslint-plugin-import": "^2.29.1",
"typescript": "^6.0.3" "prettier": "^3.0.3",
}, "prettier-plugin-sort-imports": "^1.8.3",
"engines": { "typescript": "^5.3.3"
"node": ">=22"
}, },
"scripts": { "scripts": {
"build": "ray build -e dist", "build": "ray build -e dist",
+2 -2
View File
@@ -41,7 +41,7 @@ export function CreateTaskFromNotification({ notification, mutate }: CreateTaskF
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>( const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/projects/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl}/api/tasks/projects/search?matches=${searchText}`,
{ {
keepPreviousData: true, keepPreviousData: true,
headers: { headers: {
@@ -123,7 +123,7 @@ async function createTaskFromNotification(
try { try {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}/task`, { fetch(`${preferences.universalInboxBaseUrl}/api/notifications/${notification.id}/task`, {
method: "POST", method: "POST",
body: JSON.stringify(taskCreation), body: JSON.stringify(taskCreation),
headers: { headers: {
+2 -2
View File
@@ -22,7 +22,7 @@ export function LinkNotificationToTask({ notification, mutate }: LinkNotificatio
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const { isLoading, data: tasks } = useFetch<Array<Task>>( const { isLoading, data: tasks } = useFetch<Array<Task>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl}/api/tasks/search?matches=${searchText}`,
{ {
keepPreviousData: true, keepPreviousData: true,
headers: { headers: {
@@ -78,7 +78,7 @@ async function linkNotificationToTask(
try { try {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/notifications/${notification.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Deleted, task_id: taskId }), body: JSON.stringify({ status: TaskStatus.Deleted, task_id: taskId }),
headers: { headers: {
+10 -10
View File
@@ -5,7 +5,7 @@ import { LinkNotificationToTask } from "./LinkNotificationToTask";
import { Page, UniversalInboxPreferences } from "../types"; import { Page, UniversalInboxPreferences } from "../types";
import { default as dayjs, extend } from "dayjs"; import { default as dayjs, extend } from "dayjs";
import { MutatePromise } from "@raycast/utils"; import { MutatePromise } from "@raycast/utils";
import { useMemo, ReactNode } from "react"; import { useMemo, ReactElement } from "react";
import { handleErrors } from "../api"; import { handleErrors } from "../api";
import { TaskStatus } from "../task"; import { TaskStatus } from "../task";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
@@ -15,7 +15,7 @@ extend(utc);
interface NotificationActionsProps { interface NotificationActionsProps {
notification: Notification; notification: Notification;
detailsTarget: ReactNode; detailsTarget: ReactElement;
mutate: MutatePromise<Page<Notification> | undefined>; mutate: MutatePromise<Page<Notification> | undefined>;
} }
@@ -26,8 +26,8 @@ export function NotificationActions({ notification, detailsTarget, mutate }: Not
return ( return (
<ActionPanel> <ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} /> <Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
<Action.OpenInBrowser url={notificationHtmlUrl} shortcut={{ modifiers: ["cmd"], key: "enter" }} />
<Action <Action
title="Delete Notification" title="Delete Notification"
icon={Icon.Trash} icon={Icon.Trash}
@@ -35,7 +35,7 @@ export function NotificationActions({ notification, detailsTarget, mutate }: Not
onAction={() => deleteNotification(notification, mutate)} onAction={() => deleteNotification(notification, mutate)}
/> />
<Action <Action
title="Unsubscribe from Notification" title="Unsubscribe From Notification"
icon={Icon.BellDisabled} icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }} shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification, mutate)} onAction={() => unsubscribeFromNotification(notification, mutate)}
@@ -47,13 +47,13 @@ export function NotificationActions({ notification, detailsTarget, mutate }: Not
onAction={() => snoozeNotification(notification, mutate)} onAction={() => snoozeNotification(notification, mutate)}
/> />
<Action.Push <Action.Push
title="Create Task" title="Create Task..."
icon={Icon.Calendar} icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "t" }} shortcut={{ modifiers: ["ctrl"], key: "t" }}
target={<CreateTaskFromNotification notification={notification} mutate={mutate} />} target={<CreateTaskFromNotification notification={notification} mutate={mutate} />}
/> />
<Action.Push <Action.Push
title="Link to Task" title="Link to Task..."
icon={Icon.Link} icon={Icon.Link}
shortcut={{ modifiers: ["ctrl"], key: "l" }} shortcut={{ modifiers: ["ctrl"], key: "l" }}
target={<LinkNotificationToTask notification={notification} mutate={mutate} />} target={<LinkNotificationToTask notification={notification} mutate={mutate} />}
@@ -72,7 +72,7 @@ export async function deleteNotification(
if (isNotificationBuiltFromTask(notification) && notification.task) { if (isNotificationBuiltFromTask(notification) && notification.task) {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/tasks/${notification.task.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Deleted }), body: JSON.stringify({ status: TaskStatus.Deleted }),
headers: { headers: {
@@ -93,7 +93,7 @@ export async function deleteNotification(
} else { } else {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/notifications/${notification.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: NotificationStatus.Deleted }), body: JSON.stringify({ status: NotificationStatus.Deleted }),
headers: { headers: {
@@ -132,7 +132,7 @@ export async function unsubscribeFromNotification(
try { try {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/notifications/${notification.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: NotificationStatus.Unsubscribed }), body: JSON.stringify({ status: NotificationStatus.Unsubscribed }),
headers: { headers: {
@@ -171,7 +171,7 @@ export async function snoozeNotification(
const snoozeTime = computeSnoozedUntil(new Date(), 1, 6); const snoozeTime = computeSnoozedUntil(new Date(), 1, 6);
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/notifications/${notification.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ snoozed_until: snoozeTime }), body: JSON.stringify({ snoozed_until: snoozeTime }),
headers: { headers: {
+6 -6
View File
@@ -3,7 +3,7 @@ import { Notification, getNotificationHtmlUrl, isNotificationBuiltFromTask } fro
import { Action, ActionPanel, Icon, Toast, getPreferenceValues, showToast } from "@raycast/api"; import { Action, ActionPanel, Icon, Toast, getPreferenceValues, showToast } from "@raycast/api";
import { Page, UniversalInboxPreferences } from "../types"; import { Page, UniversalInboxPreferences } from "../types";
import { MutatePromise } from "@raycast/utils"; import { MutatePromise } from "@raycast/utils";
import { useMemo, ReactNode } from "react"; import { useMemo, ReactElement } from "react";
import { PlanTask } from "./PlanTask"; import { PlanTask } from "./PlanTask";
import { handleErrors } from "../api"; import { handleErrors } from "../api";
import { TaskStatus } from "../task"; import { TaskStatus } from "../task";
@@ -11,7 +11,7 @@ import fetch from "node-fetch";
interface NotificationTaskActionsProps { interface NotificationTaskActionsProps {
notification: Notification; notification: Notification;
detailsTarget: ReactNode; detailsTarget: ReactElement;
mutate: MutatePromise<Page<Notification> | undefined>; mutate: MutatePromise<Page<Notification> | undefined>;
} }
@@ -22,8 +22,8 @@ export function NotificationTaskActions({ notification, detailsTarget, mutate }:
return ( return (
<ActionPanel> <ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} /> <Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
<Action.OpenInBrowser url={notificationHtmlUrl} shortcut={{ modifiers: ["cmd"], key: "enter" }} />
<Action <Action
title="Delete Notification" title="Delete Notification"
icon={Icon.Trash} icon={Icon.Trash}
@@ -31,7 +31,7 @@ export function NotificationTaskActions({ notification, detailsTarget, mutate }:
onAction={() => deleteNotification(notification, mutate)} onAction={() => deleteNotification(notification, mutate)}
/> />
<Action <Action
title="Unsubscribe from Notification" title="Unsubscribe From Notification"
icon={Icon.BellDisabled} icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }} shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification, mutate)} onAction={() => unsubscribeFromNotification(notification, mutate)}
@@ -49,7 +49,7 @@ export function NotificationTaskActions({ notification, detailsTarget, mutate }:
onAction={() => completeTask(notification, mutate)} onAction={() => completeTask(notification, mutate)}
/> />
<Action.Push <Action.Push
title="Plan Task" title="Plan Task..."
icon={Icon.Calendar} icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "t" }} shortcut={{ modifiers: ["ctrl"], key: "t" }}
target={<PlanTask notification={notification} mutate={mutate} />} target={<PlanTask notification={notification} mutate={mutate} />}
@@ -68,7 +68,7 @@ async function completeTask(notification: Notification, mutate: MutatePromise<Pa
try { try {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/tasks/${notification.task.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Done }), body: JSON.stringify({ status: TaskStatus.Done }),
headers: { headers: {
+2 -2
View File
@@ -39,7 +39,7 @@ export function PlanTask({ notification, mutate }: PlanTaskProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>( const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/projects/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl}/api/tasks/projects/search?matches=${searchText}`,
{ {
keepPreviousData: true, keepPreviousData: true,
headers: { headers: {
@@ -122,7 +122,7 @@ async function planTask(
try { try {
await mutate( await mutate(
handleErrors( handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl}/api/tasks/${notification.task.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
project: taskPlanning.project.name, project: taskPlanning.project.name,
+10 -26
View File
@@ -1,14 +1,10 @@
import { GoogleCalendarNotificationListItem } from "./integrations/google-calendar/listitem/GoogleCalendarNotificationListItem";
import { GoogleDriveNotificationListItem } from "./integrations/google-drive/listitem/GoogleDriveNotificationListItem";
import { Action, ActionPanel, Detail, Icon, List, getPreferenceValues, openExtensionPreferences } from "@raycast/api"; import { Action, ActionPanel, Detail, Icon, List, getPreferenceValues, openExtensionPreferences } from "@raycast/api";
import { GoogleMailNotificationListItem } from "./integrations/google-mail/listitem/GoogleMailNotificationListItem"; import { GoogleMailNotificationListItem } from "./integrations/google-mail/listitem/GoogleMailNotificationListItem";
import { TickTickNotificationListItem } from "./integrations/ticktick/listitem/TickTickNotificationListItem";
import { TodoistNotificationListItem } from "./integrations/todoist/listitem/TodoistNotificationListItem"; import { TodoistNotificationListItem } from "./integrations/todoist/listitem/TodoistNotificationListItem";
import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem";
import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem"; import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem";
import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem"; import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem";
import { Notification, NotificationListItemProps, NotificationSourceKind } from "./notification"; import { Notification, NotificationListItemProps } from "./notification";
import { APINotificationListItem } from "./integrations/api/listitem/APINotificationListItem";
import { NotificationActions } from "./action/NotificationActions"; import { NotificationActions } from "./action/NotificationActions";
import { Page, UniversalInboxPreferences } from "./types"; import { Page, UniversalInboxPreferences } from "./types";
import { useFetch } from "@raycast/utils"; import { useFetch } from "@raycast/utils";
@@ -37,7 +33,7 @@ export default function Command() {
const [notificationKind, setNotificationKind] = useState(""); const [notificationKind, setNotificationKind] = useState("");
const { isLoading, data, mutate } = useFetch<Page<Notification>>( const { isLoading, data, mutate } = useFetch<Page<Notification>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications?status=Unread,Read&with_tasks=true${ `${preferences.universalInboxBaseUrl}/api/notifications?status=Unread,Read&with_tasks=true${
notificationKind ? "&notification_kind=" + notificationKind : "" notificationKind ? "&notification_kind=" + notificationKind : ""
}`, }`,
{ {
@@ -71,25 +67,17 @@ export default function Command() {
} }
function NotificationListItem({ notification, mutate }: NotificationListItemProps) { function NotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.kind) { switch (notification.metadata.type) {
case NotificationSourceKind.Github: case "Github":
return <GithubNotificationListItem notification={notification} mutate={mutate} />; return <GithubNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Linear: case "Linear":
return <LinearNotificationListItem notification={notification} mutate={mutate} />; return <LinearNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.GoogleMail: case "GoogleMail":
return <GoogleMailNotificationListItem notification={notification} mutate={mutate} />; return <GoogleMailNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Slack: case "Slack":
return <SlackNotificationListItem notification={notification} mutate={mutate} />; return <SlackNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Todoist: case "Todoist":
return <TodoistNotificationListItem notification={notification} mutate={mutate} />; return <TodoistNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.TickTick:
return <TickTickNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.GoogleCalendar:
return <GoogleCalendarNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.GoogleDrive:
return <GoogleDriveNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.API:
return <APINotificationListItem notification={notification} mutate={mutate} />;
default: default:
return <DefaultNotificationListItem notification={notification} mutate={mutate} />; return <DefaultNotificationListItem notification={notification} mutate={mutate} />;
} }
@@ -100,7 +88,7 @@ function DefaultNotificationListItem({ notification, mutate }: NotificationListI
<List.Item <List.Item
key={notification.id} key={notification.id}
title={notification.title} title={notification.title}
subtitle={`#${notification.source_item.source_id}`} subtitle={`#${notification.source_id}`}
actions={ actions={
<NotificationActions <NotificationActions
notification={notification} notification={notification}
@@ -127,10 +115,6 @@ function NotificationKindDropdown({ value, onNotificationKindChange }: Notificat
<List.Dropdown.Item key="GoogleMail" title="Google Mail" value="GoogleMail" /> <List.Dropdown.Item key="GoogleMail" title="Google Mail" value="GoogleMail" />
<List.Dropdown.Item key="Slack" title="Slack" value="Slack" /> <List.Dropdown.Item key="Slack" title="Slack" value="Slack" />
<List.Dropdown.Item key="Todoist" title="Todoist" value="Todoist" /> <List.Dropdown.Item key="Todoist" title="Todoist" value="Todoist" />
<List.Dropdown.Item key="TickTick" title="TickTick" value="TickTick" />
<List.Dropdown.Item key="GoogleCalendar" title="Google Calendar" value="GoogleCalendar" />
<List.Dropdown.Item key="GoogleDrive" title="Google Drive" value="GoogleDrive" />
<List.Dropdown.Item key="API" title="API" value="API" />
</List.Dropdown.Section> </List.Dropdown.Section>
</List.Dropdown> </List.Dropdown>
); );
@@ -1,35 +0,0 @@
import { APINotificationPreview } from "../preview/APINotificationPreview";
import { NotificationActions } from "../../../action/NotificationActions";
import { NotificationListItemProps } from "../../../notification";
import { Icon, List } from "@raycast/api";
import { WebPage } from "../types";
export function APINotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "WebPage") return null;
const webPage: WebPage = notification.source_item.data.content;
const accessories: List.Item.Accessory[] = [
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
return (
<List.Item
key={notification.id}
title={notification.title}
icon={Icon.Globe}
subtitle={webPage.url}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<APINotificationPreview notification={notification} webPage={webPage} />}
mutate={mutate}
/>
}
/>
);
}
@@ -1,38 +0,0 @@
import { formatDateTime, formatElapsedTime } from "../../../utils";
import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Notification } from "../../../notification";
import { Detail } from "@raycast/api";
import { WebPage } from "../types";
interface APINotificationPreviewProps {
notification: Notification;
webPage: WebPage;
}
function getHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
export function APINotificationPreview({ notification, webPage }: APINotificationPreviewProps) {
const sourceLabel = webPage.source === "universalinboxextension" ? "Universal Inbox extension" : webPage.source;
const markdown = `# ${webPage.title || notification.title}\n\n[${webPage.url}](${webPage.url})`;
const metadata = (
<>
<Detail.Metadata.Link title="Host" target={webPage.url} text={getHost(webPage.url)} />
<Detail.Metadata.Label title="Source" text={sourceLabel} />
{webPage.timestamp ? (
<Detail.Metadata.Label
title="Captured"
text={`${formatDateTime(webPage.timestamp)} (${formatElapsedTime(webPage.timestamp)})`}
/>
) : null}
</>
);
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
}
-13
View File
@@ -1,13 +0,0 @@
export type APISource = "universalinboxextension" | string;
export interface WebPage {
url: string;
title: string;
timestamp: string;
source: APISource;
favicon?: string;
}
export function getWebPageHtmlUrl(webPage: WebPage): string {
return webPage.url;
}
@@ -3,14 +3,12 @@ import { GithubDiscussionNotificationListItem } from "./GithubDiscussionNotifica
import { NotificationListItemProps } from "../../../notification"; import { NotificationListItemProps } from "../../../notification";
export function GithubNotificationListItem({ notification, mutate }: NotificationListItemProps) { export function GithubNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GithubNotification") return null; switch (notification.details?.type) {
switch (notification.source_item.data.content.item?.type) {
case "GithubPullRequest": case "GithubPullRequest":
return ( return (
<GithubPullRequestNotificationListItem <GithubPullRequestNotificationListItem
notification={notification} notification={notification}
githubPullRequest={notification.source_item.data.content.item.content} githubPullRequest={notification.details.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -18,7 +16,7 @@ export function GithubNotificationListItem({ notification, mutate }: Notificatio
return ( return (
<GithubDiscussionNotificationListItem <GithubDiscussionNotificationListItem
notification={notification} notification={notification}
githubDiscussion={notification.source_item.data.content.item.content} githubDiscussion={notification.details.content}
mutate={mutate} mutate={mutate}
/> />
); );
-42
View File
@@ -1,42 +0,0 @@
import { GithubCheckConclusionState, GithubCheckStatusState, GithubPullRequestReviewState } from "./types";
/** Emoji for a GitHub check run based on its conclusion / status. */
export function checkRunEmoji(conclusion?: GithubCheckConclusionState, status?: GithubCheckStatusState): string {
if (status && status !== GithubCheckStatusState.Completed) {
return "⏳";
}
switch (conclusion) {
case GithubCheckConclusionState.Success:
return "✅";
case GithubCheckConclusionState.Failure:
case GithubCheckConclusionState.StartupFailure:
case GithubCheckConclusionState.TimedOut:
return "❌";
case GithubCheckConclusionState.ActionRequired:
return "⚠️";
case GithubCheckConclusionState.Cancelled:
case GithubCheckConclusionState.Skipped:
case GithubCheckConclusionState.Stale:
return "⚪";
case GithubCheckConclusionState.Neutral:
return "";
default:
return "🟡";
}
}
/** Emoji for a GitHub pull request review state. */
export function reviewStateEmoji(state: GithubPullRequestReviewState): string {
switch (state) {
case GithubPullRequestReviewState.Approved:
return "✅";
case GithubPullRequestReviewState.ChangesRequested:
return "🔴";
case GithubPullRequestReviewState.Commented:
return "💬";
case GithubPullRequestReviewState.Dismissed:
return "🚫";
case GithubPullRequestReviewState.Pending:
return "⏳";
}
}
@@ -1,45 +1,26 @@
import { getGithubActorName, GithubDiscussion } from "../types"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { cleanHtml, formatElapsedTime } from "../../../utils"; import { GithubDiscussion } from "../types";
import { Notification } from "../../../notification"; import { useMemo } from "react";
import { Detail } from "@raycast/api";
interface GithubDiscussionPreviewProps { interface GithubDiscussionPreviewProps {
notification: Notification; notification: Notification;
githubDiscussion: GithubDiscussion; githubDiscussion: GithubDiscussion;
} }
export function GithubDiscussionPreview({ notification, githubDiscussion: discussion }: GithubDiscussionPreviewProps) { export function GithubDiscussionPreview({ notification, githubDiscussion }: GithubDiscussionPreviewProps) {
let markdown = `# ${discussion.title} #${discussion.number}`; const notificationHtmlUrl = useMemo(() => {
if (discussion.author) { return getNotificationHtmlUrl(notification);
markdown += `\n\n_Opened by ${getGithubActorName(discussion.author)} · ${formatElapsedTime(discussion.created_at)}_`; }, [notification]);
}
if (discussion.body) {
markdown += `\n\n${cleanHtml(discussion.body)}`;
}
if (discussion.answer) {
markdown += `\n\n---\n\n## ✅ Answer`;
markdown += `\n\n**${getGithubActorName(discussion.answer.author)}**\n\n${cleanHtml(discussion.answer.body)}`;
}
const metadata = ( return (
<> <Detail
<Detail.Metadata.Label title="Repository" text={discussion.repository.name_with_owner} /> markdown={`# ${githubDiscussion.title}`}
{discussion.state_reason ? <Detail.Metadata.Label title="State" text={discussion.state_reason} /> : null} actions={
<Detail.Metadata.Label title="Comments" text={`${discussion.comments_count}`} /> <ActionPanel>
<Detail.Metadata.Label title="Updated" text={formatElapsedTime(discussion.updated_at)} /> <Action.OpenInBrowser url={notificationHtmlUrl} />
{discussion.answer_chosen_by ? ( </ActionPanel>
<Detail.Metadata.Label title="Answered by" text={getGithubActorName(discussion.answer_chosen_by)} /> }
) : null} />
{discussion.labels.length > 0 ? (
<Detail.Metadata.TagList title="Labels">
{discussion.labels.map((label) => (
<Detail.Metadata.TagList.Item key={label.name} text={label.name} color={`#${label.color}`} />
))}
</Detail.Metadata.TagList>
) : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
@@ -1,103 +1,26 @@
import { getGithubActorName, GithubPullRequest, GithubPullRequestState, GithubReviewer } from "../types"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { cleanHtml, formatElapsedTime } from "../../../utils"; import { GithubPullRequest } from "../types";
import { checkRunEmoji, reviewStateEmoji } from "../markdown"; import { useMemo } from "react";
import { Notification } from "../../../notification";
import { Color, Detail } from "@raycast/api";
interface GithubPullRequestPreviewProps { interface GithubPullRequestPreviewProps {
notification: Notification; notification: Notification;
githubPullRequest: GithubPullRequest; githubPullRequest: GithubPullRequest;
} }
function stateTag(pr: GithubPullRequest): { text: string; color: Color } { export function GithubPullRequestPreview({ notification, githubPullRequest }: GithubPullRequestPreviewProps) {
if (pr.state === GithubPullRequestState.Merged) return { text: "Merged", color: Color.Purple }; const notificationHtmlUrl = useMemo(() => {
if (pr.state === GithubPullRequestState.Closed) return { text: "Closed", color: Color.Red }; return getNotificationHtmlUrl(notification);
if (pr.is_draft) return { text: "Draft", color: Color.SecondaryText }; }, [notification]);
return { text: "Open", color: Color.Green };
}
function reviewerName(reviewer: GithubReviewer): string { return (
switch (reviewer.type) { <Detail
case "GithubUserSummary": markdown={`# ${githubPullRequest.title}`}
return reviewer.content.name || reviewer.content.login; actions={
case "GithubBotSummary": <ActionPanel>
case "GithubMannequinSummary": <Action.OpenInBrowser url={notificationHtmlUrl} />
return reviewer.content.login; </ActionPanel>
case "GithubTeamSummary":
return reviewer.content.name;
} }
} />
export function GithubPullRequestPreview({ notification, githubPullRequest: pr }: GithubPullRequestPreviewProps) {
let markdown = `# ${pr.title} #${pr.number}`;
if (pr.body) {
markdown += `\n\n${cleanHtml(pr.body)}`;
}
const checkRuns = pr.latest_commit.check_suites?.flatMap((suite) => suite.check_runs) ?? [];
if (checkRuns.length > 0) {
markdown += `\n\n---\n\n## Checks\n\n`;
markdown += checkRuns.map((run) => `${checkRunEmoji(run.conclusion, run.status)} ${run.name}`).join("\n");
}
if (pr.reviews.length > 0) {
markdown += `\n\n---\n\n## Reviews\n\n`;
markdown += pr.reviews
.map((review) => {
const head = `${reviewStateEmoji(review.state)} **${getGithubActorName(review.author)}**`;
return review.body ? `${head}\n\n${cleanHtml(review.body)}` : head;
})
.join("\n\n");
}
if (pr.comments.length > 0) {
markdown += `\n\n---\n\n## Comments\n\n`;
markdown += pr.comments
.map(
(comment) =>
`**${getGithubActorName(comment.author)}** · ${formatElapsedTime(comment.created_at)}\n\n${cleanHtml(comment.body)}`,
)
.join("\n\n---\n\n");
}
const state = stateTag(pr);
const metadata = (
<>
<Detail.Metadata.TagList title="Status">
<Detail.Metadata.TagList.Item text={state.text} color={state.color} />
</Detail.Metadata.TagList>
{pr.state === GithubPullRequestState.Open ? (
<Detail.Metadata.Label title="Mergeable" text={`${pr.mergeable_state} · ${pr.merge_state_status}`} />
) : null}
<Detail.Metadata.Label title="Changes" text={`+${pr.additions} ${pr.deletions} in ${pr.changed_files} files`} />
<Detail.Metadata.Label title="Branch" text={`${pr.head_ref_name}${pr.base_ref_name}`} />
{pr.review_decision ? <Detail.Metadata.Label title="Review" text={pr.review_decision} /> : null}
{pr.author ? <Detail.Metadata.Label title="Author" text={getGithubActorName(pr.author)} /> : null}
{pr.assignees.length > 0 ? (
<Detail.Metadata.TagList title="Assignees">
{pr.assignees.map((assignee, index) => (
<Detail.Metadata.TagList.Item key={index} text={getGithubActorName(assignee)} />
))}
</Detail.Metadata.TagList>
) : null}
{pr.review_requests.length > 0 ? (
<Detail.Metadata.TagList title="Review requests">
{pr.review_requests.map((reviewer, index) => (
<Detail.Metadata.TagList.Item key={index} text={reviewerName(reviewer)} />
))}
</Detail.Metadata.TagList>
) : null}
{pr.labels.length > 0 ? (
<Detail.Metadata.TagList title="Labels">
{pr.labels.map((label) => (
<Detail.Metadata.TagList.Item key={label.name} text={label.name} color={`#${label.color}`} />
))}
</Detail.Metadata.TagList>
) : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
-57
View File
@@ -1,60 +1,3 @@
import { match, P } from "ts-pattern";
export interface GithubNotification {
id: string;
repository: GithubRepositorySummary;
subject: GithubNotificationSubject;
reason: string;
unread: boolean;
updated_at: Date;
last_read_at?: Date;
url: string;
subscription_url: string;
item?: GithubNotificationItem;
}
export function getGithubNotificationHtmlUrl(notification: GithubNotification): string {
return match(notification.item)
.with({ type: "GithubPullRequest", content: P.select() }, (pr) => pr.url)
.with({ type: "GithubDiscussion", content: P.select() }, (discussion) => discussion.url)
.otherwise(() => getHtmlUrlFromApiUrl(notification.subject.url) ?? getHtmlUrlFromMetadata(notification));
}
function getHtmlUrlFromApiUrl(apiUrl: string | undefined): string | undefined {
if (!apiUrl) {
return undefined;
}
const url = new URL(apiUrl);
if (url.host === "api.github.com" && url.pathname.startsWith("/repos")) {
const result = new URL(apiUrl);
result.host = "github.com";
result.pathname = url.pathname.replace("/repos", "").replace("/pulls/", "/pull/");
return result.toString();
}
return undefined;
}
function getHtmlUrlFromMetadata(notification: GithubNotification): string {
return match(notification.subject.type)
.with("CheckSuite", () => {
const result = new URL(notification.repository.url);
result.pathname = `${result.pathname}/actions`;
return result.toString();
})
.otherwise(() => notification.repository.url);
}
export interface GithubNotificationSubject {
title: string;
url?: string;
latest_comment_url?: string;
type: string;
}
export type GithubNotificationItem =
| { type: "GithubPullRequest"; content: GithubPullRequest }
| { type: "GithubDiscussion"; content: GithubDiscussion };
export interface GithubPullRequest { export interface GithubPullRequest {
id: string; id: string;
number: number; number: number;
@@ -1,59 +0,0 @@
import { GoogleCalendarEventPreview } from "../preview/GoogleCalendarEventPreview";
import { GoogleCalendarEvent, getEventStartDisplay, getMeetLink } from "../types";
import { NotificationActions } from "../../../action/NotificationActions";
import { NotificationListItemProps } from "../../../notification";
import { Icon, Color, List } from "@raycast/api";
export function GoogleCalendarNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GoogleCalendarEvent") return null;
const event: GoogleCalendarEvent = notification.source_item.data.content;
const subtitle = getEventStartDisplay(event);
const meetLink = getMeetLink(event);
const accessories: List.Item.Accessory[] = [
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
if (event.attendees.length > 0) {
accessories.unshift({
text: `${event.attendees.length}`,
icon: Icon.Person,
tooltip: `${event.attendees.length} attendee(s)`,
});
}
if (meetLink) {
accessories.unshift({
icon: { source: Icon.Video, tintColor: Color.Green },
tooltip: "Google Meet link available",
});
}
if (event.status === "cancelled") {
accessories.unshift({
icon: { source: Icon.XMarkCircle, tintColor: Color.Red },
tooltip: "Cancelled",
});
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={Icon.Calendar}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<GoogleCalendarEventPreview notification={notification} event={event} />}
mutate={mutate}
/>
}
/>
);
}
@@ -1,58 +0,0 @@
import { GoogleCalendarEvent, getEventStartDisplay, getMeetLink } from "../types";
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { getThirdPartyItemSourceLabel } from "../../../third_party_item";
import { Detail, ActionPanel, Action, Color } from "@raycast/api";
import { useMemo } from "react";
interface GoogleCalendarEventPreviewProps {
notification: Notification;
event: GoogleCalendarEvent;
}
export function GoogleCalendarEventPreview({ notification, event }: GoogleCalendarEventPreviewProps) {
const notificationHtmlUrl = useMemo(() => getNotificationHtmlUrl(notification), [notification]);
const meetLink = getMeetLink(event);
const endDisplay = event.end.dateTime ? new Date(event.end.dateTime).toLocaleString() : (event.end.date ?? "");
const attendeeLines = event.attendees
.map((a) => {
const name = a.displayName ?? a.email;
const status = a.responseStatus === "accepted" ? "✓" : a.responseStatus === "declined" ? "✗" : "?";
return `- ${status} ${name}`;
})
.join("\n");
const markdown = [
`# ${event.summary}`,
event.description ? `\n${event.description}` : null,
attendeeLines ? `\n## Attendees\n\n${attendeeLines}` : null,
]
.filter(Boolean)
.join("\n");
const statusColor =
event.status === "cancelled" ? Color.Red : event.status === "tentative" ? Color.Yellow : Color.Green;
const metadata = (
<Detail.Metadata>
<Detail.Metadata.Label title="Type" text={getThirdPartyItemSourceLabel(notification.source_item)} />
<Detail.Metadata.Separator />
<Detail.Metadata.TagList title="Status">
<Detail.Metadata.TagList.Item text={event.status} color={statusColor} />
</Detail.Metadata.TagList>
<Detail.Metadata.Label title="Start" text={getEventStartDisplay(event)} />
<Detail.Metadata.Label title="End" text={endDisplay} />
{event.location ? <Detail.Metadata.Label title="Location" text={event.location} /> : null}
{meetLink ? <Detail.Metadata.Link title="Meet" target={meetLink} text="Join" /> : null}
<Detail.Metadata.Label title="Attendees" text={`${event.attendees.length}`} />
</Detail.Metadata>
);
const actions = [<Action.OpenInBrowser key="open" url={notificationHtmlUrl} />];
if (meetLink) {
actions.push(<Action.OpenInBrowser key="meet" title="Join Meeting" url={meetLink} />);
}
return <Detail markdown={markdown} metadata={metadata} actions={<ActionPanel>{actions}</ActionPanel>} />;
}
-64
View File
@@ -1,64 +0,0 @@
export interface EventDateTime {
dateTime?: string;
date?: string;
timeZone?: string;
}
export interface EventAttendee {
email: string;
displayName?: string;
responseStatus: "needsAction" | "declined" | "tentative" | "accepted";
self?: boolean;
organizer?: boolean;
}
export interface EntryPoint {
entryPointType: "video" | "phone" | "sip" | "more";
uri: string;
label?: string;
}
export interface ConferenceSolution {
name: string;
iconUri?: string;
}
export interface ConferenceData {
entryPoints?: EntryPoint[];
conferenceSolution?: ConferenceSolution;
}
export interface GoogleCalendarEvent {
id: string;
summary: string;
htmlLink: string;
hangoutLink?: string;
start: EventDateTime;
end: EventDateTime;
attendees: EventAttendee[];
status: "confirmed" | "tentative" | "cancelled";
eventType: string;
conferenceData?: ConferenceData;
description?: string;
location?: string;
}
export function getGoogleCalendarEventHtmlUrl(event: GoogleCalendarEvent): string {
return event.htmlLink;
}
export function getEventStartDisplay(event: GoogleCalendarEvent): string {
if (event.start.dateTime) {
return new Date(event.start.dateTime).toLocaleString();
}
if (event.start.date) {
return event.start.date;
}
return "";
}
export function getMeetLink(event: GoogleCalendarEvent): string | undefined {
if (event.hangoutLink) return event.hangoutLink;
const meetEntry = event.conferenceData?.entryPoints?.find((ep) => ep.entryPointType === "video");
return meetEntry?.uri;
}
@@ -1,50 +0,0 @@
import { GoogleDriveCommentPreview } from "../preview/GoogleDriveCommentPreview";
import { NotificationActions } from "../../../action/NotificationActions";
import { NotificationListItemProps } from "../../../notification";
import { Icon, Color, List } from "@raycast/api";
import { GoogleDriveComment } from "../types";
export function GoogleDriveNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GoogleDriveComment") return null;
const comment: GoogleDriveComment = notification.source_item.data.content;
const accessories: List.Item.Accessory[] = [
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
if (comment.resolved) {
accessories.unshift({
icon: { source: Icon.CheckCircle, tintColor: Color.Green },
tooltip: "Resolved",
});
}
if (comment.replies.length > 0) {
accessories.unshift({
text: `${comment.replies.length}`,
icon: Icon.Message,
tooltip: `${comment.replies.length} repl${comment.replies.length === 1 ? "y" : "ies"}`,
});
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={Icon.Document}
subtitle={comment.file_name}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<GoogleDriveCommentPreview notification={notification} comment={comment} />}
mutate={mutate}
/>
}
/>
);
}
@@ -1,44 +0,0 @@
import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Notification } from "../../../notification";
import { GoogleDriveComment } from "../types";
import { Color, Detail } from "@raycast/api";
interface GoogleDriveCommentPreviewProps {
notification: Notification;
comment: GoogleDriveComment;
}
export function GoogleDriveCommentPreview({ notification, comment }: GoogleDriveCommentPreviewProps) {
const quotedSection = comment.quoted_file_content
? `> ${comment.quoted_file_content.split("\n").join("\n> ")}\n\n`
: "";
const replyLines = comment.replies.map((r) => `**${r.author.display_name}:** ${r.content}`).join("\n\n");
const markdown = [
`# ${comment.file_name}`,
``,
`**Comment by ${comment.author.display_name}:**`,
``,
quotedSection + comment.content,
replyLines ? `\n---\n\n### Replies\n\n${replyLines}` : null,
]
.filter((line) => line !== null)
.join("\n");
const metadata = (
<>
<Detail.Metadata.Label title="File" text={comment.file_name} />
<Detail.Metadata.Label title="Author" text={comment.author.display_name} />
<Detail.Metadata.Label title="Replies" text={`${comment.replies.length}`} />
<Detail.Metadata.TagList title="Status">
<Detail.Metadata.TagList.Item
text={comment.resolved ? "Resolved" : "Open"}
color={comment.resolved ? Color.Green : Color.Yellow}
/>
</Detail.Metadata.TagList>
</>
);
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
}
-34
View File
@@ -1,34 +0,0 @@
export interface GoogleDriveCommentAuthor {
display_name: string;
email_address?: string;
photo_link?: string;
}
export interface GoogleDriveCommentReply {
id: string;
content: string;
html_content?: string;
author: GoogleDriveCommentAuthor;
created_time: string;
modified_time: string;
action?: string;
}
export interface GoogleDriveComment {
id: string;
file_id: string;
file_name: string;
file_mime_type: string;
content: string;
html_content?: string;
quoted_file_content?: string;
author: GoogleDriveCommentAuthor;
created_time: string;
modified_time: string;
resolved?: boolean;
replies: GoogleDriveCommentReply[];
}
export function getGoogleDriveCommentHtmlUrl(comment: GoogleDriveComment): string {
return `https://drive.google.com/file/d/${comment.file_id}/view`;
}
@@ -2,12 +2,12 @@ import { GoogleMailThreadListItem } from "./GoogleMailThreadListItem";
import { NotificationListItemProps } from "../../../notification"; import { NotificationListItemProps } from "../../../notification";
export function GoogleMailNotificationListItem({ notification, mutate }: NotificationListItemProps) { export function GoogleMailNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GoogleMailThread") return null; if (notification.metadata.type !== "GoogleMail") return null;
return ( return (
<GoogleMailThreadListItem <GoogleMailThreadListItem
notification={notification} notification={notification}
googleMailThread={notification.source_item.data.content} googleMailThread={notification.metadata.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -1,5 +1,6 @@
import { GoogleMailThreadPreview } from "../preview/GoogleMailThreadPreview"; import { GoogleMailThreadPreview } from "../preview/GoogleMailThreadPreview";
import { NotificationActions } from "../../../action/NotificationActions"; import { NotificationActions } from "../../../action/NotificationActions";
//import { getLinearUserAccessory } from "../accessories";
import { Notification } from "../../../notification"; import { Notification } from "../../../notification";
import { Icon, Color, List } from "@raycast/api"; import { Icon, Color, List } from "@raycast/api";
import { MutatePromise } from "@raycast/utils"; import { MutatePromise } from "@raycast/utils";
@@ -1,49 +1,26 @@
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { GoogleMailMessage, GoogleMailThread } from "../types"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { Notification } from "../../../notification"; import { GoogleMailThread } from "../types";
import { formatDateTime } from "../../../utils"; import { useMemo } from "react";
import { Color, Detail } from "@raycast/api";
interface GoogleMailThreadPreviewProps { interface GoogleMailThreadPreviewProps {
notification: Notification; notification: Notification;
googleMailThread: GoogleMailThread; googleMailThread: GoogleMailThread;
} }
function header(message: GoogleMailMessage, name: string): string | undefined { export function GoogleMailThreadPreview({ notification }: GoogleMailThreadPreviewProps) {
return message.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value; const notificationHtmlUrl = useMemo(() => {
} return getNotificationHtmlUrl(notification);
}, [notification]);
export function GoogleMailThreadPreview({ notification, googleMailThread }: GoogleMailThreadPreviewProps) { return (
const messages = googleMailThread.messages; <Detail
const firstMessage = messages[0]; markdown={`# ${notification.title}`}
const subject = (firstMessage && header(firstMessage, "Subject")) || notification.title; actions={
<ActionPanel>
const isStarred = messages.some((m) => m.labelIds?.includes("STARRED")); <Action.OpenInBrowser url={notificationHtmlUrl} />
const isImportant = messages.some((m) => m.labelIds?.includes("IMPORTANT")); </ActionPanel>
}
const body = messages />
.map((message) => {
const from = header(message, "From") ?? "Unknown sender";
const date = formatDateTime(message.internalDate);
return `**${from}** · ${date}\n\n${message.snippet}`;
})
.join("\n\n---\n\n");
const markdown = `# ${subject}\n\n${body}`;
const metadata = (
<>
{firstMessage ? <Detail.Metadata.Label title="From" text={header(firstMessage, "From") ?? "Unknown"} /> : null}
<Detail.Metadata.Label title="Subject" text={subject} />
<Detail.Metadata.Label title="Messages" text={`${messages.length}`} />
{isStarred || isImportant ? (
<Detail.Metadata.TagList title="Labels">
{isStarred ? <Detail.Metadata.TagList.Item text="Starred" color={Color.Yellow} /> : null}
{isImportant ? <Detail.Metadata.TagList.Item text="Important" color={Color.Red} /> : null}
</Detail.Metadata.TagList>
) : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
@@ -3,14 +3,14 @@ import { LinearIssueNotificationListItem } from "./LinearIssueNotificationListIt
import { NotificationListItemProps } from "../../../notification"; import { NotificationListItemProps } from "../../../notification";
export function LinearNotificationListItem({ notification, mutate }: NotificationListItemProps) { export function LinearNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "LinearNotification") return null; if (notification.metadata.type !== "Linear") return null;
switch (notification.source_item.data.content.type) { switch (notification.metadata.content.type) {
case "IssueNotification": case "IssueNotification":
return ( return (
<LinearIssueNotificationListItem <LinearIssueNotificationListItem
notification={notification} notification={notification}
linearIssueNotification={notification.source_item.data.content.content} linearIssueNotification={notification.metadata.content.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -18,7 +18,7 @@ export function LinearNotificationListItem({ notification, mutate }: Notificatio
return ( return (
<LinearProjectNotificationListItem <LinearProjectNotificationListItem
notification={notification} notification={notification}
linearProjectNotification={notification.source_item.data.content.content} linearProjectNotification={notification.metadata.content.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -1,85 +1,26 @@
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { formatDate, formatElapsedTime } from "../../../utils"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { LinearIssue, LinearIssuePriority } from "../types"; import { LinearIssue } from "../types";
import { Notification } from "../../../notification"; import { useMemo } from "react";
import { Color, Detail, Image } from "@raycast/api";
interface LinearIssuePreviewProps { interface LinearIssuePreviewProps {
notification: Notification; notification: Notification;
linearIssue: LinearIssue; linearIssue: LinearIssue;
} }
const PRIORITY_LABEL: Record<LinearIssuePriority, string> = {
[LinearIssuePriority.NoPriority]: "No priority",
[LinearIssuePriority.Urgent]: "Urgent",
[LinearIssuePriority.High]: "High",
[LinearIssuePriority.Normal]: "Normal",
[LinearIssuePriority.Low]: "Low",
};
const PRIORITY_COLOR: Record<LinearIssuePriority, Color> = {
[LinearIssuePriority.NoPriority]: Color.SecondaryText,
[LinearIssuePriority.Urgent]: Color.Red,
[LinearIssuePriority.High]: Color.Orange,
[LinearIssuePriority.Normal]: Color.Yellow,
[LinearIssuePriority.Low]: Color.Blue,
};
function userIcon(avatarUrl?: string): Image.ImageLike | undefined {
return avatarUrl ? { source: avatarUrl, mask: Image.Mask.Circle } : undefined;
}
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) { export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) {
let markdown = `# ${linearIssue.identifier} ${linearIssue.title}`; const notificationHtmlUrl = useMemo(() => {
if (linearIssue.creator) { return getNotificationHtmlUrl(notification);
markdown += `\n\n_Opened by ${linearIssue.creator.name} · ${formatElapsedTime(linearIssue.created_at)}_`; }, [notification]);
}
if (linearIssue.description) {
markdown += `\n\n${linearIssue.description}`;
}
const metadata = ( return (
<> <Detail
<Detail.Metadata.TagList title="Status"> markdown={`# ${linearIssue.title}`}
<Detail.Metadata.TagList.Item text={linearIssue.state.name} color={linearIssue.state.color} /> actions={
</Detail.Metadata.TagList> <ActionPanel>
<Detail.Metadata.TagList title="Priority"> <Action.OpenInBrowser url={notificationHtmlUrl} />
<Detail.Metadata.TagList.Item </ActionPanel>
text={PRIORITY_LABEL[linearIssue.priority]} }
color={PRIORITY_COLOR[linearIssue.priority]}
/> />
</Detail.Metadata.TagList>
{linearIssue.assignee ? (
<Detail.Metadata.Label
title="Assignee"
text={linearIssue.assignee.name}
icon={userIcon(linearIssue.assignee.avatar_url)}
/>
) : null}
{linearIssue.creator ? (
<Detail.Metadata.Label
title="Creator"
text={linearIssue.creator.name}
icon={userIcon(linearIssue.creator.avatar_url)}
/>
) : null}
{linearIssue.project ? (
<Detail.Metadata.Link title="Project" target={linearIssue.project.url} text={linearIssue.project.name} />
) : null}
{linearIssue.project_milestone ? (
<Detail.Metadata.Label title="Milestone" text={linearIssue.project_milestone.name} />
) : null}
<Detail.Metadata.Label title="Team" text={linearIssue.team.name} />
{linearIssue.due_date ? <Detail.Metadata.Label title="Due" text={formatDate(linearIssue.due_date)} /> : null}
{linearIssue.labels.length > 0 ? (
<Detail.Metadata.TagList title="Labels">
{linearIssue.labels.map((label) => (
<Detail.Metadata.TagList.Item key={label.name} text={label.name} color={label.color} />
))}
</Detail.Metadata.TagList>
) : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
@@ -1,60 +1,26 @@
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { LinearProject, LinearProjectState } from "../types"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { Notification } from "../../../notification"; import { LinearProject } from "../types";
import { Color, Detail, Image } from "@raycast/api"; import { useMemo } from "react";
import { formatDate } from "../../../utils";
interface LinearProjectPreviewProps { interface LinearProjectPreviewProps {
notification: Notification; notification: Notification;
linearProject: LinearProject; linearProject: LinearProject;
} }
const STATE_COLOR: Record<LinearProjectState, Color> = {
[LinearProjectState.Planned]: Color.Blue,
[LinearProjectState.Backlog]: Color.SecondaryText,
[LinearProjectState.Started]: Color.Yellow,
[LinearProjectState.Paused]: Color.Orange,
[LinearProjectState.Completed]: Color.Green,
[LinearProjectState.Canceled]: Color.Red,
};
function progressBar(progress: number): string {
const filled = Math.round(progress / 10);
return `${"▰".repeat(filled)}${"▱".repeat(10 - filled)} ${progress}%`;
}
export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) { export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) {
let markdown = `# ${linearProject.name}`; const notificationHtmlUrl = useMemo(() => {
markdown += `\n\n${progressBar(linearProject.progress)}`; return getNotificationHtmlUrl(notification);
if (linearProject.description) { }, [notification]);
markdown += `\n\n${linearProject.description}`;
}
const metadata = ( return (
<> <Detail
<Detail.Metadata.TagList title="State"> markdown={`# ${linearProject.name}`}
<Detail.Metadata.TagList.Item text={linearProject.state} color={STATE_COLOR[linearProject.state]} /> actions={
</Detail.Metadata.TagList> <ActionPanel>
<Detail.Metadata.Label title="Progress" text={`${linearProject.progress}%`} /> <Action.OpenInBrowser url={notificationHtmlUrl} />
{linearProject.lead ? ( </ActionPanel>
<Detail.Metadata.Label
title="Lead"
text={linearProject.lead.name}
icon={
linearProject.lead.avatar_url
? { source: linearProject.lead.avatar_url, mask: Image.Mask.Circle }
: undefined
} }
/> />
) : null}
{linearProject.start_date ? (
<Detail.Metadata.Label title="Start" text={formatDate(linearProject.start_date)} />
) : null}
{linearProject.target_date ? (
<Detail.Metadata.Label title="Target" text={formatDate(linearProject.target_date)} />
) : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
-13
View File
@@ -2,15 +2,6 @@ export type LinearNotification =
| { type: "IssueNotification"; content: LinearIssueNotification } | { type: "IssueNotification"; content: LinearIssueNotification }
| { type: "ProjectNotification"; content: LinearProjectNotification }; | { type: "ProjectNotification"; content: LinearProjectNotification };
export function getLinearNotificationHtmlUrl(notification: LinearNotification): string {
switch (notification.type) {
case "IssueNotification":
return notification.content.issue.url;
case "ProjectNotification":
return notification.content.project.url;
}
}
export interface LinearIssueNotification { export interface LinearIssueNotification {
id: string; id: string;
type: string; type: string;
@@ -48,10 +39,6 @@ export interface LinearIssue {
team: LinearTeam; team: LinearTeam;
} }
export function getLinearIssueHtmlUrl(linearIssue: LinearIssue): string {
return linearIssue.url;
}
export enum LinearIssuePriority { export enum LinearIssuePriority {
NoPriority = 0, NoPriority = 0,
Urgent = 1, Urgent = 1,
-42
View File
@@ -1,42 +0,0 @@
import { SlackIcon, SlackUser } from "./types";
export function getSlackUserAvatarUrl(slackUser: SlackUser): string | null {
if (!slackUser.profile) {
return null;
}
if (slackUser.profile.image_24) {
return slackUser.profile.image_24;
}
if (slackUser.profile.image_32) {
return slackUser.profile.image_32;
}
if (slackUser.profile.image_34) {
return slackUser.profile.image_34;
}
if (slackUser.profile.image_44) {
return slackUser.profile.image_44;
}
if (slackUser.profile.image_48) {
return slackUser.profile.image_48;
}
return null;
}
export function getSlackIconUrl(slackIcon?: SlackIcon): string | null {
if (slackIcon?.image_24) {
return slackIcon.image_24;
}
if (slackIcon?.image_32) {
return slackIcon.image_32;
}
if (slackIcon?.image_34) {
return slackIcon.image_34;
}
if (slackIcon?.image_44) {
return slackIcon.image_44;
}
if (slackIcon?.image_48) {
return slackIcon.image_48;
}
return null;
}
@@ -1,35 +1,147 @@
import { SlackReactionNotificationListItem } from "./SlackReactionNotificationListItem"; import { NotificationDetails, NotificationListItemProps } from "../../../notification";
import { SlackThreadNotificationListItem } from "./SlackThreadNotificationListItem"; import { NotificationActions } from "../../../action/NotificationActions";
import { SlackStarNotificationListItem } from "./SlackStarNotificationListItem"; import { SlackStarPreview } from "../preview/SlackStarPreview";
import { NotificationListItemProps } from "../../../notification"; import { SlackBotInfo, SlackIcon, SlackUser } from "../types";
/* import { NotificationActions } from "../../../action/NotificationActions"; */
import { Icon, Image, List } from "@raycast/api";
import { getAvatarIcon } from "@raycast/utils";
import { match, P } from "ts-pattern";
export function SlackNotificationListItem({ notification, mutate }: NotificationListItemProps) { export function SlackNotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.source_item.data.type) { const subtitle = getSlackNotificationSubtitle(notification.details);
case "SlackStar":
const author = getSlackAuthorAccessory(notification.details);
const team = getSlackTeamAccessory(notification.details);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return ( return (
<SlackStarNotificationListItem <List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification} notification={notification}
slack_star={notification.source_item.data.content} detailsTarget={<SlackStarPreview notification={notification} />}
mutate={mutate} mutate={mutate}
/> />
); }
case "SlackReaction":
return (
<SlackReactionNotificationListItem
notification={notification}
slack_reaction={notification.source_item.data.content}
mutate={mutate}
/> />
); );
case "SlackThread": }
return (
<SlackThreadNotificationListItem function getSlackNotificationSubtitle(notificationDetails?: NotificationDetails): string {
notification={notification} return match(notificationDetails)
slack_thread={notification.source_item.data.content} .with(
mutate={mutate} {
/> type: P.union("SlackMessage", "SlackFile", "SlackFileComment", "SlackChannel", "SlackIm", "SlackGroup"),
); content: P.select(),
default: },
(slackNotificationDetails) => {
const channelName = slackNotificationDetails.channel?.name;
return channelName ? `#${channelName}` : "";
},
)
.otherwise(() => "");
}
function getSlackAuthorAccessory(notificationDetails?: NotificationDetails): List.Item.Accessory | null {
return match(notificationDetails)
.with(
{
type: "SlackMessage",
content: P.select(),
},
(slackNotificationDetails) => {
return match(slackNotificationDetails.sender)
.with({ type: "User", content: P.select() }, (slackUser: SlackUser) => {
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
})
.with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => {
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
})
.otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" }));
},
)
.otherwise(() => null);
}
function getSlackTeamAccessory(notificationDetails?: NotificationDetails): List.Item.Accessory | null {
return match(notificationDetails)
.with(
{
type: P.union("SlackMessage", "SlackFile", "SlackFileComment", "SlackChannel", "SlackIm", "SlackGroup"),
content: P.select(),
},
(slackNotificationDetails) => {
const teamName = slackNotificationDetails.team.name;
const teamIconUrl = getSlackIconUrl(slackNotificationDetails.team.icon);
if (!teamName || !teamIconUrl) {
return null; return null;
} }
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
},
)
.otherwise(() => null);
}
function getSlackUserAvatarUrl(slackUser: SlackUser): string | null {
if (!slackUser.profile) {
return null;
}
if (slackUser.profile.image_24) {
return slackUser.profile.image_24;
}
if (slackUser.profile.image_32) {
return slackUser.profile.image_32;
}
if (slackUser.profile.image_34) {
return slackUser.profile.image_34;
}
if (slackUser.profile.image_44) {
return slackUser.profile.image_44;
}
if (slackUser.profile.image_48) {
return slackUser.profile.image_48;
}
return null;
}
function getSlackIconUrl(slackIcon?: SlackIcon): string | null {
if (slackIcon?.image_24) {
return slackIcon.image_24;
}
if (slackIcon?.image_32) {
return slackIcon.image_32;
}
if (slackIcon?.image_34) {
return slackIcon.image_34;
}
if (slackIcon?.image_44) {
return slackIcon.image_44;
}
if (slackIcon?.image_48) {
return slackIcon.image_48;
}
return null;
} }
@@ -1,117 +0,0 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackReactionPreview } from "../preview/SlackReactionPreview";
import { SlackBotInfo, SlackReaction, SlackUser } from "../types";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
export type SlackReactionNotificationListItemProps = {
notification: Notification;
slack_reaction: SlackReaction;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackReactionNotificationListItem({
notification,
slack_reaction,
mutate,
}: SlackReactionNotificationListItemProps) {
const subtitle = getSlackReactionNotificationSubtitle(slack_reaction);
const author = getSlackReactionAuthorAccessory(slack_reaction);
const team = getSlackReactionTeamAccessory(slack_reaction);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackReactionPreview notification={notification} slack_reaction={slack_reaction} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackReactionNotificationSubtitle(slack_reaction: SlackReaction): string {
return match(slack_reaction.item)
.with(
{
type: P.union("Message", "File"),
content: P.select(),
},
(slack_reaction_item) => {
const channelName = slack_reaction_item.channel?.name;
return channelName ? `#${channelName}` : "";
},
)
.otherwise(() => "");
}
function getSlackReactionAuthorAccessory(slack_reaction: SlackReaction): List.Item.Accessory | null {
return match(slack_reaction.item)
.with(
{
type: "Message",
content: P.select(),
},
(slackMessageDetails) => {
return match(slackMessageDetails.sender)
.with({ type: "User", content: P.select() }, (slackUser: SlackUser) => {
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
})
.with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => {
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
})
.otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" }));
},
)
.otherwise(() => null);
}
function getSlackReactionTeamAccessory(slack_reaction: SlackReaction): List.Item.Accessory | null {
return match(slack_reaction.item)
.with(
{
type: P.union("Message", "File"),
content: P.select(),
},
(slack_reaction_item) => {
const teamName = slack_reaction_item.team.name;
const teamIconUrl = getSlackIconUrl(slack_reaction_item.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
},
)
.otherwise(() => null);
}
@@ -1,117 +0,0 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackStarPreview } from "../preview/SlackStarPreview";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { SlackBotInfo, SlackStar, SlackUser } from "../types";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
export type SlackStarNotificationListItemProps = {
notification: Notification;
slack_star: SlackStar;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackStarNotificationListItem({
notification,
slack_star,
mutate,
}: SlackStarNotificationListItemProps) {
const subtitle = getSlackStarNotificationSubtitle(slack_star);
const author = getSlackStarAuthorAccessory(slack_star);
const team = getSlackStarTeamAccessory(slack_star);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackStarPreview notification={notification} slack_star={slack_star} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackStarNotificationSubtitle(slack_star: SlackStar): string {
return match(slack_star.item)
.with(
{
type: P.union("Message", "File", "FileComment", "Channel", "Im", "Group"),
content: P.select(),
},
(slack_star_item) => {
const channelName = slack_star_item.channel?.name;
return channelName ? `#${channelName}` : "";
},
)
.otherwise(() => "");
}
function getSlackStarAuthorAccessory(slack_star: SlackStar): List.Item.Accessory | null {
return match(slack_star.item)
.with(
{
type: "Message",
content: P.select(),
},
(slackMessageDetails) => {
return match(slackMessageDetails.sender)
.with({ type: "User", content: P.select() }, (slackUser: SlackUser) => {
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
})
.with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => {
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
})
.otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" }));
},
)
.otherwise(() => null);
}
function getSlackStarTeamAccessory(slack_star: SlackStar): List.Item.Accessory | null {
return match(slack_star.item)
.with(
{
type: P.union("Message", "File", "FileComment", "Channel", "Im", "Group"),
content: P.select(),
},
(slack_star_item) => {
const teamName = slack_star_item.team.name;
const teamIconUrl = getSlackIconUrl(slack_star_item.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
},
)
.otherwise(() => null);
}
@@ -1,99 +0,0 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackThreadPreview } from "../preview/SlackThreadPreview";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { SlackThread } from "../types";
import { Page } from "../../../types";
export type SlackThreadNotificationListItemProps = {
notification: Notification;
slack_thread: SlackThread;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackThreadNotificationListItem({
notification,
slack_thread,
mutate,
}: SlackThreadNotificationListItemProps) {
const subtitle = getSlackThreadNotificationSubtitle(slack_thread);
const author = getSlackThreadAuthorAccessory(slack_thread);
const team = getSlackThreadTeamAccessory(slack_thread);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackThreadPreview notification={notification} slack_thread={slack_thread} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackThreadNotificationSubtitle(slack_thread: SlackThread): string {
const channelName = slack_thread.channel?.name;
return channelName ? `in #${channelName}` : "";
}
function getSlackThreadAuthorAccessory(slack_thread: SlackThread): List.Item.Accessory | null {
const firstUnreadMessage = slack_thread.messages[0];
if (firstUnreadMessage.user) {
const profile = slack_thread.sender_profiles[firstUnreadMessage.user];
if (profile && profile.type === "User") {
const slackUser = profile.content;
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
}
}
if (firstUnreadMessage.bot_id) {
const profile = slack_thread.sender_profiles[firstUnreadMessage.bot_id];
if (profile && profile.type === "Bot") {
const slackBot = profile.content;
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
}
}
return { icon: Icon.Person, tooltip: "Unknown" };
}
function getSlackThreadTeamAccessory(slack_thread: SlackThread): List.Item.Accessory | null {
const teamName = slack_thread.team.name;
const teamIconUrl = getSlackIconUrl(slack_thread.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
}
-100
View File
@@ -1,100 +0,0 @@
import { SlackBlock, SlackBlockText, SlackHistoryMessage, SlackMessageSenderDetails, SlackReferences } from "./types";
function blockTextToString(text?: SlackBlockText): string {
if (!text) return "";
return text.type === "plain_text" ? text.value : text.text;
}
/** Resolve the display name of a message's sender from the thread's sender_profiles map. */
export function slackSenderName(
message: SlackHistoryMessage,
senderProfiles: Record<string, SlackMessageSenderDetails>,
): string {
const key = message.user ?? message.bot_id ?? "";
const profile = senderProfiles[key];
if (!profile) return "Unknown";
if (profile.type === "User") {
return (
profile.content.profile?.display_name || profile.content.real_name || profile.content.name || profile.content.id
);
}
return profile.content.name;
}
/** Replace Slack reference tokens (<@U…>, <#C…>, <url|label>, <!here>) with markdown. */
function resolveReferences(text: string, references?: SlackReferences): string {
let out = text;
out = out.replace(/<@([A-Z0-9]+)(\|[^>]*)?>/g, (_match, id: string) => {
const name = references?.users?.[id];
return name ? `@${name}` : `@${id}`;
});
out = out.replace(/<#([A-Z0-9]+)(\|([^>]*))?>/g, (_match, id: string, _full: string, name: string) => {
const refName = name || references?.channels?.[id];
return refName ? `#${refName}` : `#${id}`;
});
out = out.replace(/<!subteam\^([A-Z0-9]+)(\|([^>]*))?>/g, (_match, id: string, _full: string, name: string) =>
name ? `@${name}` : `@${id}`,
);
out = out.replace(/<!(here|channel|everyone)>/g, (_match, keyword: string) => `@${keyword}`);
out = out.replace(/<(https?:[^|>]+)\|([^>]+)>/g, (_match, url: string, label: string) => `[${label}](${url})`);
out = out.replace(/<(https?:[^|>]+)>/g, (_match, url: string) => url);
return out;
}
/** Convert Slack mrkdwn emphasis to GitHub-flavoured markdown. */
function slackMrkdwnToMarkdown(text: string): string {
let out = text;
// *bold* -> **bold** (Slack uses single asterisks)
out = out.replace(/(^|[\s(])\*(?!\s)([^*\n]+?)\*(?=[\s).,!?:]|$)/g, "$1**$2**");
// ~strike~ -> ~~strike~~
out = out.replace(/(^|[\s(])~(?!\s)([^~\n]+?)~(?=[\s).,!?:]|$)/g, "$1~~$2~~");
return out;
}
function blockToMarkdown(block: SlackBlock): string {
switch (block.type) {
case "section": {
const main = blockTextToString(block.text);
const fields = block.fields?.map(blockTextToString).filter(Boolean).join("\n") ?? "";
return [main, fields].filter(Boolean).join("\n");
}
case "header":
return `### ${blockTextToString(block.text)}`;
case "divider":
return "---";
case "image":
return `![${block.alt_text}](${block.image_url})`;
default:
return "";
}
}
/** Render a Slack message (text or blocks, files, reactions) as markdown. */
export function slackMessageToMarkdown(message: SlackHistoryMessage, references?: SlackReferences): string {
const parts: string[] = [];
let body = message.text ?? "";
if (!body && message.blocks) {
body = message.blocks.map(blockToMarkdown).filter(Boolean).join("\n\n");
}
if (body) {
parts.push(slackMrkdwnToMarkdown(resolveReferences(body, references)));
}
if (message.files?.length) {
parts.push(
message.files
.map((file) => {
const name = file.title || file.name || "file";
return file.permalink ? `📎 [${name}](${file.permalink})` : `📎 ${name}`;
})
.join("\n"),
);
}
if (message.reactions?.length) {
parts.push(`_${message.reactions.map((reaction) => `:${reaction.name}: ${reaction.count}`).join(" ")}_`);
}
return parts.join("\n\n") || "_No message content_";
}
@@ -1,41 +0,0 @@
import { PreviewDetail } from "../../../preview/PreviewDetail";
import { SlackReaction, SlackReactionState } from "../types";
import { Notification } from "../../../notification";
import { slackMessageToMarkdown } from "../markdown";
import { Color, Detail } from "@raycast/api";
import { match } from "ts-pattern";
interface SlackReactionPreviewProps {
notification: Notification;
slack_reaction: SlackReaction;
}
function reactionContent(reaction: SlackReaction): { body: string; channel?: string } {
return match(reaction.item)
.with({ type: "Message" }, (item) => ({
body: slackMessageToMarkdown(item.content.message),
channel: item.content.channel.name,
}))
.with({ type: "File" }, (item) => ({ body: "_Reacted file_", channel: item.content.channel.name }))
.exhaustive();
}
export function SlackReactionPreview({ notification, slack_reaction }: SlackReactionPreviewProps) {
const { body, channel } = reactionContent(slack_reaction);
const markdown = `# ${notification.title}\n\n:${slack_reaction.name}:\n\n${body}`;
const metadata = (
<>
<Detail.Metadata.TagList title="State">
<Detail.Metadata.TagList.Item
text={slack_reaction.state === SlackReactionState.ReactionAdded ? "Added" : "Removed"}
color={slack_reaction.state === SlackReactionState.ReactionAdded ? Color.Green : Color.SecondaryText}
/>
</Detail.Metadata.TagList>
<Detail.Metadata.Label title="Reaction" text={`:${slack_reaction.name}:`} />
{channel ? <Detail.Metadata.Label title="Channel" text={`#${channel}`} /> : null}
</>
);
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
}
@@ -1,47 +1,24 @@
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { Notification } from "../../../notification"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { slackMessageToMarkdown } from "../markdown"; import { useMemo } from "react";
import { SlackStar, SlackStarState } from "../types";
import { Color, Detail } from "@raycast/api";
import { match } from "ts-pattern";
interface SlackStarPreviewProps { interface SlackStarPreviewProps {
notification: Notification; notification: Notification;
slack_star: SlackStar;
} }
function starContent(star: SlackStar): { body: string; channel?: string } { export function SlackStarPreview({ notification }: SlackStarPreviewProps) {
return match(star.item) const notificationHtmlUrl = useMemo(() => {
.with({ type: "Message" }, (item) => ({ return getNotificationHtmlUrl(notification);
body: slackMessageToMarkdown(item.content.message), }, [notification]);
channel: item.content.channel.name,
}))
.with({ type: "File" }, (item) => ({ body: "_Starred file_", channel: item.content.channel.name }))
.with({ type: "FileComment" }, (item) => ({ body: "_File comment_", channel: item.content.channel.name }))
.with({ type: "Channel" }, (item) => ({
body: `_Channel_ #${item.content.channel.name ?? item.content.channel.id}`,
channel: item.content.channel.name,
}))
.with({ type: "Im" }, (item) => ({ body: "_Direct message_", channel: item.content.channel.name }))
.with({ type: "Group" }, (item) => ({ body: "_Group message_", channel: item.content.channel.name }))
.exhaustive();
}
export function SlackStarPreview({ notification, slack_star }: SlackStarPreviewProps) { return (
const { body, channel } = starContent(slack_star); <Detail
const markdown = `# ${notification.title}\n\n${body}`; markdown={`# ${notification.title}`}
actions={
const metadata = ( <ActionPanel>
<> <Action.OpenInBrowser url={notificationHtmlUrl} />
<Detail.Metadata.TagList title="State"> </ActionPanel>
<Detail.Metadata.TagList.Item }
text={slack_star.state === SlackStarState.StarAdded ? "Starred" : "Unstarred"}
color={slack_star.state === SlackStarState.StarAdded ? Color.Yellow : Color.SecondaryText}
/> />
</Detail.Metadata.TagList>
{channel ? <Detail.Metadata.Label title="Channel" text={`#${channel}`} /> : null}
</>
); );
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
} }
@@ -1,36 +0,0 @@
import { slackMessageToMarkdown, slackSenderName } from "../markdown";
import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Notification } from "../../../notification";
import { formatElapsedTime } from "../../../utils";
import { SlackThread } from "../types";
import { Detail } from "@raycast/api";
interface SlackThreadPreviewProps {
notification: Notification;
slack_thread: SlackThread;
}
export function SlackThreadPreview({ notification, slack_thread }: SlackThreadPreviewProps) {
const channelName = slack_thread.channel.name ?? slack_thread.channel.id;
const body = slack_thread.messages
.map((message) => {
const sender = slackSenderName(message, slack_thread.sender_profiles);
const when = message.ts ? ` · ${formatElapsedTime(new Date(parseFloat(message.ts) * 1000))}` : "";
return `**${sender}**${when}\n\n${slackMessageToMarkdown(message, slack_thread.references)}`;
})
.join("\n\n---\n\n");
const markdown = `# ${notification.title}\n\n${body}`;
const metadata = (
<>
<Detail.Metadata.Link title="Channel" target={slack_thread.url} text={`#${channelName}`} />
{slack_thread.team.name ? <Detail.Metadata.Label title="Team" text={slack_thread.team.name} /> : null}
<Detail.Metadata.Label title="Messages" text={`${slack_thread.messages.length}`} />
<Detail.Metadata.Label title="Subscribed" text={slack_thread.subscribed ? "Yes" : "No"} />
</>
);
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
}
+3 -96
View File
@@ -1,94 +1,3 @@
import { match, P } from "ts-pattern";
export interface SlackStar {
state: SlackStarState;
created_at: Date;
item: SlackStarItem;
}
export function getSlackStarHtmlUrl(slack_star: SlackStar): string {
return match(slack_star.item)
.with({ type: "Message", content: P.select() }, (message) => message.url)
.with(
{ type: "File", content: P.select() },
(file) => `https://app.slack.com/client/${file.team.id}/${file.channel.id}`,
)
.with(
{ type: "FileComment", content: P.select() },
(fileComment) => `https://app.slack.com/client/${fileComment.team.id}/${fileComment.channel.id}`,
)
.with(
{ type: "Channel", content: P.select() },
(channel) => `https://app.slack.com/client/${channel.team.id}/${channel.channel.id}`,
)
.with({ type: "Im", content: P.select() }, (im) => `https://app.slack.com/client/${im.team.id}/${im.channel.id}`)
.with(
{ type: "Group", content: P.select() },
(group) => `https://app.slack.com/client/${group.team.id}/${group.channel.id}`,
)
.otherwise(() => "");
}
export enum SlackStarState {
StarAdded = "StarAdded",
StarRemoved = "StarRemoved",
}
export type SlackStarItem =
| { type: "Message"; content: SlackMessageDetails }
| { type: "File"; content: SlackFileDetails }
| { type: "FileComment"; content: SlackFileCommentDetails }
| { type: "Channel"; content: SlackChannelDetails }
| { type: "Im"; content: SlackImDetails }
| { type: "Group"; content: SlackGroupDetails };
export interface SlackReaction {
name: string;
state: SlackReactionState;
created_at: Date;
item: SlackReactionItem;
}
export function getSlackReactionHtmlUrl(slack_reaction: SlackReaction): string {
return match(slack_reaction.item)
.with({ type: "Message", content: P.select() }, (message) => message.url)
.with(
{ type: "File", content: P.select() },
(file) => `https://app.slack.com/client/${file.team.id}/${file.channel.id}`,
)
.otherwise(() => "");
}
export enum SlackReactionState {
ReactionAdded = "ReactionAdded",
ReactionRemoved = "ReactionRemoved",
}
export type SlackReactionItem =
| { type: "Message"; content: SlackMessageDetails }
| { type: "File"; content: SlackFileDetails };
export interface SlackThread {
url: string;
messages: Array<SlackHistoryMessage>;
sender_profiles: Record<string, SlackMessageSenderDetails>;
subscribed: boolean;
last_read?: string;
channel: SlackChannelInfo;
team: SlackTeamInfo;
references?: SlackReferences;
}
export function getSlackThreadHtmlUrl(slack_thread: SlackThread): string {
return slack_thread.url;
}
export interface SlackReferences {
channels: Record<string, string | null>;
users: Record<string, string | null>;
usergroups: Record<string, string | null>;
}
export interface SlackPushEventCallback { export interface SlackPushEventCallback {
team_id: string; team_id: string;
api_app_id: string; api_app_id: string;
@@ -142,18 +51,16 @@ export interface SlackHistoryMessage {
channel_type?: string; channel_type?: string;
thread_ts?: string; thread_ts?: string;
client_msg_id?: string; client_msg_id?: string;
user?: string;
bot_id?: string;
text?: string; text?: string;
blocks?: Array<SlackBlock>; blocks?: Array<SlackBlock>;
attachments?: Array<SlackMessageAttachment>; attachments?: Array<SlackMessageAttachment>;
upload?: boolean; upload?: boolean;
files?: Array<SlackFile>; files?: Array<SlackFile>;
reactions?: Array<SlackReactionDetails>; reactions?: Array<SlackReaction>;
} }
export interface SlackReactionDetails { export interface SlackReaction {
name: string; name: string;
count: number; count: number;
users: Array<string>; users: Array<string>;
@@ -277,7 +184,7 @@ export interface SlackFile {
url_private_download?: string; url_private_download?: string;
permalink?: string; permalink?: string;
permalink_public?: string; permalink_public?: string;
reactions?: Array<SlackReactionDetails>; reactions?: Array<SlackReaction>;
editable?: boolean; editable?: boolean;
is_external?: boolean; is_external?: boolean;
is_public?: boolean; is_public?: boolean;
@@ -1,52 +0,0 @@
import { NotificationTaskActions } from "../../../action/NotificationTaskActions";
import { TickTickTaskPreview } from "../preview/TickTickTaskPreview";
import { NotificationListItemProps } from "../../../notification";
import { Icon, List, Color } from "@raycast/api";
import { TaskPriority } from "../../../task";
import { match } from "ts-pattern";
import dayjs from "dayjs";
export function TickTickNotificationListItem({ notification, mutate }: NotificationListItemProps) {
const dueAt = notification.task?.due_at?.content;
const subtitle = dueAt ? dayjs(dueAt).format("YYYY-MM-DD") : undefined;
const color = match(notification)
.with({ task: { priority: TaskPriority.P1 } }, () => Color.Red)
.with({ task: { priority: TaskPriority.P2 } }, () => Color.Orange)
.with({ task: { priority: TaskPriority.P3 } }, () => Color.Yellow)
.otherwise(() => null);
const accessories: List.Item.Accessory[] = [
{
icon: { source: Icon.Circle, tintColor: color },
},
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
const task = notification.task;
if (task) {
for (const tag of task.tags) {
accessories.unshift({ tag: { value: tag } });
}
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={Icon.CheckCircle}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationTaskActions
notification={notification}
detailsTarget={<TickTickTaskPreview notification={notification} />}
mutate={mutate}
/>
}
/>
);
}
@@ -1,34 +0,0 @@
import { PreviewDetail } from "../../../preview/PreviewDetail";
import { TaskMetadata } from "../../../preview/TaskMetadata";
import { Notification } from "../../../notification";
import { TickTickTaskStatus } from "../types";
interface TickTickTaskPreviewProps {
notification: Notification;
}
export function TickTickTaskPreview({ notification }: TickTickTaskPreviewProps) {
const task = notification.task;
const item =
notification.source_item.data.type === "TickTickItem" ? notification.source_item.data.content : undefined;
const title = task?.title ?? notification.title;
let markdown = `# ${title}`;
if (task?.body) {
markdown += `\n\n${task.body}`;
}
if (item?.items?.length) {
const checklist = item.items
.map((entry) => `- [${entry.status === TickTickTaskStatus.Completed ? "x" : " "}] ${entry.title}`)
.join("\n");
markdown += `\n\n## Checklist\n\n${checklist}`;
}
return (
<PreviewDetail
notification={notification}
markdown={markdown}
metadata={task ? <TaskMetadata task={task} /> : undefined}
/>
);
}
-41
View File
@@ -1,41 +0,0 @@
export enum TickTickItemPriority {
None = 0,
Low = 1,
Medium = 3,
High = 5,
}
export enum TickTickTaskStatus {
Normal = 0,
Completed = 2,
}
export interface TickTickTag {
name: string;
color?: string;
}
export interface TickTickChecklistItem {
id: string;
title: string;
status: TickTickTaskStatus;
sortOrder?: number;
}
export interface TickTickItem {
id: string;
projectId: string;
title: string;
content?: string;
allDay?: boolean;
startDate?: string;
dueDate?: string;
priority: TickTickItemPriority;
status: TickTickTaskStatus;
tags?: TickTickTag[];
items?: TickTickChecklistItem[];
}
export function getTickTickItemHtmlUrl(item: TickTickItem): string {
return `https://ticktick.com/webapp/#p/${item.projectId}/tasks/${item.id}`;
}
@@ -1,25 +1,24 @@
import { PreviewDetail } from "../../../preview/PreviewDetail"; import { Notification, getNotificationHtmlUrl } from "../../../notification";
import { TaskMetadata } from "../../../preview/TaskMetadata"; import { Detail, ActionPanel, Action } from "@raycast/api";
import { Notification } from "../../../notification"; import { useMemo } from "react";
interface TodoistTaskPreviewProps { interface TodoistTaskPreviewProps {
notification: Notification; notification: Notification;
} }
export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) { export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) {
const task = notification.task; const notificationHtmlUrl = useMemo(() => {
const title = task?.title ?? notification.title; return getNotificationHtmlUrl(notification);
}, [notification]);
let markdown = `# ${title}`;
if (task?.body) {
markdown += `\n\n${task.body}`;
}
return ( return (
<PreviewDetail <Detail
notification={notification} markdown={`# ${notification.title}`}
markdown={markdown} actions={
metadata={task ? <TaskMetadata task={task} /> : undefined} <ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/> />
); );
} }
-3
View File
@@ -1,3 +0,0 @@
export interface TodoistItem {
id: string;
}
+70 -18
View File
@@ -1,33 +1,53 @@
import { getThirdPartyItemHtmlUrl, ThirdPartyItem } from "./third_party_item"; import {
SlackChannelDetails,
SlackFileCommentDetails,
SlackFileDetails,
SlackGroupDetails,
SlackImDetails,
SlackMessageDetails,
SlackPushEventCallback,
} from "./integrations/slack/types";
import { GithubDiscussion, GithubPullRequest } from "./integrations/github/types";
import { GoogleMailThread } from "./integrations/google-mail/types";
import { LinearNotification } from "./integrations/linear/types";
import { MutatePromise } from "@raycast/utils"; import { MutatePromise } from "@raycast/utils";
import { match, P } from "ts-pattern";
import { Page } from "./types"; import { Page } from "./types";
import { Task } from "./task"; import { Task } from "./task";
export interface Notification { export interface Notification {
id: string; id: string;
title: string; title: string;
source_id: string;
status: NotificationStatus; status: NotificationStatus;
created_at: Date; metadata: NotificationMetadata;
updated_at: Date; updated_at: Date;
last_read_at?: Date; last_read_at?: Date;
snoozed_until?: Date; snoozed_until?: Date;
user_id: string; user_id: string;
task?: Task; task?: Task;
kind: NotificationSourceKind; details?: NotificationDetails;
source_item: ThirdPartyItem;
} }
export enum NotificationSourceKind { export type NotificationMetadata =
Github = "Github", | {
Todoist = "Todoist", type: "Github" | "Todoist";
TickTick = "TickTick", // eslint-disable-next-line @typescript-eslint/no-explicit-any
Linear = "Linear", content: any;
GoogleMail = "GoogleMail", }
GoogleCalendar = "GoogleCalendar", | { type: "Linear"; content: LinearNotification }
GoogleDrive = "GoogleDrive", | { type: "GoogleMail"; content: GoogleMailThread }
Slack = "Slack", | { type: "Slack"; content: SlackPushEventCallback };
API = "API",
} export type NotificationDetails =
| { type: "GithubPullRequest"; content: GithubPullRequest }
| { type: "GithubDiscussion"; content: GithubDiscussion }
| { type: "SlackMessage"; content: SlackMessageDetails }
| { type: "SlackFile"; content: SlackFileDetails }
| { type: "SlackFileComment"; content: SlackFileCommentDetails }
| { type: "SlackChannel"; content: SlackChannelDetails }
| { type: "SlackIm"; content: SlackImDetails }
| { type: "SlackGroup"; content: SlackGroupDetails };
export enum NotificationStatus { export enum NotificationStatus {
Unread = "Unread", Unread = "Unread",
@@ -41,10 +61,42 @@ export type NotificationListItemProps = {
mutate: MutatePromise<Page<Notification> | undefined>; mutate: MutatePromise<Page<Notification> | undefined>;
}; };
export function getNotificationHtmlUrl(notification: Notification): string { export function getNotificationHtmlUrl(notification: Notification) {
return getThirdPartyItemHtmlUrl(notification.source_item); return match(notification)
.with({ details: { type: "SlackMessage", content: P.select() } }, (notificationDetails) => notificationDetails.url)
.with(
{
details: {
type: P.union("SlackChannel", "SlackFile", "SlackFileComment", "SlackGroup", "SlackIm"),
content: P.select(),
},
},
(notificationDetails) =>
`https://app.slack.com/client/${notificationDetails.team.id}/${notificationDetails.channel.id}`,
)
.with(
{ details: { type: P.union("GithubPullRequest", "GithubDiscussion"), content: P.select() } },
(notificationDetails) => notificationDetails.url,
)
.with(
{ metadata: { type: "Linear", content: { type: "IssueNotification", content: P.select() } } },
(linearIssueNotification) => linearIssueNotification.issue.url,
)
.with(
{ metadata: { type: "Linear", content: { type: "ProjectNotification", content: P.select() } } },
(linearProjectNotification) => linearProjectNotification.project.url,
)
.with(
{ metadata: { type: "GoogleMail", content: P.select() } },
(googleMailThread) =>
`https://mail.google.com/mail/u/${googleMailThread.user_email_address}/#inbox/${googleMailThread.id}`,
)
.with({ metadata: { type: "Todoist" } }, () => `https://todoist.com/showTask?id=${notification.source_id}`)
.with({ metadata: { type: "Github" } }, () => "https://github.com")
.with({ metadata: { type: "Slack" } }, () => "https://app.slack.com")
.exhaustive();
} }
export function isNotificationBuiltFromTask(notification: Notification) { export function isNotificationBuiltFromTask(notification: Notification) {
return notification.kind === NotificationSourceKind.Todoist || notification.kind === NotificationSourceKind.TickTick; return notification.metadata.type === "Todoist";
} }
-38
View File
@@ -1,38 +0,0 @@
import { getNotificationHtmlUrl, Notification } from "../notification";
import { getThirdPartyItemSourceLabel } from "../third_party_item";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { ReactNode, useMemo } from "react";
interface PreviewDetailProps {
notification: Notification;
markdown: string;
/** Inner `Detail.Metadata.*` items — rendered below the notification Type row. */
metadata?: ReactNode;
}
/**
* Shared wrapper for every notification preview: renders a `Detail` with the
* standard "Open in Browser" action, and a metadata sidebar whose first row is
* always the notification type, followed by the preview-specific `metadata`.
*/
export function PreviewDetail({ notification, markdown, metadata }: PreviewDetailProps) {
const notificationHtmlUrl = useMemo(() => getNotificationHtmlUrl(notification), [notification]);
return (
<Detail
markdown={markdown}
metadata={
<Detail.Metadata>
<Detail.Metadata.Label title="Type" text={getThirdPartyItemSourceLabel(notification.source_item)} />
{metadata ? <Detail.Metadata.Separator /> : null}
{metadata}
</Detail.Metadata>
}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { Task, TaskPriority, TaskStatus } from "../task";
import { Color, Detail } from "@raycast/api";
import { formatDate } from "../utils";
const PRIORITY_COLOR: Record<TaskPriority, Color> = {
[TaskPriority.P1]: Color.Red,
[TaskPriority.P2]: Color.Orange,
[TaskPriority.P3]: Color.Yellow,
[TaskPriority.P4]: Color.SecondaryText,
};
/** Metadata sidebar shared by the Todoist and TickTick task previews. */
export function TaskMetadata({ task }: { task: Task }) {
const due = task.due_at?.content ? formatDate(task.due_at.content) : undefined;
return (
<>
<Detail.Metadata.TagList title="Status">
<Detail.Metadata.TagList.Item
text={task.status}
color={task.status === TaskStatus.Done ? Color.Green : Color.Blue}
/>
</Detail.Metadata.TagList>
<Detail.Metadata.TagList title="Priority">
<Detail.Metadata.TagList.Item text={`P${task.priority}`} color={PRIORITY_COLOR[task.priority]} />
</Detail.Metadata.TagList>
{due ? <Detail.Metadata.Label title="Due" text={due} /> : null}
{task.project ? <Detail.Metadata.Label title="Project" text={task.project} /> : null}
{task.tags.length > 0 ? (
<Detail.Metadata.TagList title="Tags">
{task.tags.map((tag) => (
<Detail.Metadata.TagList.Item key={tag} text={tag} />
))}
</Detail.Metadata.TagList>
) : null}
</>
);
}
-112
View File
@@ -1,112 +0,0 @@
import {
getSlackReactionHtmlUrl,
getSlackStarHtmlUrl,
getSlackThreadHtmlUrl,
SlackReaction,
SlackStar,
SlackThread,
} from "./integrations/slack/types";
import {
getLinearIssueHtmlUrl,
getLinearNotificationHtmlUrl,
LinearIssue,
LinearNotification,
} from "./integrations/linear/types";
import { getGoogleCalendarEventHtmlUrl, GoogleCalendarEvent } from "./integrations/google-calendar/types";
import { getGoogleDriveCommentHtmlUrl, GoogleDriveComment } from "./integrations/google-drive/types";
import { getGithubNotificationHtmlUrl, GithubNotification } from "./integrations/github/types";
import { getTickTickItemHtmlUrl, TickTickItem } from "./integrations/ticktick/types";
import { getWebPageHtmlUrl, WebPage } from "./integrations/api/types";
import { GoogleMailThread } from "./integrations/google-mail/types";
import { TodoistItem } from "./integrations/todoist/types";
export interface ThirdPartyItem {
id: string;
source_id: string;
data: ThirdPartyItemData;
created_at: Date;
updated_at: Date;
user_id: string;
integration_connection_id: string;
}
export type ThirdPartyItemData =
| { type: "TodoistItem"; content: TodoistItem }
| { type: "TickTickItem"; content: TickTickItem }
| { type: "SlackStar"; content: SlackStar }
| { type: "SlackReaction"; content: SlackReaction }
| { type: "SlackThread"; content: SlackThread }
| { type: "LinearIssue"; content: LinearIssue }
| { type: "LinearNotification"; content: LinearNotification }
| { type: "GithubNotification"; content: GithubNotification }
| { type: "GoogleMailThread"; content: GoogleMailThread }
| { type: "GoogleCalendarEvent"; content: GoogleCalendarEvent }
| { type: "GoogleDriveComment"; content: GoogleDriveComment }
| { type: "WebPage"; content: WebPage };
/** Human-readable label for the notification's source item type (e.g. "Linear Issue", "GitHub Pull Request"). */
export function getThirdPartyItemSourceLabel(thirdPartyItem: ThirdPartyItem): string {
switch (thirdPartyItem.data.type) {
case "TodoistItem":
return "Todoist Task";
case "TickTickItem":
return "TickTick Task";
case "SlackStar":
return "Slack Star";
case "SlackReaction":
return "Slack Reaction";
case "SlackThread":
return "Slack Thread";
case "LinearIssue":
return "Linear Issue";
case "LinearNotification":
return thirdPartyItem.data.content.type === "ProjectNotification" ? "Linear Project" : "Linear Issue";
case "GithubNotification": {
switch (thirdPartyItem.data.content.item?.type) {
case "GithubPullRequest":
return "GitHub Pull Request";
case "GithubDiscussion":
return "GitHub Discussion";
default:
return "GitHub";
}
}
case "GoogleMailThread":
return "Google Mail";
case "GoogleCalendarEvent":
return "Google Calendar Event";
case "GoogleDriveComment":
return "Google Drive Comment";
case "WebPage":
return "Web Page";
}
}
export function getThirdPartyItemHtmlUrl(thirdPartyItem: ThirdPartyItem): string {
switch (thirdPartyItem.data.type) {
case "TodoistItem":
return `https://todoist.com/showTask?id=${thirdPartyItem.data.content.id}`;
case "SlackStar":
return getSlackStarHtmlUrl(thirdPartyItem.data.content);
case "SlackReaction":
return getSlackReactionHtmlUrl(thirdPartyItem.data.content);
case "SlackThread":
return getSlackThreadHtmlUrl(thirdPartyItem.data.content);
case "LinearIssue":
return getLinearIssueHtmlUrl(thirdPartyItem.data.content);
case "LinearNotification":
return getLinearNotificationHtmlUrl(thirdPartyItem.data.content);
case "GithubNotification":
return getGithubNotificationHtmlUrl(thirdPartyItem.data.content);
case "GoogleMailThread":
return `https://mail.google.com/mail/u/0/#inbox/${thirdPartyItem.data.content.id}`;
case "TickTickItem":
return getTickTickItemHtmlUrl(thirdPartyItem.data.content);
case "GoogleCalendarEvent":
return getGoogleCalendarEventHtmlUrl(thirdPartyItem.data.content);
case "GoogleDriveComment":
return getGoogleDriveCommentHtmlUrl(thirdPartyItem.data.content);
case "WebPage":
return getWebPageHtmlUrl(thirdPartyItem.data.content);
}
}
-38
View File
@@ -1,38 +0,0 @@
import relativeTime from "dayjs/plugin/relativeTime";
import dayjs from "dayjs";
dayjs.extend(relativeTime);
export function formatElapsedTime(date: Date | string): string {
return dayjs(date).fromNow();
}
export function formatDate(date: Date | string): string {
return dayjs(date).format("YYYY-MM-DD");
}
export function formatDateTime(date: Date | string): string {
return dayjs(date).format("YYYY-MM-DD HH:mm");
}
/**
* Convert an HTML-flavoured body (as GitHub sometimes returns) into readable
* markdown: strips comments and tags, turns block elements into line breaks,
* and decodes common entities. Preserves markdown autolinks like <https://…>.
*/
export function cleanHtml(text: string): string {
return text
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(?:p|div|li|h[1-6]|tr|ul|ol|blockquote)>/gi, "\n\n")
.replace(/<li[^>]*>/gi, "- ")
.replace(/<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s[^>]*)?>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
}