feat: add missing integrations and rich notification previews
Add TickTick, Google Calendar, Google Drive and API (WebPage) notification types, which the backend already supported but the extension ignored. Fill the previously empty notification previews with content modeled on the web app: a metadata sidebar (status, priority, assignee, labels, dates, channel, etc.) plus a markdown body and comment/message threads. Add shared helpers: PreviewDetail wrapper, TaskMetadata, Slack mrkdwn renderer, GitHub check/review emoji, and date/HTML utils (cleanHtml strips raw HTML from GitHub bodies). The preview metadata "Type" row shows the source item type (Linear Issue, GitHub Pull Request, Slack Thread, etc.). Swap list-screen shortcuts: Enter shows details, Cmd+Enter opens in browser.
This commit is contained in:
@@ -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, SlackMessageSenderDetails>,
|
||||
): 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…>, <url|label>, <!here>) 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(/<!subteam\^([A-Z0-9]+)(\|([^>]*))?>/g, (_match, id: string, _full: string, name: string) =>
|
||||
name ? `@${name}` : `@${id}`,
|
||||
);
|
||||
out = out.replace(/<!(here|channel|everyone)>/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 ``;
|
||||
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_";
|
||||
}
|
||||
@@ -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 (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
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 = (
|
||||
<>
|
||||
<Detail.Metadata.TagList title="State">
|
||||
<Detail.Metadata.TagList.Item
|
||||
text={slack_reaction.state === SlackReactionState.ReactionAdded ? "Added" : "Removed"}
|
||||
color={slack_reaction.state === SlackReactionState.ReactionAdded ? Color.Green : Color.SecondaryText}
|
||||
/>
|
||||
</Detail.Metadata.TagList>
|
||||
<Detail.Metadata.Label title="Reaction" text={`:${slack_reaction.name}:`} />
|
||||
{channel ? <Detail.Metadata.Label title="Channel" text={`#${channel}`} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
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 = (
|
||||
<>
|
||||
<Detail.Metadata.TagList title="State">
|
||||
<Detail.Metadata.TagList.Item
|
||||
text={slack_star.state === SlackStarState.StarAdded ? "Starred" : "Unstarred"}
|
||||
color={slack_star.state === SlackStarState.StarAdded ? Color.Yellow : Color.SecondaryText}
|
||||
/>
|
||||
</Detail.Metadata.TagList>
|
||||
{channel ? <Detail.Metadata.Label title="Channel" text={`#${channel}`} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Detail
|
||||
markdown={`# ${notification.title}`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.OpenInBrowser url={notificationHtmlUrl} />
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
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 = (
|
||||
<>
|
||||
<Detail.Metadata.Link title="Channel" target={slack_thread.url} text={`#${channelName}`} />
|
||||
{slack_thread.team.name ? <Detail.Metadata.Label title="Team" text={slack_thread.team.name} /> : null}
|
||||
<Detail.Metadata.Label title="Messages" text={`${slack_thread.messages.length}`} />
|
||||
<Detail.Metadata.Label title="Subscribed" text={slack_thread.subscribed ? "Yes" : "No"} />
|
||||
</>
|
||||
);
|
||||
|
||||
return <PreviewDetail notification={notification} markdown={markdown} metadata={metadata} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user