Compare commits

..
Author SHA1 Message Date
david 12f3a30451 chore: Release 0.3.0 2026-06-08 11:23:50 +02:00
david 9e51e0df6c feat: add missing integrations and rich notification previews
Add TickTick, Google Calendar, Google Drive and API (WebPage) notification
types, which the backend already supported but the extension ignored.

Fill the previously empty notification previews with content modeled on the
web app: a metadata sidebar (status, priority, assignee, labels, dates,
channel, etc.) plus a markdown body and comment/message threads. Add shared
helpers: PreviewDetail wrapper, TaskMetadata, Slack mrkdwn renderer, GitHub
check/review emoji, and date/HTML utils (cleanHtml strips raw HTML from
GitHub bodies).

The preview metadata "Type" row shows the source item type (Linear Issue,
GitHub Pull Request, Slack Thread, etc.).

Swap list-screen shortcuts: Enter shows details, Cmd+Enter opens in browser.
2026-06-08 11:23:06 +02:00
david fc5b290c5e chore: update all dependencies to latest versions 2026-06-08 11:23:02 +02:00
david ad008bf4dd chore: update dependencies 2025-01-17 10:18:02 +01:00
david fc577741de Merge branch 'contributions/merge-1734379598380' 2024-12-16 21:06:39 +01:00
david 560ec86aff chore: Release v0.2.0 2024-12-16 21:02:18 +01:00
david 33dc17483a feat: Add support for Slack reaction and message notifications 2024-12-16 20:44:09 +01:00
david 735b2f5481 fix: Trim trailing slash in Universal Inbox URL 2024-04-29 17:29:37 +02:00
55 changed files with 5067 additions and 3039 deletions
-10
View File
@@ -1,10 +0,0 @@
{
"root": true,
"extends": ["@raycast", "plugin:import/recommended", "plugin:import/typescript"],
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
}
}
+23
View File
@@ -2,6 +2,29 @@
## [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
+1001
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"packages": [ "packages": [
"nodejs@latest", "nodejs@24",
"nodePackages.prettier@latest", "nodePackages.prettier@latest",
"pre-commit@latest", "pre-commit@latest",
"just@latest" "just@latest"
+73 -8
View File
@@ -1,6 +1,10 @@
{ {
"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",
@@ -41,23 +45,84 @@
} }
} }
}, },
"nodejs@latest": { "nodejs@24": {
"last_modified": "2024-01-14T03:55:27Z", "last_modified": "2025-12-27T12:56:01Z",
"resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#nodejs_21", "plugin_version": "0.0.2",
"resolved": "github:NixOS/nixpkgs/3edc4a30ed3903fdf6f90c837f961fa6b49582d1#nodejs_24",
"source": "devbox-search", "source": "devbox-search",
"version": "21.5.0", "version": "24.12.0",
"systems": { "systems": {
"aarch64-darwin": { "aarch64-darwin": {
"store_path": "/nix/store/ybpqk26vz7k9grapsgx0sd900s0sp4sa-nodejs-21.5.0" "outputs": [
{
"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": {
"store_path": "/nix/store/brnzb5xxgdx6bbicygz83ybi5inqp09v-nodejs-21.5.0" "outputs": [
{
"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": {
"store_path": "/nix/store/yvgnx3lj8am9mqn30yr09sb4ia7qy3w8-nodejs-21.5.0" "outputs": [
{
"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": {
"store_path": "/nix/store/nxfirpvaycr7wqzwl6wqifpdrqn7is7x-nodejs-21.5.0" "outputs": [
{
"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
@@ -0,0 +1,3 @@
const raycast = require("@raycast/eslint-config");
module.exports = [...raycast.flat()];
+1975 -2625
View File
File diff suppressed because it is too large Load Diff
+17 -18
View File
@@ -5,10 +5,8 @@
"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.1.4", "version": "0.3.0",
"categories": [ "categories": ["Productivity"],
"Productivity"
],
"license": "MIT", "license": "MIT",
"preferences": [ "preferences": [
{ {
@@ -37,23 +35,24 @@
} }
], ],
"dependencies": { "dependencies": {
"@raycast/api": "^1.65.1", "@raycast/api": "^1.104.19",
"@raycast/utils": "^1.10.1", "@raycast/utils": "^2.2.6",
"dayjs": "^1.11.10", "dayjs": "^1.11.21",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"ts-pattern": "^5.0.6" "ts-pattern": "^5.9.0"
}, },
"devDependencies": { "devDependencies": {
"@raycast/eslint-config": "^1.0.6", "@raycast/eslint-config": "^2.1.1",
"@types/node": "^20.8.10", "@types/node": "^25.9.1",
"@types/react": "^18.2.27", "@types/react": "19.0.10",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^8.60.1",
"eslint": "^8.56.0", "eslint": "^9",
"eslint-import-resolver-typescript": "^3.6.1", "prettier": "^3.8.3",
"eslint-plugin-import": "^2.29.1", "prettier-plugin-sort-imports": "^1.8.11",
"prettier": "^3.0.3", "typescript": "^6.0.3"
"prettier-plugin-sort-imports": "^1.8.3", },
"typescript": "^5.3.3" "engines": {
"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}/api/tasks/projects/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/notifications/${notification.id}/task`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/tasks/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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, ReactElement } from "react"; import { useMemo, ReactNode } 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: ReactElement; detailsTarget: ReactNode;
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}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/notifications/${notification.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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, ReactElement } from "react"; import { useMemo, ReactNode } 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: ReactElement; detailsTarget: ReactNode;
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}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/tasks/projects/search?matches=${searchText}`, `${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/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}/api/tasks/${notification.task.id}`, { fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
project: taskPlanning.project.name, project: taskPlanning.project.name,
+26 -10
View File
@@ -1,10 +1,14 @@
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 { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem"; import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem";
import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem"; import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem";
import { Notification, NotificationListItemProps } from "./notification"; import { Notification, NotificationListItemProps, NotificationSourceKind } 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";
@@ -33,7 +37,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}/api/notifications?status=Unread,Read&with_tasks=true${ `${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications?status=Unread,Read&with_tasks=true${
notificationKind ? "&notification_kind=" + notificationKind : "" notificationKind ? "&notification_kind=" + notificationKind : ""
}`, }`,
{ {
@@ -67,17 +71,25 @@ export default function Command() {
} }
function NotificationListItem({ notification, mutate }: NotificationListItemProps) { function NotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.metadata.type) { switch (notification.kind) {
case "Github": case NotificationSourceKind.Github:
return <GithubNotificationListItem notification={notification} mutate={mutate} />; return <GithubNotificationListItem notification={notification} mutate={mutate} />;
case "Linear": case NotificationSourceKind.Linear:
return <LinearNotificationListItem notification={notification} mutate={mutate} />; return <LinearNotificationListItem notification={notification} mutate={mutate} />;
case "GoogleMail": case NotificationSourceKind.GoogleMail:
return <GoogleMailNotificationListItem notification={notification} mutate={mutate} />; return <GoogleMailNotificationListItem notification={notification} mutate={mutate} />;
case "Slack": case NotificationSourceKind.Slack:
return <SlackNotificationListItem notification={notification} mutate={mutate} />; return <SlackNotificationListItem notification={notification} mutate={mutate} />;
case "Todoist": case NotificationSourceKind.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} />;
} }
@@ -88,7 +100,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_id}`} subtitle={`#${notification.source_item.source_id}`}
actions={ actions={
<NotificationActions <NotificationActions
notification={notification} notification={notification}
@@ -115,6 +127,10 @@ 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>
); );
@@ -0,0 +1,35 @@
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}
/>
}
/>
);
}
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,13 @@
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,12 +3,14 @@ import { GithubDiscussionNotificationListItem } from "./GithubDiscussionNotifica
import { NotificationListItemProps } from "../../../notification"; import { NotificationListItemProps } from "../../../notification";
export function GithubNotificationListItem({ notification, mutate }: NotificationListItemProps) { export function GithubNotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.details?.type) { if (notification.source_item.data.type !== "GithubNotification") return null;
switch (notification.source_item.data.content.item?.type) {
case "GithubPullRequest": case "GithubPullRequest":
return ( return (
<GithubPullRequestNotificationListItem <GithubPullRequestNotificationListItem
notification={notification} notification={notification}
githubPullRequest={notification.details.content} githubPullRequest={notification.source_item.data.content.item.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -16,7 +18,7 @@ export function GithubNotificationListItem({ notification, mutate }: Notificatio
return ( return (
<GithubDiscussionNotificationListItem <GithubDiscussionNotificationListItem
notification={notification} notification={notification}
githubDiscussion={notification.details.content} githubDiscussion={notification.source_item.data.content.item.content}
mutate={mutate} mutate={mutate}
/> />
); );
+42
View File
@@ -0,0 +1,42 @@
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,26 +1,45 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { getGithubActorName, GithubDiscussion } from "../types";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { GithubDiscussion } from "../types"; import { cleanHtml, formatElapsedTime } from "../../../utils";
import { useMemo } from "react"; import { Notification } from "../../../notification";
import { Detail } from "@raycast/api";
interface GithubDiscussionPreviewProps { interface GithubDiscussionPreviewProps {
notification: Notification; notification: Notification;
githubDiscussion: GithubDiscussion; githubDiscussion: GithubDiscussion;
} }
export function GithubDiscussionPreview({ notification, githubDiscussion }: GithubDiscussionPreviewProps) { export function GithubDiscussionPreview({ notification, githubDiscussion: discussion }: GithubDiscussionPreviewProps) {
const notificationHtmlUrl = useMemo(() => { let markdown = `# ${discussion.title} #${discussion.number}`;
return getNotificationHtmlUrl(notification); if (discussion.author) {
}, [notification]); markdown += `\n\n_Opened by ${getGithubActorName(discussion.author)} · ${formatElapsedTime(discussion.created_at)}_`;
}
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)}`;
}
return ( const metadata = (
<Detail <>
markdown={`# ${githubDiscussion.title}`} <Detail.Metadata.Label title="Repository" text={discussion.repository.name_with_owner} />
actions={ {discussion.state_reason ? <Detail.Metadata.Label title="State" text={discussion.state_reason} /> : null}
<ActionPanel> <Detail.Metadata.Label title="Comments" text={`${discussion.comments_count}`} />
<Action.OpenInBrowser url={notificationHtmlUrl} /> <Detail.Metadata.Label title="Updated" text={formatElapsedTime(discussion.updated_at)} />
</ActionPanel> {discussion.answer_chosen_by ? (
} <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,26 +1,103 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { getGithubActorName, GithubPullRequest, GithubPullRequestState, GithubReviewer } from "../types";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { GithubPullRequest } from "../types"; import { cleanHtml, formatElapsedTime } from "../../../utils";
import { useMemo } from "react"; import { checkRunEmoji, reviewStateEmoji } from "../markdown";
import { Notification } from "../../../notification";
import { Color, Detail } from "@raycast/api";
interface GithubPullRequestPreviewProps { interface GithubPullRequestPreviewProps {
notification: Notification; notification: Notification;
githubPullRequest: GithubPullRequest; githubPullRequest: GithubPullRequest;
} }
export function GithubPullRequestPreview({ notification, githubPullRequest }: GithubPullRequestPreviewProps) { function stateTag(pr: GithubPullRequest): { text: string; color: Color } {
const notificationHtmlUrl = useMemo(() => { if (pr.state === GithubPullRequestState.Merged) return { text: "Merged", color: Color.Purple };
return getNotificationHtmlUrl(notification); if (pr.state === GithubPullRequestState.Closed) return { text: "Closed", color: Color.Red };
}, [notification]); if (pr.is_draft) return { text: "Draft", color: Color.SecondaryText };
return { text: "Open", color: Color.Green };
}
return ( function reviewerName(reviewer: GithubReviewer): string {
<Detail switch (reviewer.type) {
markdown={`# ${githubPullRequest.title}`} case "GithubUserSummary":
actions={ return reviewer.content.name || reviewer.content.login;
<ActionPanel> case "GithubBotSummary":
<Action.OpenInBrowser url={notificationHtmlUrl} /> case "GithubMannequinSummary":
</ActionPanel> return reviewer.content.login;
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,3 +1,60 @@
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;
@@ -0,0 +1,59 @@
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}
/>
}
/>
);
}
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,64 @@
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;
}
@@ -0,0 +1,50 @@
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}
/>
}
/>
);
}
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,34 @@
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.metadata.type !== "GoogleMail") return null; if (notification.source_item.data.type !== "GoogleMailThread") return null;
return ( return (
<GoogleMailThreadListItem <GoogleMailThreadListItem
notification={notification} notification={notification}
googleMailThread={notification.metadata.content} googleMailThread={notification.source_item.data.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -1,6 +1,5 @@
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,26 +1,49 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { GoogleMailMessage, GoogleMailThread } from "../types";
import { GoogleMailThread } from "../types"; import { Notification } from "../../../notification";
import { useMemo } from "react"; import { formatDateTime } from "../../../utils";
import { Color, Detail } from "@raycast/api";
interface GoogleMailThreadPreviewProps { interface GoogleMailThreadPreviewProps {
notification: Notification; notification: Notification;
googleMailThread: GoogleMailThread; googleMailThread: GoogleMailThread;
} }
export function GoogleMailThreadPreview({ notification }: GoogleMailThreadPreviewProps) { function header(message: GoogleMailMessage, name: string): string | undefined {
const notificationHtmlUrl = useMemo(() => { return message.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
return getNotificationHtmlUrl(notification); }
}, [notification]);
return ( export function GoogleMailThreadPreview({ notification, googleMailThread }: GoogleMailThreadPreviewProps) {
<Detail const messages = googleMailThread.messages;
markdown={`# ${notification.title}`} const firstMessage = messages[0];
actions={ const subject = (firstMessage && header(firstMessage, "Subject")) || notification.title;
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} /> const isStarred = messages.some((m) => m.labelIds?.includes("STARRED"));
</ActionPanel> const isImportant = messages.some((m) => m.labelIds?.includes("IMPORTANT"));
}
/> 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.metadata.type !== "Linear") return null; if (notification.source_item.data.type !== "LinearNotification") return null;
switch (notification.metadata.content.type) { switch (notification.source_item.data.content.type) {
case "IssueNotification": case "IssueNotification":
return ( return (
<LinearIssueNotificationListItem <LinearIssueNotificationListItem
notification={notification} notification={notification}
linearIssueNotification={notification.metadata.content.content} linearIssueNotification={notification.source_item.data.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.metadata.content.content} linearProjectNotification={notification.source_item.data.content.content}
mutate={mutate} mutate={mutate}
/> />
); );
@@ -1,26 +1,85 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { formatDate, formatElapsedTime } from "../../../utils";
import { LinearIssue } from "../types"; import { LinearIssue, LinearIssuePriority } from "../types";
import { useMemo } from "react"; import { Notification } from "../../../notification";
import { Color, Detail, Image } from "@raycast/api";
interface LinearIssuePreviewProps { interface LinearIssuePreviewProps {
notification: Notification; notification: Notification;
linearIssue: LinearIssue; linearIssue: LinearIssue;
} }
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) { const PRIORITY_LABEL: Record<LinearIssuePriority, string> = {
const notificationHtmlUrl = useMemo(() => { [LinearIssuePriority.NoPriority]: "No priority",
return getNotificationHtmlUrl(notification); [LinearIssuePriority.Urgent]: "Urgent",
}, [notification]); [LinearIssuePriority.High]: "High",
[LinearIssuePriority.Normal]: "Normal",
[LinearIssuePriority.Low]: "Low",
};
return ( const PRIORITY_COLOR: Record<LinearIssuePriority, Color> = {
<Detail [LinearIssuePriority.NoPriority]: Color.SecondaryText,
markdown={`# ${linearIssue.title}`} [LinearIssuePriority.Urgent]: Color.Red,
actions={ [LinearIssuePriority.High]: Color.Orange,
<ActionPanel> [LinearIssuePriority.Normal]: Color.Yellow,
<Action.OpenInBrowser url={notificationHtmlUrl} /> [LinearIssuePriority.Low]: Color.Blue,
</ActionPanel> };
function userIcon(avatarUrl?: string): Image.ImageLike | undefined {
return avatarUrl ? { source: avatarUrl, mask: Image.Mask.Circle } : undefined;
} }
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) {
let markdown = `# ${linearIssue.identifier} ${linearIssue.title}`;
if (linearIssue.creator) {
markdown += `\n\n_Opened by ${linearIssue.creator.name} · ${formatElapsedTime(linearIssue.created_at)}_`;
}
if (linearIssue.description) {
markdown += `\n\n${linearIssue.description}`;
}
const metadata = (
<>
<Detail.Metadata.TagList title="Status">
<Detail.Metadata.TagList.Item text={linearIssue.state.name} color={linearIssue.state.color} />
</Detail.Metadata.TagList>
<Detail.Metadata.TagList title="Priority">
<Detail.Metadata.TagList.Item
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,26 +1,60 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { LinearProject, LinearProjectState } from "../types";
import { LinearProject } from "../types"; import { Notification } from "../../../notification";
import { useMemo } from "react"; import { Color, Detail, Image } from "@raycast/api";
import { formatDate } from "../../../utils";
interface LinearProjectPreviewProps { interface LinearProjectPreviewProps {
notification: Notification; notification: Notification;
linearProject: LinearProject; linearProject: LinearProject;
} }
export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) { const STATE_COLOR: Record<LinearProjectState, Color> = {
const notificationHtmlUrl = useMemo(() => { [LinearProjectState.Planned]: Color.Blue,
return getNotificationHtmlUrl(notification); [LinearProjectState.Backlog]: Color.SecondaryText,
}, [notification]); [LinearProjectState.Started]: Color.Yellow,
[LinearProjectState.Paused]: Color.Orange,
[LinearProjectState.Completed]: Color.Green,
[LinearProjectState.Canceled]: Color.Red,
};
return ( function progressBar(progress: number): string {
<Detail const filled = Math.round(progress / 10);
markdown={`# ${linearProject.name}`} return `${"▰".repeat(filled)}${"▱".repeat(10 - filled)} ${progress}%`;
actions={ }
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} /> export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) {
</ActionPanel> let markdown = `# ${linearProject.name}`;
markdown += `\n\n${progressBar(linearProject.progress)}`;
if (linearProject.description) {
markdown += `\n\n${linearProject.description}`;
}
const metadata = (
<>
<Detail.Metadata.TagList title="State">
<Detail.Metadata.TagList.Item text={linearProject.state} color={STATE_COLOR[linearProject.state]} />
</Detail.Metadata.TagList>
<Detail.Metadata.Label title="Progress" text={`${linearProject.progress}%`} />
{linearProject.lead ? (
<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,6 +2,15 @@ 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;
@@ -39,6 +48,10 @@ 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
@@ -0,0 +1,42 @@
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,147 +1,35 @@
import { NotificationDetails, NotificationListItemProps } from "../../../notification"; import { SlackReactionNotificationListItem } from "./SlackReactionNotificationListItem";
import { NotificationActions } from "../../../action/NotificationActions"; import { SlackThreadNotificationListItem } from "./SlackThreadNotificationListItem";
import { SlackStarPreview } from "../preview/SlackStarPreview"; import { SlackStarNotificationListItem } from "./SlackStarNotificationListItem";
import { SlackBotInfo, SlackIcon, SlackUser } from "../types"; import { NotificationListItemProps } from "../../../notification";
/* 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) {
const subtitle = getSlackNotificationSubtitle(notification.details); switch (notification.source_item.data.type) {
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 (
<List.Item <SlackStarNotificationListItem
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}
detailsTarget={<SlackStarPreview notification={notification} />} slack_star={notification.source_item.data.content}
mutate={mutate} mutate={mutate}
/> />
} );
case "SlackReaction":
return (
<SlackReactionNotificationListItem
notification={notification}
slack_reaction={notification.source_item.data.content}
mutate={mutate}
/> />
); );
} case "SlackThread":
return (
function getSlackNotificationSubtitle(notificationDetails?: NotificationDetails): string { <SlackThreadNotificationListItem
return match(notificationDetails) notification={notification}
.with( slack_thread={notification.source_item.data.content}
{ 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;
} }
@@ -0,0 +1,117 @@
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);
}
@@ -0,0 +1,117 @@
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);
}
@@ -0,0 +1,99 @@
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
@@ -0,0 +1,100 @@
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_";
}
@@ -0,0 +1,41 @@
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,24 +1,47 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { Notification } from "../../../notification";
import { useMemo } from "react"; import { slackMessageToMarkdown } from "../markdown";
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;
} }
export function SlackStarPreview({ notification }: SlackStarPreviewProps) { function starContent(star: SlackStar): { body: string; channel?: string } {
const notificationHtmlUrl = useMemo(() => { return match(star.item)
return getNotificationHtmlUrl(notification); .with({ type: "Message" }, (item) => ({
}, [notification]); body: slackMessageToMarkdown(item.content.message),
channel: item.content.channel.name,
return ( }))
<Detail .with({ type: "File" }, (item) => ({ body: "_Starred file_", channel: item.content.channel.name }))
markdown={`# ${notification.title}`} .with({ type: "FileComment" }, (item) => ({ body: "_File comment_", channel: item.content.channel.name }))
actions={ .with({ type: "Channel" }, (item) => ({
<ActionPanel> body: `_Channel_ #${item.content.channel.name ?? item.content.channel.id}`,
<Action.OpenInBrowser url={notificationHtmlUrl} /> channel: item.content.channel.name,
</ActionPanel> }))
.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) {
const { body, channel } = starContent(slack_star);
const markdown = `# ${notification.title}\n\n${body}`;
const metadata = (
<>
<Detail.Metadata.TagList title="State">
<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} />;
} }
@@ -0,0 +1,36 @@
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} />;
}
+96 -3
View File
@@ -1,3 +1,94 @@
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;
@@ -51,16 +142,18 @@ 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<SlackReaction>; reactions?: Array<SlackReactionDetails>;
} }
export interface SlackReaction { export interface SlackReactionDetails {
name: string; name: string;
count: number; count: number;
users: Array<string>; users: Array<string>;
@@ -184,7 +277,7 @@ export interface SlackFile {
url_private_download?: string; url_private_download?: string;
permalink?: string; permalink?: string;
permalink_public?: string; permalink_public?: string;
reactions?: Array<SlackReaction>; reactions?: Array<SlackReactionDetails>;
editable?: boolean; editable?: boolean;
is_external?: boolean; is_external?: boolean;
is_public?: boolean; is_public?: boolean;
@@ -0,0 +1,52 @@
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}
/>
}
/>
);
}
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,41 @@
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,24 +1,25 @@
import { Notification, getNotificationHtmlUrl } from "../../../notification"; import { PreviewDetail } from "../../../preview/PreviewDetail";
import { Detail, ActionPanel, Action } from "@raycast/api"; import { TaskMetadata } from "../../../preview/TaskMetadata";
import { useMemo } from "react"; import { Notification } from "../../../notification";
interface TodoistTaskPreviewProps { interface TodoistTaskPreviewProps {
notification: Notification; notification: Notification;
} }
export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) { export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) {
const notificationHtmlUrl = useMemo(() => { const task = notification.task;
return getNotificationHtmlUrl(notification); const title = task?.title ?? notification.title;
}, [notification]);
let markdown = `# ${title}`;
if (task?.body) {
markdown += `\n\n${task.body}`;
}
return ( return (
<Detail <PreviewDetail
markdown={`# ${notification.title}`} notification={notification}
actions={ markdown={markdown}
<ActionPanel> metadata={task ? <TaskMetadata task={task} /> : undefined}
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/> />
); );
} }
+3
View File
@@ -0,0 +1,3 @@
export interface TodoistItem {
id: string;
}
+17 -69
View File
@@ -1,53 +1,33 @@
import { import { getThirdPartyItemHtmlUrl, ThirdPartyItem } from "./third_party_item";
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;
metadata: NotificationMetadata; created_at: Date;
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;
details?: NotificationDetails; kind: NotificationSourceKind;
source_item: ThirdPartyItem;
} }
export type NotificationMetadata = export enum NotificationSourceKind {
| { Github = "Github",
type: "Github" | "Todoist"; Todoist = "Todoist",
// eslint-disable-next-line @typescript-eslint/no-explicit-any TickTick = "TickTick",
content: any; Linear = "Linear",
GoogleMail = "GoogleMail",
GoogleCalendar = "GoogleCalendar",
GoogleDrive = "GoogleDrive",
Slack = "Slack",
API = "API",
} }
| { type: "Linear"; content: LinearNotification }
| { type: "GoogleMail"; content: GoogleMailThread }
| { type: "Slack"; content: SlackPushEventCallback };
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",
@@ -61,42 +41,10 @@ export type NotificationListItemProps = {
mutate: MutatePromise<Page<Notification> | undefined>; mutate: MutatePromise<Page<Notification> | undefined>;
}; };
export function getNotificationHtmlUrl(notification: Notification) { export function getNotificationHtmlUrl(notification: Notification): string {
return match(notification) return getThirdPartyItemHtmlUrl(notification.source_item);
.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.metadata.type === "Todoist"; return notification.kind === NotificationSourceKind.Todoist || notification.kind === NotificationSourceKind.TickTick;
} }
+38
View File
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,112 @@
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
@@ -0,0 +1,38 @@
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();
}