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