Compare commits
3
Commits
ad008bf4dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f3a30451 | ||
|
|
9e51e0df6c | ||
|
|
fc5b290c5e |
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@raycast", "plugin:import/recommended", "plugin:import/typescript"],
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": true,
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
## [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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"packages": [
|
||||
"nodejs@latest",
|
||||
"nodejs@24",
|
||||
"nodePackages.prettier@latest",
|
||||
"pre-commit@latest",
|
||||
"just@latest"
|
||||
|
||||
+72
-8
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"lockfile_version": "1",
|
||||
"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": {
|
||||
"last_modified": "2024-01-14T03:55:27Z",
|
||||
"resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#just",
|
||||
@@ -41,24 +45,84 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodejs@latest": {
|
||||
"last_modified": "2024-01-14T03:55:27Z",
|
||||
"nodejs@24": {
|
||||
"last_modified": "2025-12-27T12:56:01Z",
|
||||
"plugin_version": "0.0.2",
|
||||
"resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#nodejs_21",
|
||||
"resolved": "github:NixOS/nixpkgs/3edc4a30ed3903fdf6f90c837f961fa6b49582d1#nodejs_24",
|
||||
"source": "devbox-search",
|
||||
"version": "21.5.0",
|
||||
"version": "24.12.0",
|
||||
"systems": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const raycast = require("@raycast/eslint-config");
|
||||
|
||||
module.exports = [...raycast.flat()];
|
||||
Generated
+1016
-3608
File diff suppressed because it is too large
Load Diff
+16
-15
@@ -5,7 +5,7 @@
|
||||
"description": "Manage your notifications in a single Universal Inbox",
|
||||
"icon": "ui-logo-transparent.png",
|
||||
"author": "dax42",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"categories": ["Productivity"],
|
||||
"license": "MIT",
|
||||
"preferences": [
|
||||
@@ -35,23 +35,24 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@raycast/api": "^1.65.1",
|
||||
"@raycast/utils": "^1.10.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"@raycast/api": "^1.104.19",
|
||||
"@raycast/utils": "^2.2.6",
|
||||
"dayjs": "^1.11.21",
|
||||
"node-fetch": "^3.3.2",
|
||||
"ts-pattern": "^5.0.6"
|
||||
"ts-pattern": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@raycast/eslint-config": "^1.0.6",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/react": "^18.2.27",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-sort-imports": "^1.8.3",
|
||||
"typescript": "^5.3.3"
|
||||
"@raycast/eslint-config": "^2.1.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "19.0.10",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^9",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-sort-imports": "^1.8.11",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ray build -e dist",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LinkNotificationToTask } from "./LinkNotificationToTask";
|
||||
import { Page, UniversalInboxPreferences } from "../types";
|
||||
import { default as dayjs, extend } from "dayjs";
|
||||
import { MutatePromise } from "@raycast/utils";
|
||||
import { useMemo, ReactElement } from "react";
|
||||
import { useMemo, ReactNode } from "react";
|
||||
import { handleErrors } from "../api";
|
||||
import { TaskStatus } from "../task";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
@@ -15,7 +15,7 @@ extend(utc);
|
||||
|
||||
interface NotificationActionsProps {
|
||||
notification: Notification;
|
||||
detailsTarget: ReactElement;
|
||||
detailsTarget: ReactNode;
|
||||
mutate: MutatePromise<Page<Notification> | undefined>;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function NotificationActions({ notification, detailsTarget, mutate }: Not
|
||||
|
||||
return (
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} shortcut={{ modifiers: ["cmd"], key: "enter" }} />
|
||||
<Action
|
||||
title="Delete Notification"
|
||||
icon={Icon.Trash}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Notification, getNotificationHtmlUrl, isNotificationBuiltFromTask } fro
|
||||
import { Action, ActionPanel, Icon, Toast, getPreferenceValues, showToast } from "@raycast/api";
|
||||
import { Page, UniversalInboxPreferences } from "../types";
|
||||
import { MutatePromise } from "@raycast/utils";
|
||||
import { useMemo, ReactElement } from "react";
|
||||
import { useMemo, ReactNode } from "react";
|
||||
import { PlanTask } from "./PlanTask";
|
||||
import { handleErrors } from "../api";
|
||||
import { TaskStatus } from "../task";
|
||||
@@ -11,7 +11,7 @@ import fetch from "node-fetch";
|
||||
|
||||
interface NotificationTaskActionsProps {
|
||||
notification: Notification;
|
||||
detailsTarget: ReactElement;
|
||||
detailsTarget: ReactNode;
|
||||
mutate: MutatePromise<Page<Notification> | undefined>;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ export function NotificationTaskActions({ notification, detailsTarget, mutate }:
|
||||
|
||||
return (
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} shortcut={{ modifiers: ["cmd"], key: "enter" }} />
|
||||
<Action
|
||||
title="Delete Notification"
|
||||
icon={Icon.Trash}
|
||||
|
||||
+17
-1
@@ -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 { GoogleMailNotificationListItem } from "./integrations/google-mail/listitem/GoogleMailNotificationListItem";
|
||||
import { TickTickNotificationListItem } from "./integrations/ticktick/listitem/TickTickNotificationListItem";
|
||||
import { TodoistNotificationListItem } from "./integrations/todoist/listitem/TodoistNotificationListItem";
|
||||
import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
|
||||
import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem";
|
||||
import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
|
||||
import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem";
|
||||
import { Notification, NotificationListItemProps, NotificationSourceKind } from "./notification";
|
||||
import { APINotificationListItem } from "./integrations/api/listitem/APINotificationListItem";
|
||||
import { NotificationActions } from "./action/NotificationActions";
|
||||
import { Page, UniversalInboxPreferences } from "./types";
|
||||
import { useFetch } from "@raycast/utils";
|
||||
@@ -78,6 +82,14 @@ function NotificationListItem({ notification, mutate }: NotificationListItemProp
|
||||
return <SlackNotificationListItem notification={notification} mutate={mutate} />;
|
||||
case NotificationSourceKind.Todoist:
|
||||
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:
|
||||
return <DefaultNotificationListItem notification={notification} mutate={mutate} />;
|
||||
}
|
||||
@@ -115,6 +127,10 @@ function NotificationKindDropdown({ value, onNotificationKindChange }: Notificat
|
||||
<List.Dropdown.Item key="GoogleMail" title="Google Mail" value="GoogleMail" />
|
||||
<List.Dropdown.Item key="Slack" title="Slack" value="Slack" />
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { GithubDiscussion } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { getGithubActorName, GithubDiscussion } from "../types";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { cleanHtml, formatElapsedTime } from "../../../utils";
|
||||
import { Notification } from "../../../notification";
|
||||
import { Detail } from "@raycast/api";
|
||||
|
||||
interface GithubDiscussionPreviewProps {
|
||||
notification: Notification;
|
||||
githubDiscussion: GithubDiscussion;
|
||||
}
|
||||
|
||||
export function GithubDiscussionPreview({ notification, githubDiscussion }: GithubDiscussionPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
export function GithubDiscussionPreview({ notification, githubDiscussion: discussion }: GithubDiscussionPreviewProps) {
|
||||
let markdown = `# ${discussion.title} #${discussion.number}`;
|
||||
if (discussion.author) {
|
||||
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 (
|
||||
<Detail
|
||||
markdown={`# ${githubDiscussion.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
const metadata = (
|
||||
<>
|
||||
<Detail.Metadata.Label title="Repository" text={discussion.repository.name_with_owner} />
|
||||
{discussion.state_reason ? <Detail.Metadata.Label title="State" text={discussion.state_reason} /> : null}
|
||||
<Detail.Metadata.Label title="Comments" text={`${discussion.comments_count}`} />
|
||||
<Detail.Metadata.Label title="Updated" text={formatElapsedTime(discussion.updated_at)} />
|
||||
{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 { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { GithubPullRequest } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { getGithubActorName, GithubPullRequest, GithubPullRequestState, GithubReviewer } from "../types";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { cleanHtml, formatElapsedTime } from "../../../utils";
|
||||
import { checkRunEmoji, reviewStateEmoji } from "../markdown";
|
||||
import { Notification } from "../../../notification";
|
||||
import { Color, Detail } from "@raycast/api";
|
||||
|
||||
interface GithubPullRequestPreviewProps {
|
||||
notification: Notification;
|
||||
githubPullRequest: GithubPullRequest;
|
||||
}
|
||||
|
||||
export function GithubPullRequestPreview({ notification, githubPullRequest }: GithubPullRequestPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
function stateTag(pr: GithubPullRequest): { text: string; color: Color } {
|
||||
if (pr.state === GithubPullRequestState.Merged) return { text: "Merged", color: Color.Purple };
|
||||
if (pr.state === GithubPullRequestState.Closed) return { text: "Closed", color: Color.Red };
|
||||
if (pr.is_draft) return { text: "Draft", color: Color.SecondaryText };
|
||||
return { text: "Open", color: Color.Green };
|
||||
}
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${githubPullRequest.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
function reviewerName(reviewer: GithubReviewer): string {
|
||||
switch (reviewer.type) {
|
||||
case "GithubUserSummary":
|
||||
return reviewer.content.name || reviewer.content.login;
|
||||
case "GithubBotSummary":
|
||||
case "GithubMannequinSummary":
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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>} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -1,26 +1,49 @@
|
||||
import { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { GoogleMailThread } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { GoogleMailMessage, GoogleMailThread } from "../types";
|
||||
import { Notification } from "../../../notification";
|
||||
import { formatDateTime } from "../../../utils";
|
||||
import { Color, Detail } from "@raycast/api";
|
||||
|
||||
interface GoogleMailThreadPreviewProps {
|
||||
notification: Notification;
|
||||
googleMailThread: GoogleMailThread;
|
||||
}
|
||||
|
||||
export function GoogleMailThreadPreview({ notification }: GoogleMailThreadPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
function header(message: GoogleMailMessage, name: string): string | undefined {
|
||||
return message.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
export function GoogleMailThreadPreview({ notification, googleMailThread }: GoogleMailThreadPreviewProps) {
|
||||
const messages = googleMailThread.messages;
|
||||
const firstMessage = messages[0];
|
||||
const subject = (firstMessage && header(firstMessage, "Subject")) || notification.title;
|
||||
|
||||
const isStarred = messages.some((m) => m.labelIds?.includes("STARRED"));
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,85 @@
|
||||
import { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { LinearIssue } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { formatDate, formatElapsedTime } from "../../../utils";
|
||||
import { LinearIssue, LinearIssuePriority } from "../types";
|
||||
import { Notification } from "../../../notification";
|
||||
import { Color, Detail, Image } from "@raycast/api";
|
||||
|
||||
interface LinearIssuePreviewProps {
|
||||
notification: Notification;
|
||||
linearIssue: LinearIssue;
|
||||
}
|
||||
|
||||
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
const PRIORITY_LABEL: Record<LinearIssuePriority, string> = {
|
||||
[LinearIssuePriority.NoPriority]: "No priority",
|
||||
[LinearIssuePriority.Urgent]: "Urgent",
|
||||
[LinearIssuePriority.High]: "High",
|
||||
[LinearIssuePriority.Normal]: "Normal",
|
||||
[LinearIssuePriority.Low]: "Low",
|
||||
};
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${linearIssue.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
const PRIORITY_COLOR: Record<LinearIssuePriority, Color> = {
|
||||
[LinearIssuePriority.NoPriority]: Color.SecondaryText,
|
||||
[LinearIssuePriority.Urgent]: Color.Red,
|
||||
[LinearIssuePriority.High]: Color.Orange,
|
||||
[LinearIssuePriority.Normal]: Color.Yellow,
|
||||
[LinearIssuePriority.Low]: Color.Blue,
|
||||
};
|
||||
|
||||
function userIcon(avatarUrl?: string): Image.ImageLike | undefined {
|
||||
return avatarUrl ? { source: avatarUrl, mask: Image.Mask.Circle } : undefined;
|
||||
}
|
||||
|
||||
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) {
|
||||
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 { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { LinearProject } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { LinearProject, LinearProjectState } from "../types";
|
||||
import { Notification } from "../../../notification";
|
||||
import { Color, Detail, Image } from "@raycast/api";
|
||||
import { formatDate } from "../../../utils";
|
||||
|
||||
interface LinearProjectPreviewProps {
|
||||
notification: Notification;
|
||||
linearProject: LinearProject;
|
||||
}
|
||||
|
||||
export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
const STATE_COLOR: Record<LinearProjectState, Color> = {
|
||||
[LinearProjectState.Planned]: Color.Blue,
|
||||
[LinearProjectState.Backlog]: Color.SecondaryText,
|
||||
[LinearProjectState.Started]: Color.Yellow,
|
||||
[LinearProjectState.Paused]: Color.Orange,
|
||||
[LinearProjectState.Completed]: Color.Green,
|
||||
[LinearProjectState.Canceled]: Color.Red,
|
||||
};
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${linearProject.name}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
function progressBar(progress: number): string {
|
||||
const filled = Math.round(progress / 10);
|
||||
return `${"▰".repeat(filled)}${"▱".repeat(10 - filled)} ${progress}%`;
|
||||
}
|
||||
|
||||
export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) {
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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 ``;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a Slack message (text or blocks, files, reactions) as markdown. */
|
||||
export function slackMessageToMarkdown(message: SlackHistoryMessage, references?: SlackReferences): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
let body = message.text ?? "";
|
||||
if (!body && message.blocks) {
|
||||
body = message.blocks.map(blockToMarkdown).filter(Boolean).join("\n\n");
|
||||
}
|
||||
if (body) {
|
||||
parts.push(slackMrkdwnToMarkdown(resolveReferences(body, references)));
|
||||
}
|
||||
|
||||
if (message.files?.length) {
|
||||
parts.push(
|
||||
message.files
|
||||
.map((file) => {
|
||||
const name = file.title || file.name || "file";
|
||||
return file.permalink ? `📎 [${name}](${file.permalink})` : `📎 ${name}`;
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (message.reactions?.length) {
|
||||
parts.push(`_${message.reactions.map((reaction) => `:${reaction.name}: ${reaction.count}`).join(" ")}_`);
|
||||
}
|
||||
|
||||
return parts.join("\n\n") || "_No message content_";
|
||||
}
|
||||
@@ -1,26 +1,41 @@
|
||||
import { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { SlackReaction } from "../types";
|
||||
import { useMemo } from "react";
|
||||
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;
|
||||
}
|
||||
|
||||
export function SlackReactionPreview({ notification }: SlackReactionPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
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();
|
||||
}
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
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,26 +1,47 @@
|
||||
import { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { SlackStar } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { Notification } from "../../../notification";
|
||||
import { slackMessageToMarkdown } from "../markdown";
|
||||
import { SlackStar, SlackStarState } from "../types";
|
||||
import { Color, Detail } from "@raycast/api";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
interface SlackStarPreviewProps {
|
||||
notification: Notification;
|
||||
slack_star: SlackStar;
|
||||
}
|
||||
|
||||
export function SlackStarPreview({ notification }: SlackStarPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
function starContent(star: SlackStar): { body: string; channel?: string } {
|
||||
return match(star.item)
|
||||
.with({ type: "Message" }, (item) => ({
|
||||
body: slackMessageToMarkdown(item.content.message),
|
||||
channel: item.content.channel.name,
|
||||
}))
|
||||
.with({ type: "File" }, (item) => ({ body: "_Starred file_", channel: item.content.channel.name }))
|
||||
.with({ type: "FileComment" }, (item) => ({ body: "_File comment_", channel: item.content.channel.name }))
|
||||
.with({ type: "Channel" }, (item) => ({
|
||||
body: `_Channel_ #${item.content.channel.name ?? item.content.channel.id}`,
|
||||
channel: item.content.channel.name,
|
||||
}))
|
||||
.with({ type: "Im" }, (item) => ({ body: "_Direct message_", channel: item.content.channel.name }))
|
||||
.with({ type: "Group" }, (item) => ({ body: "_Group message_", channel: item.content.channel.name }))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { slackMessageToMarkdown, slackSenderName } from "../markdown";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { Notification } from "../../../notification";
|
||||
import { formatElapsedTime } from "../../../utils";
|
||||
import { SlackThread } from "../types";
|
||||
import { useMemo } from "react";
|
||||
import { Detail } from "@raycast/api";
|
||||
|
||||
interface SlackThreadPreviewProps {
|
||||
notification: Notification;
|
||||
slack_thread: SlackThread;
|
||||
}
|
||||
|
||||
export function SlackThreadPreview({ notification }: SlackThreadPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
export function SlackThreadPreview({ notification, slack_thread }: SlackThreadPreviewProps) {
|
||||
const channelName = slack_thread.channel.name ?? slack_thread.channel.id;
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { getNotificationHtmlUrl, Notification } from "../../../notification";
|
||||
import { Detail, ActionPanel, Action } from "@raycast/api";
|
||||
import { useMemo } from "react";
|
||||
import { PreviewDetail } from "../../../preview/PreviewDetail";
|
||||
import { TaskMetadata } from "../../../preview/TaskMetadata";
|
||||
import { Notification } from "../../../notification";
|
||||
|
||||
interface TodoistTaskPreviewProps {
|
||||
notification: Notification;
|
||||
}
|
||||
|
||||
export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) {
|
||||
const notificationHtmlUrl = useMemo(() => {
|
||||
return getNotificationHtmlUrl(notification);
|
||||
}, [notification]);
|
||||
const task = notification.task;
|
||||
const title = task?.title ?? notification.title;
|
||||
|
||||
let markdown = `# ${title}`;
|
||||
if (task?.body) {
|
||||
markdown += `\n\n${task.body}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
<PreviewDetail
|
||||
notification={notification}
|
||||
markdown={markdown}
|
||||
metadata={task ? <TaskMetadata task={task} /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -20,9 +20,13 @@ export interface Notification {
|
||||
export enum NotificationSourceKind {
|
||||
Github = "Github",
|
||||
Todoist = "Todoist",
|
||||
TickTick = "TickTick",
|
||||
Linear = "Linear",
|
||||
GoogleMail = "GoogleMail",
|
||||
GoogleCalendar = "GoogleCalendar",
|
||||
GoogleDrive = "GoogleDrive",
|
||||
Slack = "Slack",
|
||||
API = "API",
|
||||
}
|
||||
|
||||
export enum NotificationStatus {
|
||||
@@ -42,5 +46,5 @@ export function getNotificationHtmlUrl(notification: Notification): string {
|
||||
}
|
||||
|
||||
export function isNotificationBuiltFromTask(notification: Notification) {
|
||||
return notification.kind === NotificationSourceKind.Todoist;
|
||||
return notification.kind === NotificationSourceKind.Todoist || notification.kind === NotificationSourceKind.TickTick;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+55
-1
@@ -12,7 +12,11 @@ import {
|
||||
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";
|
||||
|
||||
@@ -28,13 +32,55 @@ export interface ThirdPartyItem {
|
||||
|
||||
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: "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) {
|
||||
@@ -54,5 +100,13 @@ export function getThirdPartyItemHtmlUrl(thirdPartyItem: ThirdPartyItem): string
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user