From 051456ee3bb6feec8f74e5b7dd6aa18f3c557b92 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 24 Jul 2023 14:29:41 -0700 Subject: [PATCH] Mastodon API: implement proposed Glitch emoji reactions API --- .../src/server/api/mastodon/converters.ts | 4 +- .../server/api/mastodon/endpoints/status.ts | 43 +++++++++++++- .../megalodon/src/entities/notification.ts | 2 +- packages/megalodon/src/entities/reaction.ts | 1 + packages/megalodon/src/entities/status.ts | 2 +- packages/megalodon/src/megalodon.ts | 15 +++++ packages/megalodon/src/misskey.ts | 57 +++++++++++++++++++ packages/megalodon/src/misskey/api_client.ts | 47 +++++++++------ packages/megalodon/src/notification.ts | 2 +- .../test/integration/misskey.spec.ts | 2 +- .../test/unit/misskey/api_client.spec.ts | 4 +- packages/megalodon/test/unit/parser.spec.ts | 2 +- packages/megalodon/tsconfig.json | 2 +- 13 files changed, 155 insertions(+), 28 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index b7160c7dc..3ef1e53f9 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -32,6 +32,8 @@ export function convertNotification(notification: Entity.Notification) { notification.id = convertId(notification.id, IdType.MastodonId); if (notification.status) notification.status = convertStatus(notification.status); + if (notification.reaction) + notification.reaction = convertReaction(notification.reaction); return notification; } @@ -67,7 +69,7 @@ export function convertStatus(status: Entity.Status) { })); if (status.poll) status.poll = convertPoll(status.poll); if (status.reblog) status.reblog = convertStatus(status.reblog); - status.emoji_reactions = status.mentions.map(convertReaction); + status.reactions = status.reactions.map(convertReaction); return status; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 2fcd28c9a..9ffcb30c3 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -48,7 +48,7 @@ export function apiStatusMastodon(router: Router): void { try { const id = body.in_reply_to_id; const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter((e) => e.me)[0].name; + const react = post.data.reactions.filter((e) => e.me)[0].name; const data = await client.deleteEmojiReaction(id, react); ctx.body = data.data; } catch (e: any) { @@ -367,6 +367,47 @@ export function apiStatusMastodon(router: Router): void { } }, ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/react/:name", + async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.reactStatus( + convertId(ctx.params.id, IdType.FirefishId), + ctx.params.name, + ); + ctx.body = convertStatus(data.data); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/unreact/:name", + async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unreactStatus( + convertId(ctx.params.id, IdType.FirefishId), + ctx.params.name, + ); + ctx.body = convertStatus(data.data); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const accessTokens = ctx.headers.authorization; diff --git a/packages/megalodon/src/entities/notification.ts b/packages/megalodon/src/entities/notification.ts index fae32c795..68eff3347 100644 --- a/packages/megalodon/src/entities/notification.ts +++ b/packages/megalodon/src/entities/notification.ts @@ -7,7 +7,7 @@ namespace Entity { created_at: string; id: string; status?: Status; - emoji?: string; + reaction?: Reaction; type: NotificationType; }; diff --git a/packages/megalodon/src/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts index 2ad4f9d3a..4edbec6a7 100644 --- a/packages/megalodon/src/entities/reaction.ts +++ b/packages/megalodon/src/entities/reaction.ts @@ -6,6 +6,7 @@ namespace Entity { me: boolean; name: string; url?: string; + static_url?: string; accounts?: Array; }; } diff --git a/packages/megalodon/src/entities/status.ts b/packages/megalodon/src/entities/status.ts index 8c8148fd5..f27f728b5 100644 --- a/packages/megalodon/src/entities/status.ts +++ b/packages/megalodon/src/entities/status.ts @@ -38,7 +38,7 @@ namespace Entity { application: Application | null; language: string | null; pinned: boolean | null; - emoji_reactions: Array; + reactions: Array; quote: Status | null; bookmarked: boolean; }; diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts index 1fd1f9f93..33a5790f6 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -857,6 +857,21 @@ export interface MegalodonInterface { * @return Status */ unpinStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/react/:name + * @param id The target status id. + * @param name The name of the emoji reaction to add. + * @return Status + */ + reactStatus(id: string, name: string): Promise>; + /** + * POST /api/v1/statuses/:id/unreact/:name + * + * @param id The target status id. + * @param name The name of the emoji reaction to remove. + * @return Status + */ + unreactStatus(id: string, name: string): Promise>; // ====================================== // statuses/media // ====================================== diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index a3afff728..c48815bfc 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -2009,6 +2009,63 @@ export default class Misskey implements MegalodonInterface { })); } + /** + * Convert a Unicode emoji or custom emoji name to a Misskey reaction. + * @see Misskey's reaction-lib.ts + */ + private reactionName(name: string): string { + // See: https://github.com/tc39/proposal-regexp-unicode-property-escapes#matching-emoji + const isUnicodeEmoji = /\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu.test(name); + if (isUnicodeEmoji) { + return name; + } + return `:${name}:`; + } + + /** + * POST /api/notes/reactions/create + */ + public async reactStatus(id: string, name: string): Promise> { + await this.client.post<{}>("/api/notes/reactions/create", { + noteId: id, + reaction: this.reactionName(name), + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/reactions/delete + */ + public async unreactStatus(id: string, name: string): Promise> { + await this.client.post<{}>("/api/notes/reactions/delete", { + noteId: id, + reaction: this.reactionName(name), + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + // ====================================== // statuses/media // ====================================== diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 22cac2a1c..47932cbf1 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -321,7 +321,10 @@ namespace MisskeyAPI { content: n.text ? this.escapeMFM(n.text) : "", plain_content: n.text ? n.text : null, created_at: n.createdAt, - emojis: n.emojis.map((e) => this.emoji(e)), + // Remove reaction emojis with names containing @ from the emojis list. + emojis: n.emojis + .filter((e) => e.name.indexOf("@") === -1) + .map((e) => this.emoji(e)), replies_count: n.repliesCount, reblogs_count: n.renoteCount, favourites_count: this.getTotalReactions(n.reactions), @@ -339,28 +342,36 @@ namespace MisskeyAPI { application: null, language: null, pinned: null, - emoji_reactions: this.mapReactions(n.reactions, n.myReaction), + // Use emojis list to provide URLs for emoji reactions. + reactions: this.mapReactions(n.emojis, n.reactions, n.myReaction), bookmarked: false, quote: n.renote && n.text ? this.note(n.renote, host) : null, }; }; mapReactions = ( + emojis: Array, r: { [key: string]: number }, myReaction?: string, ): Array => { + // Map of emoji shortcodes to image URLs. + const emojiUrls = new Map( + emojis.map((e) => [e.name, e.url]), + ); return Object.keys(r).map((key) => { - if (myReaction && key === myReaction) { - return { - count: r[key], - me: true, - name: key, - }; - } + // Strip colons from custom emoji reaction names to match emoji shortcodes. + const shortcode = key.replaceAll(":", ""); + // If this is a custom emoji (vs. a Unicode emoji), find its image URL. + const url = emojiUrls.get(shortcode); + // Finally, remove trailing @. from local custom emoji reaction names. + const name = shortcode.replace("@.", ""); return { count: r[key], - me: false, - name: key, + me: key === myReaction, + name, + url, + // We don't actually have a static version of the asset, but clients expect one anyway. + static_url: url, }; }); }; @@ -422,7 +433,7 @@ namespace MisskeyAPI { case NotificationType.Mention: return MisskeyNotificationType.Reply; case NotificationType.Favourite: - case NotificationType.EmojiReaction: + case NotificationType.Reaction: return MisskeyNotificationType.Reaction; case NotificationType.Reblog: return MisskeyNotificationType.Renote; @@ -448,7 +459,7 @@ namespace MisskeyAPI { case MisskeyNotificationType.Quote: return NotificationType.Reblog; case MisskeyNotificationType.Reaction: - return NotificationType.EmojiReaction; + return NotificationType.Reaction; case MisskeyNotificationType.PollEnded: return NotificationType.Poll; case MisskeyNotificationType.ReceiveFollowRequest: @@ -496,11 +507,11 @@ namespace MisskeyAPI { account: this.note(n.note, host).account, }); } - } - if (n.reaction) { - notification = Object.assign(notification, { - emoji: n.reaction, - }); + if (n.reaction) { + notification = Object.assign(notification, { + reaction: this.mapReactions(n.note.emojis, { [n.reaction]: 1 })[0], + }); + } } return notification; }; diff --git a/packages/megalodon/src/notification.ts b/packages/megalodon/src/notification.ts index e378bdac1..84cd23e40 100644 --- a/packages/megalodon/src/notification.ts +++ b/packages/megalodon/src/notification.ts @@ -5,7 +5,7 @@ namespace NotificationType { export const Favourite: Entity.NotificationType = "favourite"; export const Reblog: Entity.NotificationType = "reblog"; export const Mention: Entity.NotificationType = "mention"; - export const EmojiReaction: Entity.NotificationType = "emoji_reaction"; + export const Reaction: Entity.NotificationType = "reaction"; export const FollowRequest: Entity.NotificationType = "follow_request"; export const Status: Entity.NotificationType = "status"; export const Poll: Entity.NotificationType = "poll"; diff --git a/packages/megalodon/test/integration/misskey.spec.ts b/packages/megalodon/test/integration/misskey.spec.ts index 49d39a097..0ec128842 100644 --- a/packages/megalodon/test/integration/misskey.spec.ts +++ b/packages/megalodon/test/integration/misskey.spec.ts @@ -163,7 +163,7 @@ describe('getNotifications', () => { }, { event: reaction, - expected: MegalodonNotificationType.EmojiReaction, + expected: MegalodonNotificationType.Reaction, title: 'reaction' }, { diff --git a/packages/megalodon/test/unit/misskey/api_client.spec.ts b/packages/megalodon/test/unit/misskey/api_client.spec.ts index b8e1df048..7cf33b983 100644 --- a/packages/megalodon/test/unit/misskey/api_client.spec.ts +++ b/packages/megalodon/test/unit/misskey/api_client.spec.ts @@ -34,7 +34,7 @@ describe('api_client', () => { dist: MisskeyNotificationType.Reaction }, { - src: MegalodonNotificationType.EmojiReaction, + src: MegalodonNotificationType.Reaction, dist: MisskeyNotificationType.Reaction }, { @@ -80,7 +80,7 @@ describe('api_client', () => { }, { src: MisskeyNotificationType.Reaction, - dist: MegalodonNotificationType.EmojiReaction + dist: MegalodonNotificationType.Reaction }, { src: MisskeyNotificationType.PollEnded, diff --git a/packages/megalodon/test/unit/parser.spec.ts b/packages/megalodon/test/unit/parser.spec.ts index 585210773..5174a647c 100644 --- a/packages/megalodon/test/unit/parser.spec.ts +++ b/packages/megalodon/test/unit/parser.spec.ts @@ -54,7 +54,7 @@ const status: Entity.Status = { } as Entity.Application, language: null, pinned: null, - emoji_reactions: [], + reactions: [], bookmarked: false, quote: null } diff --git a/packages/megalodon/tsconfig.json b/packages/megalodon/tsconfig.json index 45efcd8a2..5a9bfbde9 100644 --- a/packages/megalodon/tsconfig.json +++ b/packages/megalodon/tsconfig.json @@ -3,7 +3,7 @@ /* Basic Options */ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ - "lib": ["es6", "dom"], /* Specify library files to be included in the compilation. */ + "lib": ["es2021", "dom"], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */