diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97df5a7..8a27121 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
## [Unreleased]
+### 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
diff --git a/src/action/NotificationActions.tsx b/src/action/NotificationActions.tsx
index eeac5b9..c7cf7e6 100644
--- a/src/action/NotificationActions.tsx
+++ b/src/action/NotificationActions.tsx
@@ -26,8 +26,8 @@ export function NotificationActions({ notification, detailsTarget, mutate }: Not
return (
-
+
-
+
;
case NotificationSourceKind.Todoist:
return ;
+ case NotificationSourceKind.TickTick:
+ return ;
+ case NotificationSourceKind.GoogleCalendar:
+ return ;
+ case NotificationSourceKind.GoogleDrive:
+ return ;
+ case NotificationSourceKind.API:
+ return ;
default:
return ;
}
@@ -115,6 +127,10 @@ function NotificationKindDropdown({ value, onNotificationKindChange }: Notificat
+
+
+
+
);
diff --git a/src/integrations/api/listitem/APINotificationListItem.tsx b/src/integrations/api/listitem/APINotificationListItem.tsx
new file mode 100644
index 0000000..d499978
--- /dev/null
+++ b/src/integrations/api/listitem/APINotificationListItem.tsx
@@ -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 (
+ }
+ mutate={mutate}
+ />
+ }
+ />
+ );
+}
diff --git a/src/integrations/api/preview/APINotificationPreview.tsx b/src/integrations/api/preview/APINotificationPreview.tsx
new file mode 100644
index 0000000..d5abac9
--- /dev/null
+++ b/src/integrations/api/preview/APINotificationPreview.tsx
@@ -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 = (
+ <>
+
+
+ {webPage.timestamp ? (
+
+ ) : null}
+ >
+ );
+
+ return ;
+}
diff --git a/src/integrations/api/types.ts b/src/integrations/api/types.ts
new file mode 100644
index 0000000..b76e5ad
--- /dev/null
+++ b/src/integrations/api/types.ts
@@ -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;
+}
diff --git a/src/integrations/github/markdown.ts b/src/integrations/github/markdown.ts
new file mode 100644
index 0000000..9b8ce44
--- /dev/null
+++ b/src/integrations/github/markdown.ts
@@ -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 "⏳";
+ }
+}
diff --git a/src/integrations/github/preview/GithubDiscussionPreview.tsx b/src/integrations/github/preview/GithubDiscussionPreview.tsx
index b56e655..378598d 100644
--- a/src/integrations/github/preview/GithubDiscussionPreview.tsx
+++ b/src/integrations/github/preview/GithubDiscussionPreview.tsx
@@ -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 (
-
-
-
- }
- />
+ const metadata = (
+ <>
+
+ {discussion.state_reason ? : null}
+
+
+ {discussion.answer_chosen_by ? (
+
+ ) : null}
+ {discussion.labels.length > 0 ? (
+
+ {discussion.labels.map((label) => (
+
+ ))}
+
+ ) : null}
+ >
);
+
+ return ;
}
diff --git a/src/integrations/github/preview/GithubPullRequestPreview.tsx b/src/integrations/github/preview/GithubPullRequestPreview.tsx
index d23d0a7..92f40c4 100644
--- a/src/integrations/github/preview/GithubPullRequestPreview.tsx
+++ b/src/integrations/github/preview/GithubPullRequestPreview.tsx
@@ -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]);
-
- return (
-
-
-
- }
- />
- );
+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 };
+}
+
+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 = (
+ <>
+
+
+
+ {pr.state === GithubPullRequestState.Open ? (
+
+ ) : null}
+
+
+ {pr.review_decision ? : null}
+ {pr.author ? : null}
+ {pr.assignees.length > 0 ? (
+
+ {pr.assignees.map((assignee, index) => (
+
+ ))}
+
+ ) : null}
+ {pr.review_requests.length > 0 ? (
+
+ {pr.review_requests.map((reviewer, index) => (
+
+ ))}
+
+ ) : null}
+ {pr.labels.length > 0 ? (
+
+ {pr.labels.map((label) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/google-calendar/listitem/GoogleCalendarNotificationListItem.tsx b/src/integrations/google-calendar/listitem/GoogleCalendarNotificationListItem.tsx
new file mode 100644
index 0000000..507a222
--- /dev/null
+++ b/src/integrations/google-calendar/listitem/GoogleCalendarNotificationListItem.tsx
@@ -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 (
+ }
+ mutate={mutate}
+ />
+ }
+ />
+ );
+}
diff --git a/src/integrations/google-calendar/preview/GoogleCalendarEventPreview.tsx b/src/integrations/google-calendar/preview/GoogleCalendarEventPreview.tsx
new file mode 100644
index 0000000..0d41c77
--- /dev/null
+++ b/src/integrations/google-calendar/preview/GoogleCalendarEventPreview.tsx
@@ -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 = (
+
+
+
+
+
+
+
+
+ {event.location ? : null}
+ {meetLink ? : null}
+
+
+ );
+
+ const actions = [];
+ if (meetLink) {
+ actions.push();
+ }
+
+ return {actions}} />;
+}
diff --git a/src/integrations/google-calendar/types.ts b/src/integrations/google-calendar/types.ts
new file mode 100644
index 0000000..8065e18
--- /dev/null
+++ b/src/integrations/google-calendar/types.ts
@@ -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;
+}
diff --git a/src/integrations/google-drive/listitem/GoogleDriveNotificationListItem.tsx b/src/integrations/google-drive/listitem/GoogleDriveNotificationListItem.tsx
new file mode 100644
index 0000000..edef608
--- /dev/null
+++ b/src/integrations/google-drive/listitem/GoogleDriveNotificationListItem.tsx
@@ -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 (
+ }
+ mutate={mutate}
+ />
+ }
+ />
+ );
+}
diff --git a/src/integrations/google-drive/preview/GoogleDriveCommentPreview.tsx b/src/integrations/google-drive/preview/GoogleDriveCommentPreview.tsx
new file mode 100644
index 0000000..e823df1
--- /dev/null
+++ b/src/integrations/google-drive/preview/GoogleDriveCommentPreview.tsx
@@ -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 = (
+ <>
+
+
+
+
+
+
+ >
+ );
+
+ return ;
+}
diff --git a/src/integrations/google-drive/types.ts b/src/integrations/google-drive/types.ts
new file mode 100644
index 0000000..1b5e7f4
--- /dev/null
+++ b/src/integrations/google-drive/types.ts
@@ -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`;
+}
diff --git a/src/integrations/google-mail/preview/GoogleMailThreadPreview.tsx b/src/integrations/google-mail/preview/GoogleMailThreadPreview.tsx
index 72cf473..9312fdc 100644
--- a/src/integrations/google-mail/preview/GoogleMailThreadPreview.tsx
+++ b/src/integrations/google-mail/preview/GoogleMailThreadPreview.tsx
@@ -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]);
-
- return (
-
-
-
- }
- />
- );
+function header(message: GoogleMailMessage, name: string): string | undefined {
+ return message.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
+}
+
+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 ? : null}
+
+
+ {isStarred || isImportant ? (
+
+ {isStarred ? : null}
+ {isImportant ? : null}
+
+ ) : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/linear/preview/LinearIssuePreview.tsx b/src/integrations/linear/preview/LinearIssuePreview.tsx
index 8c86e8c..e08fa69 100644
--- a/src/integrations/linear/preview/LinearIssuePreview.tsx
+++ b/src/integrations/linear/preview/LinearIssuePreview.tsx
@@ -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.NoPriority]: "No priority",
+ [LinearIssuePriority.Urgent]: "Urgent",
+ [LinearIssuePriority.High]: "High",
+ [LinearIssuePriority.Normal]: "Normal",
+ [LinearIssuePriority.Low]: "Low",
+};
- return (
-
-
-
- }
- />
- );
+const PRIORITY_COLOR: Record = {
+ [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 = (
+ <>
+
+
+
+
+
+
+ {linearIssue.assignee ? (
+
+ ) : null}
+ {linearIssue.creator ? (
+
+ ) : null}
+ {linearIssue.project ? (
+
+ ) : null}
+ {linearIssue.project_milestone ? (
+
+ ) : null}
+
+ {linearIssue.due_date ? : null}
+ {linearIssue.labels.length > 0 ? (
+
+ {linearIssue.labels.map((label) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/linear/preview/LinearProjectPreview.tsx b/src/integrations/linear/preview/LinearProjectPreview.tsx
index 09ecf45..2e2acdf 100644
--- a/src/integrations/linear/preview/LinearProjectPreview.tsx
+++ b/src/integrations/linear/preview/LinearProjectPreview.tsx
@@ -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.Planned]: Color.Blue,
+ [LinearProjectState.Backlog]: Color.SecondaryText,
+ [LinearProjectState.Started]: Color.Yellow,
+ [LinearProjectState.Paused]: Color.Orange,
+ [LinearProjectState.Completed]: Color.Green,
+ [LinearProjectState.Canceled]: Color.Red,
+};
- return (
-
-
-
- }
- />
- );
+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 = (
+ <>
+
+
+
+
+ {linearProject.lead ? (
+
+ ) : null}
+ {linearProject.start_date ? (
+
+ ) : null}
+ {linearProject.target_date ? (
+
+ ) : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/slack/markdown.ts b/src/integrations/slack/markdown.ts
new file mode 100644
index 0000000..aa9317d
--- /dev/null
+++ b/src/integrations/slack/markdown.ts
@@ -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 {
+ 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…>, , ) 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(/]*))?>/g, (_match, id: string, _full: string, name: string) =>
+ name ? `@${name}` : `@${id}`,
+ );
+ out = out.replace(//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_";
+}
diff --git a/src/integrations/slack/preview/SlackReactionPreview.tsx b/src/integrations/slack/preview/SlackReactionPreview.tsx
index 7e2760a..4411fe5 100644
--- a/src/integrations/slack/preview/SlackReactionPreview.tsx
+++ b/src/integrations/slack/preview/SlackReactionPreview.tsx
@@ -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]);
-
- return (
-
-
-
- }
- />
- );
+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 = (
+ <>
+
+
+
+
+ {channel ? : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/slack/preview/SlackStarPreview.tsx b/src/integrations/slack/preview/SlackStarPreview.tsx
index b935bba..84b7e1f 100644
--- a/src/integrations/slack/preview/SlackStarPreview.tsx
+++ b/src/integrations/slack/preview/SlackStarPreview.tsx
@@ -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]);
-
- return (
-
-
-
- }
- />
- );
+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();
+}
+
+export function SlackStarPreview({ notification, slack_star }: SlackStarPreviewProps) {
+ const { body, channel } = starContent(slack_star);
+ const markdown = `# ${notification.title}\n\n${body}`;
+
+ const metadata = (
+ <>
+
+
+
+ {channel ? : null}
+ >
+ );
+
+ return ;
}
diff --git a/src/integrations/slack/preview/SlackThreadPreview.tsx b/src/integrations/slack/preview/SlackThreadPreview.tsx
index 9419564..974d2c3 100644
--- a/src/integrations/slack/preview/SlackThreadPreview.tsx
+++ b/src/integrations/slack/preview/SlackThreadPreview.tsx
@@ -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 (
-
-
-
- }
- />
+ 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 = (
+ <>
+
+ {slack_thread.team.name ? : null}
+
+
+ >
);
+
+ return ;
}
diff --git a/src/integrations/ticktick/listitem/TickTickNotificationListItem.tsx b/src/integrations/ticktick/listitem/TickTickNotificationListItem.tsx
new file mode 100644
index 0000000..e956ed3
--- /dev/null
+++ b/src/integrations/ticktick/listitem/TickTickNotificationListItem.tsx
@@ -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 (
+ }
+ mutate={mutate}
+ />
+ }
+ />
+ );
+}
diff --git a/src/integrations/ticktick/preview/TickTickTaskPreview.tsx b/src/integrations/ticktick/preview/TickTickTaskPreview.tsx
new file mode 100644
index 0000000..c0b2329
--- /dev/null
+++ b/src/integrations/ticktick/preview/TickTickTaskPreview.tsx
@@ -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 (
+ : undefined}
+ />
+ );
+}
diff --git a/src/integrations/ticktick/types.ts b/src/integrations/ticktick/types.ts
new file mode 100644
index 0000000..f0c1941
--- /dev/null
+++ b/src/integrations/ticktick/types.ts
@@ -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}`;
+}
diff --git a/src/integrations/todoist/preview/TodoistTaskPreview.tsx b/src/integrations/todoist/preview/TodoistTaskPreview.tsx
index 6269008..9f3ad27 100644
--- a/src/integrations/todoist/preview/TodoistTaskPreview.tsx
+++ b/src/integrations/todoist/preview/TodoistTaskPreview.tsx
@@ -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 (
-
-
-
- }
+ : undefined}
/>
);
}
diff --git a/src/notification.ts b/src/notification.ts
index 6499bc1..994c83b 100644
--- a/src/notification.ts
+++ b/src/notification.ts
@@ -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;
}
diff --git a/src/preview/PreviewDetail.tsx b/src/preview/PreviewDetail.tsx
new file mode 100644
index 0000000..64cf38a
--- /dev/null
+++ b/src/preview/PreviewDetail.tsx
@@ -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 (
+
+
+ {metadata ? : null}
+ {metadata}
+
+ }
+ actions={
+
+
+
+ }
+ />
+ );
+}
diff --git a/src/preview/TaskMetadata.tsx b/src/preview/TaskMetadata.tsx
new file mode 100644
index 0000000..e676390
--- /dev/null
+++ b/src/preview/TaskMetadata.tsx
@@ -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.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 (
+ <>
+
+
+
+
+
+
+ {due ? : null}
+ {task.project ? : null}
+ {task.tags.length > 0 ? (
+
+ {task.tags.map((tag) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/third_party_item.ts b/src/third_party_item.ts
index 9bfe6f7..29a2516 100644
--- a/src/third_party_item.ts
+++ b/src/third_party_item.ts
@@ -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);
}
}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..1d825d1
--- /dev/null
+++ b/src/utils.ts
@@ -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 .
+ */
+export function cleanHtml(text: string): string {
+ return text
+ .replace(//g, "")
+ .replace(/
/gi, "\n")
+ .replace(/<\/(?:p|div|li|h[1-6]|tr|ul|ol|blockquote)>/gi, "\n\n")
+ .replace(/]*>/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();
+}