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(); +}