From 3df827ebd754a780649ecd5a30c8a3475ecc140a Mon Sep 17 00:00:00 2001 From: David Rousselie Date: Fri, 26 Jan 2024 21:22:47 +0100 Subject: [PATCH] feat: Add Linear notification list item --- assets/linear-issue-backlog.svg | 1 + assets/linear-issue-canceled.svg | 1 + assets/linear-issue-completed.svg | 1 + assets/linear-issue-started.svg | 1 + assets/linear-issue-triage.svg | 1 + assets/linear-issue-unstarted.svg | 1 + src/index.tsx | 2 +- .../GithubPullRequestNotificationListItem.tsx | 2 +- .../linear/LinearNotificationListItem.tsx | 29 ----- src/integrations/linear/accessories.ts | 12 ++ .../LinearIssueNotificationListItem.tsx | 88 +++++++++++++ .../listitem/LinearNotificationListItem.tsx | 39 ++++++ .../LinearProjectNotificationListItem.tsx | 53 ++++++++ .../linear/preview/LinearIssuePreview.tsx | 26 ++++ .../linear/preview/LinearProjectPreview.tsx | 26 ++++ src/integrations/linear/types.ts | 116 ++++++++++++++++++ src/notification.ts | 25 ++-- 17 files changed, 383 insertions(+), 41 deletions(-) create mode 100644 assets/linear-issue-backlog.svg create mode 100644 assets/linear-issue-canceled.svg create mode 100644 assets/linear-issue-completed.svg create mode 100644 assets/linear-issue-started.svg create mode 100644 assets/linear-issue-triage.svg create mode 100644 assets/linear-issue-unstarted.svg delete mode 100644 src/integrations/linear/LinearNotificationListItem.tsx create mode 100644 src/integrations/linear/accessories.ts create mode 100644 src/integrations/linear/listitem/LinearIssueNotificationListItem.tsx create mode 100644 src/integrations/linear/listitem/LinearNotificationListItem.tsx create mode 100644 src/integrations/linear/listitem/LinearProjectNotificationListItem.tsx create mode 100644 src/integrations/linear/preview/LinearIssuePreview.tsx create mode 100644 src/integrations/linear/preview/LinearProjectPreview.tsx create mode 100644 src/integrations/linear/types.ts diff --git a/assets/linear-issue-backlog.svg b/assets/linear-issue-backlog.svg new file mode 100644 index 0000000..063bbf3 --- /dev/null +++ b/assets/linear-issue-backlog.svg @@ -0,0 +1 @@ + diff --git a/assets/linear-issue-canceled.svg b/assets/linear-issue-canceled.svg new file mode 100644 index 0000000..b883ea9 --- /dev/null +++ b/assets/linear-issue-canceled.svg @@ -0,0 +1 @@ + diff --git a/assets/linear-issue-completed.svg b/assets/linear-issue-completed.svg new file mode 100644 index 0000000..6082069 --- /dev/null +++ b/assets/linear-issue-completed.svg @@ -0,0 +1 @@ + diff --git a/assets/linear-issue-started.svg b/assets/linear-issue-started.svg new file mode 100644 index 0000000..4f943ca --- /dev/null +++ b/assets/linear-issue-started.svg @@ -0,0 +1 @@ + diff --git a/assets/linear-issue-triage.svg b/assets/linear-issue-triage.svg new file mode 100644 index 0000000..22811be --- /dev/null +++ b/assets/linear-issue-triage.svg @@ -0,0 +1 @@ + diff --git a/assets/linear-issue-unstarted.svg b/assets/linear-issue-unstarted.svg new file mode 100644 index 0000000..2848c90 --- /dev/null +++ b/assets/linear-issue-unstarted.svg @@ -0,0 +1 @@ + diff --git a/src/index.tsx b/src/index.tsx index c9a14a4..e4df547 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,8 @@ import { Action, ActionPanel, Detail, List, getPreferenceValues, openExtensionPreferences } from "@raycast/api"; import { GoogleMailNotificationListItem } from "./integrations/google-mail/GoogleMailNotificationListItem"; import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem"; +import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem"; import { TodoistNotificationListItem } from "./integrations/todoist/TodoistNotificationListItem"; -import { LinearNotificationListItem } from "./integrations/linear/LinearNotificationListItem"; import { Notification, NotificationListItemProps } from "./notification"; import { NotificationActions } from "./action/NotificationActions"; import { Page, UniversalInboxPreferences } from "./types"; diff --git a/src/integrations/github/listitem/GithubPullRequestNotificationListItem.tsx b/src/integrations/github/listitem/GithubPullRequestNotificationListItem.tsx index 47c0f1c..1b96540 100644 --- a/src/integrations/github/listitem/GithubPullRequestNotificationListItem.tsx +++ b/src/integrations/github/listitem/GithubPullRequestNotificationListItem.tsx @@ -87,7 +87,7 @@ function getGithubPullRequestChecksAccessory(latestCommit: GithubCommitChecks): case GithubCheckStatusState.Pending: return { icon: Icon.Pause, tooltip: "Pending" }; case GithubCheckStatusState.InProgress: - return { icon: Icon.Pause, tooltip: "In progress" }; // TODO Spinner + return { icon: Icon.CircleProgress, tooltip: "In progress" }; case GithubCheckStatusState.Completed: switch (progress.conclusion()) { case GithubCheckConclusionState.Success: diff --git a/src/integrations/linear/LinearNotificationListItem.tsx b/src/integrations/linear/LinearNotificationListItem.tsx deleted file mode 100644 index 1ce4cd0..0000000 --- a/src/integrations/linear/LinearNotificationListItem.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { NotificationActions } from "../../action/NotificationActions"; -import { NotificationListItemProps } from "../../notification"; -import { Detail, List, environment } from "@raycast/api"; -import { useMemo } from "react"; - -export function LinearNotificationListItem({ notification, mutate }: NotificationListItemProps) { - const icon = useMemo(() => { - if (environment.appearance === "dark") { - return "linear-logo-light.svg"; - } - return "linear-logo-dark.svg"; - }, [environment]); - - return ( - } - mutate={mutate} - /> - } - /> - ); -} diff --git a/src/integrations/linear/accessories.ts b/src/integrations/linear/accessories.ts new file mode 100644 index 0000000..9bdffbf --- /dev/null +++ b/src/integrations/linear/accessories.ts @@ -0,0 +1,12 @@ +import { Icon, Image, List } from "@raycast/api"; +import { LinearUser } from "./types"; + +export function getLinearUserAccessory(user?: LinearUser): List.Item.Accessory { + if (user) { + return { + icon: user.avatar_url ? { source: user.avatar_url, mask: Image.Mask.Circle } : Icon.Person, + tooltip: user.name, + }; + } + return { icon: Icon.Person, tooltip: "Unknown" }; +} diff --git a/src/integrations/linear/listitem/LinearIssueNotificationListItem.tsx b/src/integrations/linear/listitem/LinearIssueNotificationListItem.tsx new file mode 100644 index 0000000..5fbc3b9 --- /dev/null +++ b/src/integrations/linear/listitem/LinearIssueNotificationListItem.tsx @@ -0,0 +1,88 @@ +import { NotificationActions } from "../../../action/NotificationActions"; +import { LinearIssuePreview } from "../preview/LinearIssuePreview"; +import { getLinearUserAccessory } from "../accessories"; +import { Notification } from "../../../notification"; +import { LinearIssueNotification } from "../types"; +import { MutatePromise } from "@raycast/utils"; +import { Page } from "../../../types"; +import { List } from "@raycast/api"; + +interface LinearIssueNotificationListItemProps { + icon: string; + notification: Notification; + linearIssueNotification: LinearIssueNotification; + mutate: MutatePromise | undefined>; +} + +export function LinearIssueNotificationListItem({ + icon, + notification, + linearIssueNotification, + mutate, +}: LinearIssueNotificationListItemProps) { + const subtitle = `${linearIssueNotification.issue.team.name} #${linearIssueNotification.issue.identifier}`; + + const state = getLinearIssueStateAccessory(linearIssueNotification.issue.state); + const assignee = getLinearUserAccessory(linearIssueNotification.issue.assignee); + + const accessories: List.Item.Accessory[] = [ + state, + assignee, + { + date: new Date(linearIssueNotification.updated_at), + tooltip: `Updated at ${linearIssueNotification.updated_at}`, + }, + ]; + + return ( + } + mutate={mutate} + /> + } + /> + ); +} + +export function getLinearIssueStateAccessory(state: LinearWorkflowState): List.Item.Accessory { + switch (state.type) { + case "Triage": + return { + icon: { source: "linear-issue-triage.svg", tintColor: state.color }, + tooltip: state.name, + }; + case "Backlog": + return { + icon: { source: "linear-issue-backlog.svg", tintColor: state.color }, + tooltip: state.name, + }; + case "Unstarted": + return { + icon: { source: "linear-issue-unstarted.svg", tintColor: state.color }, + tooltip: state.name, + }; + case "Started": + return { + icon: { source: "linear-issue-started.svg", tintColor: state.color }, + tooltip: state.name, + }; + case "Completed": + return { + icon: { source: "linear-issue-completed.svg", tintColor: state.color }, + tooltip: state.name, + }; + case "Canceled": + return { + icon: { source: "linear-issue-canceled.svg", tintColor: state.color }, + tooltip: state.name, + }; + } +} diff --git a/src/integrations/linear/listitem/LinearNotificationListItem.tsx b/src/integrations/linear/listitem/LinearNotificationListItem.tsx new file mode 100644 index 0000000..6a1aebb --- /dev/null +++ b/src/integrations/linear/listitem/LinearNotificationListItem.tsx @@ -0,0 +1,39 @@ +import { LinearProjectNotificationListItem } from "./LinearProjectNotificationListItem"; +import { LinearIssueNotificationListItem } from "./LinearIssueNotificationListItem"; +import { NotificationListItemProps } from "../../../notification"; +import { environment } from "@raycast/api"; +import { useMemo } from "react"; + +export function LinearNotificationListItem({ notification, mutate }: NotificationListItemProps) { + const icon = useMemo(() => { + if (environment.appearance === "dark") { + return "linear-logo-light.svg"; + } + return "linear-logo-dark.svg"; + }, [environment]); + + if (notification.metadata.type !== "Linear") return null; + + switch (notification.metadata.content.type) { + case "IssueNotification": + return ( + + ); + case "ProjectNotification": + return ( + + ); + default: + return null; + } +} diff --git a/src/integrations/linear/listitem/LinearProjectNotificationListItem.tsx b/src/integrations/linear/listitem/LinearProjectNotificationListItem.tsx new file mode 100644 index 0000000..304871b --- /dev/null +++ b/src/integrations/linear/listitem/LinearProjectNotificationListItem.tsx @@ -0,0 +1,53 @@ +import { NotificationActions } from "../../../action/NotificationActions"; +import { LinearProjectPreview } from "../preview/LinearProjectPreview"; +import { getLinearUserAccessory } from "../accessories"; +import { Notification } from "../../../notification"; +import { LinearProjectNotification } from "../types"; +import { MutatePromise } from "@raycast/utils"; +import { Page } from "../../../types"; +import { List } from "@raycast/api"; + +interface LinearProjectNotificationListItemProps { + icon: string; + notification: Notification; + linearProjectNotification: LinearProjectNotification; + mutate: MutatePromise | undefined>; +} + +export function LinearProjectNotificationListItem({ + icon, + notification, + linearProjectNotification, + mutate, +}: LinearProjectNotificationListItemProps) { + const subtitle = linearProjectNotification.project.name; + + const lead = getLinearUserAccessory(linearProjectNotification.project.lead); + + const accessories: List.Item.Accessory[] = [ + lead, + { + date: new Date(linearProjectNotification.updated_at), + tooltip: `Updated at ${linearProjectNotification.updated_at}`, + }, + ]; + + return ( + + } + mutate={mutate} + /> + } + /> + ); +} diff --git a/src/integrations/linear/preview/LinearIssuePreview.tsx b/src/integrations/linear/preview/LinearIssuePreview.tsx new file mode 100644 index 0000000..136ea92 --- /dev/null +++ b/src/integrations/linear/preview/LinearIssuePreview.tsx @@ -0,0 +1,26 @@ +import { Notification, getNotificationHtmlUrl } from "../../../notification"; +import { Detail, ActionPanel, Action } from "@raycast/api"; +import { LinearIssue } from "../types"; +import { useMemo } from "react"; + +interface LinearIssuePreviewProps { + notification: Notification; + linearIssue: LinearIssue; +} + +export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) { + const notification_html_url = useMemo(() => { + return getNotificationHtmlUrl(notification); + }, [notification]); + + return ( + + + + } + /> + ); +} diff --git a/src/integrations/linear/preview/LinearProjectPreview.tsx b/src/integrations/linear/preview/LinearProjectPreview.tsx new file mode 100644 index 0000000..9be85e1 --- /dev/null +++ b/src/integrations/linear/preview/LinearProjectPreview.tsx @@ -0,0 +1,26 @@ +import { Notification, getNotificationHtmlUrl } from "../../../notification"; +import { Detail, ActionPanel, Action } from "@raycast/api"; +import { LinearProject } from "../types"; +import { useMemo } from "react"; + +interface LinearProjectPreviewProps { + notification: Notification; + linearProject: LinearProject; +} + +export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) { + const notification_html_url = useMemo(() => { + return getNotificationHtmlUrl(notification); + }, [notification]); + + return ( + + + + } + /> + ); +} diff --git a/src/integrations/linear/types.ts b/src/integrations/linear/types.ts new file mode 100644 index 0000000..14f1591 --- /dev/null +++ b/src/integrations/linear/types.ts @@ -0,0 +1,116 @@ +export interface LinearIssueNotification { + id: string; + type: string; + read_at?: Date; + updated_at: Date; + snoozed_until_at?: Date; + organization: LinearOrganization; + issue: LinearIssue; +} + +export interface LinearOrganization { + name: string; + key: string; + logo_url?: string; +} + +export interface LinearIssue { + id: string; + created_at: Date; + updated_at: Date; + completed_at?: Date; + canceled_at?: Date; + due_date?: Date; + identifier: string; + title: string; + url: string; + priority: LinearIssuePriority; + project?: LinearProject; + project_milestone?: LinearProjectMilestone; + creator?: LinearUser; + assignee?: LinearUser; + state: LinearWorkflowState; + labels: Array; + description: string; + team: LinearTeam; +} + +export enum LinearIssuePriority { + NoPriority = 0, + Urgent = 1, + High = 2, + Normal = 3, + Low = 4, +} + +export interface LinearProject { + id: string; + name: string; + url: string; + description: string; + icon?: string; + color: string; + state: LinearProjectState; + progress: number; // percentage between 0 and 100 + start_date?: Date; + target_date?: Date; + lead?: LinearUser; +} + +export enum LinearProjectState { + Planned = "Planned", + Backlog = "Backlog", + Started = "Started", + Paused = "Paused", + Completed = "Completed", + Canceled = "Canceled", +} + +export interface LinearProjectMilestone { + name: string; + description?: string; +} + +export interface LinearUser { + name: string; + avatar_url?: string; + url: string; +} + +export interface LinearWorkflowState { + name: string; + description?: string; + color: string; + type: LinearWorkflowStateType; +} + +export enum LinearWorkflowStateType { + Triage = "Triage", + Backlog = "Backlog", + Unstarted = "Unstarted", + Started = "Started", + Completed = "Completed", + Canceled = "Canceled", +} + +export interface LinearLabel { + name: string; + description?: string; + color: string; +} + +export interface LinearTeam { + id: string; + key: string; + name: string; +} + +export interface LinearProjectNotification { + id: string; + type: string; + read_at?: Date; + updated_at: Date; + snoozed_until_at?: Date; + organization: LinearOrganization; + project: LinearProject; +} diff --git a/src/notification.ts b/src/notification.ts index 281bc61..05e219a 100644 --- a/src/notification.ts +++ b/src/notification.ts @@ -1,5 +1,6 @@ import { GithubDiscussion, GithubPullRequest } from "./integrations/github/types"; import { MutatePromise } from "@raycast/utils"; +import { match, P } from "ts-pattern"; import { Page } from "./types"; import { Task } from "./task"; @@ -40,16 +41,20 @@ export type NotificationListItemProps = { }; export function getNotificationHtmlUrl(notification: Notification) { - switch (notification.details?.type) { - case "GithubPullRequest": - return notification.details.content.url; - case "GithubDiscussion": - return notification.details.content.url; - default: { - // TODO - return "https://github.com"; - } - } + return match(notification) + .with( + { details: { type: P.union("GithubPullRequest", "GithubDiscussion") } }, + () => notification.details.content.url, + ) + .with( + { metadata: { type: "Linear", content: { type: "IssueNotification", content: P.select() } } }, + (linearIssueNotification) => linearIssueNotification.issue.url, + ) + .with( + { metadata: { type: "Linear", content: { type: "ProjectNotification", content: P.select() } } }, + (linearProjectNotification) => linearProjectNotification.project.url, + ) + .exhaustive(); } export function isNotificationBuiltFromTask(notification: Notification) {