Mastodon API: implement proposed Glitch emoji reactions API

This commit is contained in:
Vyr Cossont 2023-07-24 14:29:41 -07:00 committed by Vyr Cossont
parent df3f2d2b5e
commit 051456ee3b
13 changed files with 155 additions and 28 deletions

View File

@ -32,6 +32,8 @@ export function convertNotification(notification: Entity.Notification) {
notification.id = convertId(notification.id, IdType.MastodonId); notification.id = convertId(notification.id, IdType.MastodonId);
if (notification.status) if (notification.status)
notification.status = convertStatus(notification.status); notification.status = convertStatus(notification.status);
if (notification.reaction)
notification.reaction = convertReaction(notification.reaction);
return notification; return notification;
} }
@ -67,7 +69,7 @@ export function convertStatus(status: Entity.Status) {
})); }));
if (status.poll) status.poll = convertPoll(status.poll); if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog); if (status.reblog) status.reblog = convertStatus(status.reblog);
status.emoji_reactions = status.mentions.map(convertReaction); status.reactions = status.reactions.map(convertReaction);
return status; return status;
} }

View File

@ -48,7 +48,7 @@ export function apiStatusMastodon(router: Router): void {
try { try {
const id = body.in_reply_to_id; const id = body.in_reply_to_id;
const post = await client.getStatus(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); const data = await client.deleteEmojiReaction(id, react);
ctx.body = data.data; ctx.body = data.data;
} catch (e: any) { } 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) => { router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization; const accessTokens = ctx.headers.authorization;

View File

@ -7,7 +7,7 @@ namespace Entity {
created_at: string; created_at: string;
id: string; id: string;
status?: Status; status?: Status;
emoji?: string; reaction?: Reaction;
type: NotificationType; type: NotificationType;
}; };

View File

@ -6,6 +6,7 @@ namespace Entity {
me: boolean; me: boolean;
name: string; name: string;
url?: string; url?: string;
static_url?: string;
accounts?: Array<Account>; accounts?: Array<Account>;
}; };
} }

View File

@ -38,7 +38,7 @@ namespace Entity {
application: Application | null; application: Application | null;
language: string | null; language: string | null;
pinned: boolean | null; pinned: boolean | null;
emoji_reactions: Array<Reaction>; reactions: Array<Reaction>;
quote: Status | null; quote: Status | null;
bookmarked: boolean; bookmarked: boolean;
}; };

View File

@ -857,6 +857,21 @@ export interface MegalodonInterface {
* @return Status * @return Status
*/ */
unpinStatus(id: string): Promise<Response<Entity.Status>>; unpinStatus(id: string): Promise<Response<Entity.Status>>;
/**
* 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<Response<Entity.Status>>;
/**
* 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<Response<Entity.Status>>;
// ====================================== // ======================================
// statuses/media // statuses/media
// ====================================== // ======================================

View File

@ -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<Response<Entity.Status>> {
await this.client.post<{}>("/api/notes/reactions/create", {
noteId: id,
reaction: this.reactionName(name),
});
return this.client
.post<MisskeyAPI.Entity.Note>("/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<Response<Entity.Status>> {
await this.client.post<{}>("/api/notes/reactions/delete", {
noteId: id,
reaction: this.reactionName(name),
});
return this.client
.post<MisskeyAPI.Entity.Note>("/api/notes/show", {
noteId: id,
})
.then(async (res) => ({
...res,
data: await this.noteWithDetails(
res.data,
this.baseUrlToHost(this.baseUrl),
this.getFreshAccountCache(),
),
}));
}
// ====================================== // ======================================
// statuses/media // statuses/media
// ====================================== // ======================================

View File

@ -321,7 +321,10 @@ namespace MisskeyAPI {
content: n.text ? this.escapeMFM(n.text) : "", content: n.text ? this.escapeMFM(n.text) : "",
plain_content: n.text ? n.text : null, plain_content: n.text ? n.text : null,
created_at: n.createdAt, 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, replies_count: n.repliesCount,
reblogs_count: n.renoteCount, reblogs_count: n.renoteCount,
favourites_count: this.getTotalReactions(n.reactions), favourites_count: this.getTotalReactions(n.reactions),
@ -339,28 +342,36 @@ namespace MisskeyAPI {
application: null, application: null,
language: null, language: null,
pinned: 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, bookmarked: false,
quote: n.renote && n.text ? this.note(n.renote, host) : null, quote: n.renote && n.text ? this.note(n.renote, host) : null,
}; };
}; };
mapReactions = ( mapReactions = (
emojis: Array<MisskeyEntity.Emoji>,
r: { [key: string]: number }, r: { [key: string]: number },
myReaction?: string, myReaction?: string,
): Array<MegalodonEntity.Reaction> => { ): Array<MegalodonEntity.Reaction> => {
// Map of emoji shortcodes to image URLs.
const emojiUrls = new Map<string, string>(
emojis.map((e) => [e.name, e.url]),
);
return Object.keys(r).map((key) => { return Object.keys(r).map((key) => {
if (myReaction && key === myReaction) { // Strip colons from custom emoji reaction names to match emoji shortcodes.
return { const shortcode = key.replaceAll(":", "");
count: r[key], // If this is a custom emoji (vs. a Unicode emoji), find its image URL.
me: true, const url = emojiUrls.get(shortcode);
name: key, // Finally, remove trailing @. from local custom emoji reaction names.
}; const name = shortcode.replace("@.", "");
}
return { return {
count: r[key], count: r[key],
me: false, me: key === myReaction,
name: key, 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: case NotificationType.Mention:
return MisskeyNotificationType.Reply; return MisskeyNotificationType.Reply;
case NotificationType.Favourite: case NotificationType.Favourite:
case NotificationType.EmojiReaction: case NotificationType.Reaction:
return MisskeyNotificationType.Reaction; return MisskeyNotificationType.Reaction;
case NotificationType.Reblog: case NotificationType.Reblog:
return MisskeyNotificationType.Renote; return MisskeyNotificationType.Renote;
@ -448,7 +459,7 @@ namespace MisskeyAPI {
case MisskeyNotificationType.Quote: case MisskeyNotificationType.Quote:
return NotificationType.Reblog; return NotificationType.Reblog;
case MisskeyNotificationType.Reaction: case MisskeyNotificationType.Reaction:
return NotificationType.EmojiReaction; return NotificationType.Reaction;
case MisskeyNotificationType.PollEnded: case MisskeyNotificationType.PollEnded:
return NotificationType.Poll; return NotificationType.Poll;
case MisskeyNotificationType.ReceiveFollowRequest: case MisskeyNotificationType.ReceiveFollowRequest:
@ -496,11 +507,11 @@ namespace MisskeyAPI {
account: this.note(n.note, host).account, account: this.note(n.note, host).account,
}); });
} }
} if (n.reaction) {
if (n.reaction) { notification = Object.assign(notification, {
notification = Object.assign(notification, { reaction: this.mapReactions(n.note.emojis, { [n.reaction]: 1 })[0],
emoji: n.reaction, });
}); }
} }
return notification; return notification;
}; };

View File

@ -5,7 +5,7 @@ namespace NotificationType {
export const Favourite: Entity.NotificationType = "favourite"; export const Favourite: Entity.NotificationType = "favourite";
export const Reblog: Entity.NotificationType = "reblog"; export const Reblog: Entity.NotificationType = "reblog";
export const Mention: Entity.NotificationType = "mention"; 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 FollowRequest: Entity.NotificationType = "follow_request";
export const Status: Entity.NotificationType = "status"; export const Status: Entity.NotificationType = "status";
export const Poll: Entity.NotificationType = "poll"; export const Poll: Entity.NotificationType = "poll";

View File

@ -163,7 +163,7 @@ describe('getNotifications', () => {
}, },
{ {
event: reaction, event: reaction,
expected: MegalodonNotificationType.EmojiReaction, expected: MegalodonNotificationType.Reaction,
title: 'reaction' title: 'reaction'
}, },
{ {

View File

@ -34,7 +34,7 @@ describe('api_client', () => {
dist: MisskeyNotificationType.Reaction dist: MisskeyNotificationType.Reaction
}, },
{ {
src: MegalodonNotificationType.EmojiReaction, src: MegalodonNotificationType.Reaction,
dist: MisskeyNotificationType.Reaction dist: MisskeyNotificationType.Reaction
}, },
{ {
@ -80,7 +80,7 @@ describe('api_client', () => {
}, },
{ {
src: MisskeyNotificationType.Reaction, src: MisskeyNotificationType.Reaction,
dist: MegalodonNotificationType.EmojiReaction dist: MegalodonNotificationType.Reaction
}, },
{ {
src: MisskeyNotificationType.PollEnded, src: MisskeyNotificationType.PollEnded,

View File

@ -54,7 +54,7 @@ const status: Entity.Status = {
} as Entity.Application, } as Entity.Application,
language: null, language: null,
pinned: null, pinned: null,
emoji_reactions: [], reactions: [],
bookmarked: false, bookmarked: false,
quote: null quote: null
} }

View File

@ -3,7 +3,7 @@
/* Basic Options */ /* Basic Options */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ "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'. */ "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. */ // "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */