diff --git a/CHANGELOG.md b/CHANGELOG.md index c4eba9c..afc2272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Add Linear Project and Team icons +- Add Slack notifications (ie. save for later) support ## [0.1.3] - 2024-02-05 diff --git a/src/action/LinkNotificationToTask.tsx b/src/action/LinkNotificationToTask.tsx index 68f1917..42b5958 100644 --- a/src/action/LinkNotificationToTask.tsx +++ b/src/action/LinkNotificationToTask.tsx @@ -92,7 +92,6 @@ async function linkNotificationToTask( if (page) { page.content = page.content.filter((n) => n.id !== notification.id); } - console.log(`page(link): ${notification.id}`, page); return page; }, }, diff --git a/src/index.tsx b/src/index.tsx index e195639..ca713c2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import { GoogleMailNotificationListItem } from "./integrations/google-mail/listi import { TodoistNotificationListItem } from "./integrations/todoist/listitem/TodoistNotificationListItem"; import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem"; import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem"; +import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem"; import { Notification, NotificationListItemProps } from "./notification"; import { NotificationActions } from "./action/NotificationActions"; import { Page, UniversalInboxPreferences } from "./types"; @@ -73,6 +74,8 @@ function NotificationListItem({ notification, mutate }: NotificationListItemProp return ; case "GoogleMail": return ; + case "Slack": + return ; case "Todoist": return ; default: @@ -110,6 +113,7 @@ function NotificationKindDropdown({ value, onNotificationKindChange }: Notificat + diff --git a/src/integrations/slack/listitem/SlackNotificationListItem.tsx b/src/integrations/slack/listitem/SlackNotificationListItem.tsx new file mode 100644 index 0000000..1bf47b6 --- /dev/null +++ b/src/integrations/slack/listitem/SlackNotificationListItem.tsx @@ -0,0 +1,147 @@ +import { NotificationDetails, NotificationListItemProps } from "../../../notification"; +import { NotificationActions } from "../../../action/NotificationActions"; +import { SlackStarPreview } from "../preview/SlackStarPreview"; +import { SlackBotInfo, SlackIcon, SlackUser } from "../types"; +/* import { NotificationActions } from "../../../action/NotificationActions"; */ +import { Icon, Image, List } from "@raycast/api"; +import { getAvatarIcon } from "@raycast/utils"; +import { match, P } from "ts-pattern"; + +export function SlackNotificationListItem({ notification, mutate }: NotificationListItemProps) { + const subtitle = getSlackNotificationSubtitle(notification.details); + + const author = getSlackAuthorAccessory(notification.details); + const team = getSlackTeamAccessory(notification.details); + const updated_at = "2023-01-01"; // TODO + + const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }]; + + if (author) { + accessories.unshift(author); + } + if (team) { + accessories.unshift(team); + } + + return ( + } + mutate={mutate} + /> + } + /> + ); +} + +function getSlackNotificationSubtitle(notificationDetails?: NotificationDetails): string { + return match(notificationDetails) + .with( + { + type: P.union("SlackMessage", "SlackFile", "SlackFileComment", "SlackChannel", "SlackIm", "SlackGroup"), + content: P.select(), + }, + (slackNotificationDetails) => { + const channelName = slackNotificationDetails.channel?.name; + return channelName ? `#${channelName}` : ""; + }, + ) + .otherwise(() => ""); +} + +function getSlackAuthorAccessory(notificationDetails?: NotificationDetails): List.Item.Accessory | null { + return match(notificationDetails) + .with( + { + type: "SlackMessage", + content: P.select(), + }, + (slackNotificationDetails) => { + return match(slackNotificationDetails.sender) + .with({ type: "User", content: P.select() }, (slackUser: SlackUser) => { + const userAvatarUrl = getSlackUserAvatarUrl(slackUser); + const userName = slackUser.real_name || "Unknown"; + return { + icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName), + tooltip: userName, + }; + }) + .with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => { + const botAvatarUrl = getSlackIconUrl(slackBot.icons); + return { + icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name), + tooltip: slackBot.name, + }; + }) + .otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" })); + }, + ) + .otherwise(() => null); +} + +function getSlackTeamAccessory(notificationDetails?: NotificationDetails): List.Item.Accessory | null { + return match(notificationDetails) + .with( + { + type: P.union("SlackMessage", "SlackFile", "SlackFileComment", "SlackChannel", "SlackIm", "SlackGroup"), + content: P.select(), + }, + (slackNotificationDetails) => { + const teamName = slackNotificationDetails.team.name; + const teamIconUrl = getSlackIconUrl(slackNotificationDetails.team.icon); + if (!teamName || !teamIconUrl) { + return null; + } + return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName }; + }, + ) + .otherwise(() => null); +} + +function getSlackUserAvatarUrl(slackUser: SlackUser): string | null { + if (!slackUser.profile) { + return null; + } + if (slackUser.profile.image_24) { + return slackUser.profile.image_24; + } + if (slackUser.profile.image_32) { + return slackUser.profile.image_32; + } + if (slackUser.profile.image_34) { + return slackUser.profile.image_34; + } + if (slackUser.profile.image_44) { + return slackUser.profile.image_44; + } + if (slackUser.profile.image_48) { + return slackUser.profile.image_48; + } + return null; +} + +function getSlackIconUrl(slackIcon?: SlackIcon): string | null { + if (slackIcon?.image_24) { + return slackIcon.image_24; + } + if (slackIcon?.image_32) { + return slackIcon.image_32; + } + if (slackIcon?.image_34) { + return slackIcon.image_34; + } + if (slackIcon?.image_44) { + return slackIcon.image_44; + } + if (slackIcon?.image_48) { + return slackIcon.image_48; + } + return null; +} diff --git a/src/integrations/slack/preview/SlackStarPreview.tsx b/src/integrations/slack/preview/SlackStarPreview.tsx new file mode 100644 index 0000000..91b9101 --- /dev/null +++ b/src/integrations/slack/preview/SlackStarPreview.tsx @@ -0,0 +1,24 @@ +import { Notification, getNotificationHtmlUrl } from "../../../notification"; +import { Detail, ActionPanel, Action } from "@raycast/api"; +import { useMemo } from "react"; + +interface SlackStarPreviewProps { + notification: Notification; +} + +export function SlackStarPreview({ notification }: SlackStarPreviewProps) { + const notificationHtmlUrl = useMemo(() => { + return getNotificationHtmlUrl(notification); + }, [notification]); + + return ( + + + + } + /> + ); +} diff --git a/src/integrations/slack/types.ts b/src/integrations/slack/types.ts new file mode 100644 index 0000000..18dcaaa --- /dev/null +++ b/src/integrations/slack/types.ts @@ -0,0 +1,400 @@ +export interface SlackPushEventCallback { + team_id: string; + api_app_id: string; + event: SlackEventCallbackBody; + event_id: string; + event_time: Date; + event_context?: string; + authed_users?: Array; + authorizations?: Array; +} + +export interface SlackEventAuthorization { + team_id: string; + user_id: string; + is_bot: boolean; +} + +export type SlackEventCallbackBody = + | { type: "SarAdded"; content: SlackStarAddedEvent } + | { type: "StarRemoved"; content: SlackStarRemovedEvent }; + +export interface SlackStarAddedEvent { + user: string; + item: SlackStarsItem; + event_ts: Date; +} + +export interface SlackStarRemovedEvent { + user: string; + item: SlackStarsItem; + event_ts: Date; +} + +export type SlackStarsItem = + | { type: "Message"; content: SlackStarsItemMessage } + | { type: "File"; content: SlackStarsItemFile } + | { type: "FileComment"; content: SlackStarsItemFileComment } + | { type: "Channel"; content: SlackStarsItemChannel } + | { type: "Im"; content: SlackStarsItemIm } + | { type: "Group"; content: SlackStarsItemGroup }; + +export interface SlackStarsItemMessage { + message: SlackHistoryMessage; + channel: string; + date_create: Date; +} + +export interface SlackHistoryMessage { + ts: string; + channel?: string; + channel_type?: string; + thread_ts?: string; + client_msg_id?: string; + + text?: string; + blocks?: Array; + attachments?: Array; + upload?: boolean; + files?: Array; + reactions?: Array; +} + +export interface SlackReaction { + name: string; + count: number; + users: Array; +} + +export interface SlackMessageAttachment { + id?: number; + color?: string; + fallback?: string; + title?: string; + fields?: Array; + mrkdwn_in?: Array; + text?: string; +} + +export interface SlackMessageAttachmentFieldObject { + title?: string; + value?: string; + short?: boolean; +} + +export type SlackBlock = + | SlackSectionBlock + | SlackHeaderBlock + | SlackDividerBlock + | SlackImageBlock + | SlackActionsBlock + | SlackContextBlock + | SlackInputBlock + | SlackFileBlock + | { type: "rich_text" } + | { type: "event" }; + +export interface SlackSectionBlock { + type: "section"; + block_id?: string; + text?: SlackBlockText; + fields?: Array; + // To be specified + // eslint-disable-next-line @typescript-eslint/no-explicit-any + accessory?: any; +} + +export type SlackBlockText = + | { type: "plain_text"; value: string } + | { type: "mrkdwn"; text: string; verbatim?: boolean }; + +export interface SlackHeaderBlock { + type: "header"; + block_id?: string; + text: SlackBlockText; +} + +export interface SlackDividerBlock { + type: "divider"; + block_id?: string; +} + +export interface SlackImageBlock { + type: "image"; + block_id?: string; + image_url: string; + alt_text: string; + title?: SlackBlockText; +} + +export interface SlackActionsBlock { + type: "actions"; + block_id?: string; + // To be specified + // eslint-disable-next-line @typescript-eslint/no-explicit-any + elements: Array; +} + +export interface SlackContextBlock { + type: "context"; + block_id?: string; + // To be specified + // eslint-disable-next-line @typescript-eslint/no-explicit-any + elements: Array; +} + +export interface SlackInputBlock { + type: "input"; + block_id?: string; + label: SlackBlockText; + // To be specified + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: any; + hint?: SlackBlockText; + optional?: boolean; + dispatch_action?: boolean; +} + +export interface SlackFileBlock { + type: "file"; + block_id?: string; + external_id: string; + source: string; +} + +export interface SlackStarsItemFile { + file: SlackFile; + channel: string; + date_create: Date; +} + +export interface SlackFile { + id: string; + created?: Date; + timestamp?: Date; + name?: string; + title?: string; + mimetype?: string; + filetype?: string; + pretty_type?: string; + external_type?: string; + user?: string; + username?: string; + url_private?: string; + url_private_download?: string; + permalink?: string; + permalink_public?: string; + reactions?: Array; + editable?: boolean; + is_external?: boolean; + is_public?: boolean; + public_url_shared?: boolean; + display_as_bot?: boolean; + is_starred?: boolean; + has_rich_preview?: boolean; +} + +export interface SlackStarsItemFileComment { + file: SlackFile; + file_comment: string; + channel: string; + date_create: Date; +} + +export interface SlackStarsItemChannel { + channel: string; + date_create: Date; +} + +export interface SlackStarsItemIm { + channel: string; + date_create: Date; +} + +export interface SlackStarsItemGroup { + group: string; + date_create: Date; +} + +export interface SlackMessageDetails { + url: string; + message: SlackHistoryMessage; + channel: SlackChannelInfo; + sender: SlackMessageSenderDetails; + team: SlackTeamInfo; +} + +export type SlackMessageSenderDetails = { type: "User"; content: SlackUser } | { type: "Bot"; content: SlackBotInfo }; + +export interface SlackUser { + id: string; + team_id?: string; + name?: string; + locale?: string; + profile?: SlackUserProfile; + is_admin?: boolean; + is_app_user?: boolean; + is_bot?: boolean; + is_invited_user?: boolean; + is_owner?: boolean; + is_primary_owner?: boolean; + is_restricted?: boolean; + is_stranger?: boolean; + is_ultra_restricted?: boolean; + has_2fa?: boolean; + tz?: string; + tz_label?: string; + tz_offset?: number; + updated?: Date; + deleted?: boolean; + color?: string; + real_name?: string; + enterprise_user?: SlackEnterpriseUser; +} + +export interface SlackUserProfile { + id?: string; + display_name?: string; + real_name?: string; + real_name_normalized?: string; + avatar_hash?: string; + status_text?: string; + status_expiration?: Date; + status_emoji?: string; + display_name_normalized?: string; + email?: string; + team?: string; + image_original?: string; + image_default?: boolean; + image_24?: string; + image_32?: string; + image_34?: string; + image_44?: string; + image_48?: string; + image_68?: string; + image_72?: string; + image_88?: string; + image_102?: string; + image_132?: string; + image_192?: string; + image_230?: string; + image_512?: string; +} + +export interface SlackEnterpriseUser { + id: string; + enterprise_id: string; + enterprise_name?: string; + teams?: Array; + is_admin?: boolean; + is_app_user?: boolean; + is_bot?: boolean; + is_invited_user?: boolean; + is_owner?: boolean; + is_primary_owner?: boolean; + is_restricted?: boolean; + is_stranger?: boolean; + is_ultra_restricted?: boolean; + has_2fa?: boolean; +} + +export interface SlackBotInfo { + id?: string; + name: string; + updated?: Date; + app_id: string; + user_id: string; + icons?: SlackIcon; +} + +export interface SlackTeamInfo { + id: string; + name?: string; + domain?: string; + email_domain?: string; + icon?: SlackIcon; +} + +export interface SlackIcon { + image_original?: string; + image_default?: boolean; + image_24?: string; + image_32?: string; + image_34?: string; + image_44?: string; + image_48?: string; + image_68?: string; + image_72?: string; + image_88?: string; + image_102?: string; + image_132?: string; + image_192?: string; + image_230?: string; + image_512?: string; +} + +export interface SlackChannelInfo { + id: string; + created: Date; + creator?: string; + name?: string; + name_normalized?: string; + topic?: SlackChannelTopicInfo; + purpose?: SlackChannelPurposeInfo; + previous_names?: Array; + priority?: number; + num_members?: number; + locale?: string; + is_channel?: boolean; + is_group?: boolean; + is_im?: boolean; + is_archived?: boolean; + is_general?: boolean; + is_shared?: boolean; + is_org_shared?: boolean; + is_member?: boolean; + is_private?: boolean; + is_mpim?: boolean; + is_user_deleted?: boolean; + last_read?: string; + unread_count?: number; + unread_count_display?: number; +} + +export interface SlackChannelTopicInfo { + value: string; + creator?: string; + last_set?: Date; +} + +export interface SlackChannelPurposeInfo { + value: string; + creator?: string; + last_set?: Date; +} + +export interface SlackChannelDetails { + channel: SlackChannelInfo; + team: SlackTeamInfo; +} + +export interface SlackFileDetails { + channel: SlackChannelInfo; + sender?: SlackUser; + team: SlackTeamInfo; +} + +export interface SlackFileCommentDetails { + channel: SlackChannelInfo; + team: SlackTeamInfo; +} + +export interface SlackImDetails { + channel: SlackChannelInfo; + team: SlackTeamInfo; +} + +export interface SlackGroupDetails { + channel: SlackChannelInfo; + team: SlackTeamInfo; +} diff --git a/src/notification.ts b/src/notification.ts index 7f4ba87..85177ab 100644 --- a/src/notification.ts +++ b/src/notification.ts @@ -1,3 +1,12 @@ +import { + SlackChannelDetails, + SlackFileCommentDetails, + SlackFileDetails, + SlackGroupDetails, + SlackImDetails, + SlackMessageDetails, + SlackPushEventCallback, +} from "./integrations/slack/types"; import { GithubDiscussion, GithubPullRequest } from "./integrations/github/types"; import { GoogleMailThread } from "./integrations/google-mail/types"; import { LinearNotification } from "./integrations/linear/types"; @@ -27,11 +36,18 @@ export type NotificationMetadata = content: any; } | { type: "Linear"; content: LinearNotification } - | { type: "GoogleMail"; content: GoogleMailThread }; + | { type: "GoogleMail"; content: GoogleMailThread } + | { type: "Slack"; content: SlackPushEventCallback }; export type NotificationDetails = | { type: "GithubPullRequest"; content: GithubPullRequest } - | { type: "GithubDiscussion"; content: GithubDiscussion }; + | { type: "GithubDiscussion"; content: GithubDiscussion } + | { type: "SlackMessage"; content: SlackMessageDetails } + | { type: "SlackFile"; content: SlackFileDetails } + | { type: "SlackFileComment"; content: SlackFileCommentDetails } + | { type: "SlackChannel"; content: SlackChannelDetails } + | { type: "SlackIm"; content: SlackImDetails } + | { type: "SlackGroup"; content: SlackGroupDetails }; export enum NotificationStatus { Unread = "Unread", @@ -47,6 +63,17 @@ export type NotificationListItemProps = { export function getNotificationHtmlUrl(notification: Notification) { return match(notification) + .with({ details: { type: "SlackMessage", content: P.select() } }, (notificationDetails) => notificationDetails.url) + .with( + { + details: { + type: P.union("SlackChannel", "SlackFile", "SlackFileComment", "SlackGroup", "SlackIm"), + content: P.select(), + }, + }, + (notificationDetails) => + `https://app.slack.com/client/${notificationDetails.team.id}/${notificationDetails.channel.id}`, + ) .with( { details: { type: P.union("GithubPullRequest", "GithubDiscussion"), content: P.select() } }, (notificationDetails) => notificationDetails.url, @@ -66,6 +93,7 @@ export function getNotificationHtmlUrl(notification: Notification) { ) .with({ metadata: { type: "Todoist" } }, () => `https://todoist.com/showTask?id=${notification.source_id}`) .with({ metadata: { type: "Github" } }, () => "https://github.com") + .with({ metadata: { type: "Slack" } }, () => "https://app.slack.com") .exhaustive(); }