From 9e51e0df6c6866cda557abb01633cf26590a95e5 Mon Sep 17 00:00:00 2001 From: David Rousselie Date: Sat, 6 Jun 2026 19:46:02 +0200 Subject: [PATCH] feat: add missing integrations and rich notification previews Add TickTick, Google Calendar, Google Drive and API (WebPage) notification types, which the backend already supported but the extension ignored. Fill the previously empty notification previews with content modeled on the web app: a metadata sidebar (status, priority, assignee, labels, dates, channel, etc.) plus a markdown body and comment/message threads. Add shared helpers: PreviewDetail wrapper, TaskMetadata, Slack mrkdwn renderer, GitHub check/review emoji, and date/HTML utils (cleanHtml strips raw HTML from GitHub bodies). The preview metadata "Type" row shows the source item type (Linear Issue, GitHub Pull Request, Slack Thread, etc.). Swap list-screen shortcuts: Enter shows details, Cmd+Enter opens in browser. --- CHANGELOG.md | 14 +++ src/action/NotificationActions.tsx | 2 +- src/action/NotificationTaskActions.tsx | 2 +- src/index.tsx | 16 +++ .../api/listitem/APINotificationListItem.tsx | 35 ++++++ .../api/preview/APINotificationPreview.tsx | 38 ++++++ src/integrations/api/types.ts | 13 ++ src/integrations/github/markdown.ts | 42 +++++++ .../preview/GithubDiscussionPreview.tsx | 53 +++++--- .../preview/GithubPullRequestPreview.tsx | 115 +++++++++++++++--- .../GoogleCalendarNotificationListItem.tsx | 59 +++++++++ .../preview/GoogleCalendarEventPreview.tsx | 58 +++++++++ src/integrations/google-calendar/types.ts | 64 ++++++++++ .../GoogleDriveNotificationListItem.tsx | 50 ++++++++ .../preview/GoogleDriveCommentPreview.tsx | 44 +++++++ src/integrations/google-drive/types.ts | 34 ++++++ .../preview/GoogleMailThreadPreview.tsx | 61 +++++++--- .../linear/preview/LinearIssuePreview.tsx | 95 ++++++++++++--- .../linear/preview/LinearProjectPreview.tsx | 70 ++++++++--- src/integrations/slack/markdown.ts | 100 +++++++++++++++ .../slack/preview/SlackReactionPreview.tsx | 53 +++++--- .../slack/preview/SlackStarPreview.tsx | 59 ++++++--- .../slack/preview/SlackThreadPreview.tsx | 42 ++++--- .../listitem/TickTickNotificationListItem.tsx | 52 ++++++++ .../ticktick/preview/TickTickTaskPreview.tsx | 34 ++++++ src/integrations/ticktick/types.ts | 41 +++++++ .../todoist/preview/TodoistTaskPreview.tsx | 27 ++-- src/notification.ts | 6 +- src/preview/PreviewDetail.tsx | 38 ++++++ src/preview/TaskMetadata.tsx | 38 ++++++ src/third_party_item.ts | 56 ++++++++- src/utils.ts | 38 ++++++ 32 files changed, 1287 insertions(+), 162 deletions(-) create mode 100644 src/integrations/api/listitem/APINotificationListItem.tsx create mode 100644 src/integrations/api/preview/APINotificationPreview.tsx create mode 100644 src/integrations/api/types.ts create mode 100644 src/integrations/github/markdown.ts create mode 100644 src/integrations/google-calendar/listitem/GoogleCalendarNotificationListItem.tsx create mode 100644 src/integrations/google-calendar/preview/GoogleCalendarEventPreview.tsx create mode 100644 src/integrations/google-calendar/types.ts create mode 100644 src/integrations/google-drive/listitem/GoogleDriveNotificationListItem.tsx create mode 100644 src/integrations/google-drive/preview/GoogleDriveCommentPreview.tsx create mode 100644 src/integrations/google-drive/types.ts create mode 100644 src/integrations/slack/markdown.ts create mode 100644 src/integrations/ticktick/listitem/TickTickNotificationListItem.tsx create mode 100644 src/integrations/ticktick/preview/TickTickTaskPreview.tsx create mode 100644 src/integrations/ticktick/types.ts create mode 100644 src/preview/PreviewDetail.tsx create mode 100644 src/preview/TaskMetadata.tsx create mode 100644 src/utils.ts 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 `![${block.alt_text}](${block.image_url})`; + default: + return ""; + } +} + +/** Render a Slack message (text or blocks, files, reactions) as markdown. */ +export function slackMessageToMarkdown(message: SlackHistoryMessage, references?: SlackReferences): string { + const parts: string[] = []; + + let body = message.text ?? ""; + if (!body && message.blocks) { + body = message.blocks.map(blockToMarkdown).filter(Boolean).join("\n\n"); + } + if (body) { + parts.push(slackMrkdwnToMarkdown(resolveReferences(body, references))); + } + + if (message.files?.length) { + parts.push( + message.files + .map((file) => { + const name = file.title || file.name || "file"; + return file.permalink ? `📎 [${name}](${file.permalink})` : `📎 ${name}`; + }) + .join("\n"), + ); + } + + if (message.reactions?.length) { + parts.push(`_${message.reactions.map((reaction) => `:${reaction.name}: ${reaction.count}`).join(" ")}_`); + } + + return parts.join("\n\n") || "_No message content_"; +} 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(); +}