[mastodon-client] GET /notifications

This commit is contained in:
Laura Hausmann 2023-09-28 18:16:11 +02:00
parent 9d59ee09fd
commit 58dcbe68b7
Signed by: zotan
GPG key ID: D044E84C5BE01605
7 changed files with 158 additions and 26 deletions

View file

@ -27,7 +27,7 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) {
return simpleConvert(tag);
}
export function convertNotification(notification: Entity.Notification) {
export function convertNotification(notification: MastodonEntity.Notification) {
notification.account = convertAccount(notification.account);
notification.id = convertId(notification.id, IdType.MastodonId);
if (notification.status)

View file

@ -33,7 +33,7 @@ export class NoteConverter {
.map((x) => decodeReaction(x).reaction)
.map((x) => x.replace(/:/g, ""));
const noteEmoji = Promise.resolve(host).then(async host => populateEmojis(
const noteEmoji = host.then(async host => populateEmojis(
note.emojis.concat(reactionEmojiNames),
host,
));
@ -95,7 +95,7 @@ export class NoteConverter {
in_reply_to_id: note.replyId,
in_reply_to_account_id: note.replyUserId,
reblog: Promise.resolve(renote).then(renote => renote && note.text === null ? this.encode(renote, user, cache) : null),
content: Promise.resolve(text).then(text => text !== null ? toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""),
content: text.then(text => text !== null ? toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""),
text: text,
created_at: note.createdAt.toISOString(),
// Remove reaction emojis with names containing @ from the emojis list.

View file

@ -0,0 +1,82 @@
import { ILocalUser, User } from "@/models/entities/user.js";
import config from "@/config/index.js";
import { IMentionedRemoteUsers } from "@/models/entities/note.js";
import { Notification } from "@/models/entities/notification.js";
import { notificationTypes } from "@/types.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { awaitAll } from "@/prelude/await-all.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { getNote } from "@/server/api/common/getters.js";
type NotificationType = typeof notificationTypes[number];
export class NotificationConverter {
public static async encode(notification: Notification, localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification | null> {
if (notification.notifieeId !== localUser.id) return null;
//TODO: Test this (poll ended etc)
const account = notification.notifierId
? UserHelpers.getUserCached(notification.notifierId, cache).then(p => UserConverter.encode(p))
: UserConverter.encode(localUser);
let result = {
id: notification.id,
account: account,
created_at: notification.createdAt.toISOString(),
type: this.encodeNotificationType(notification.type),
};
if (notification.note) {
const isPureRenote = notification.note.renoteId !== null && notification.note.text === null;
const encodedNote = isPureRenote
? getNote(notification.note.renoteId!, localUser).then(note => NoteConverter.encode(note, localUser, cache))
: NoteConverter.encode(notification.note, localUser, cache);
result = Object.assign(result, {
status: encodedNote,
});
if (result.type === 'poll') {
result = Object.assign(result, {
account: encodedNote.then(p => p.account),
});
}
if (notification.reaction) {
//FIXME: Implement reactions;
}
}
return awaitAll(result);
}
public static async encodeMany(notifications: Notification[], localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification[]> {
const encoded = notifications.map(u => this.encode(u, localUser, cache));
return Promise.all(encoded)
.then(p => p.filter(n => n !== null) as MastodonEntity.Notification[]);
}
private static encodeNotificationType(t: NotificationType): MastodonEntity.NotificationType {
//FIXME: Implement custom notification for followRequestAccepted
//FIXME: Implement mastodon notification type 'update' on misskey side
switch (t) {
case "follow":
return 'follow';
case "mention":
case "reply":
return 'mention'
case "renote":
return 'reblog';
case "quote":
return 'reblog';
case "reaction":
return 'favourite';
case "pollEnded":
return 'poll';
case "receiveFollowRequest":
return 'follow_request';
case "followRequestAccepted":
case "pollVote":
case "groupInvited":
case "app":
throw new Error(`Notification type ${t} not supported`);
}
}
}

View file

@ -1,10 +1,13 @@
import megalodon, { MegalodonInterface } from "megalodon";
import Router from "@koa/router";
import { koaBody } from "koa-body";
import { convertId, IdType } from "../../index.js";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { convertTimelinesArgsId } from "./timeline.js";
import { convertTimelinesArgsId, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { convertNotification } from "../converters.js";
import authenticate from "@/server/api/authenticate.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js";
import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js";
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10);
return q;
@ -12,25 +15,23 @@ function toLimitToInt(q: any) {
export function apiNotificationsMastodon(router: Router): void {
router.get("/v1/notifications", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getNotifications(
convertTimelinesArgsId(toLimitToInt(ctx.query)),
);
const notfs = data.data;
const ret = notfs.map((n) => {
n = convertNotification(n);
if (n.type !== "follow" && n.type !== "follow_request") {
if (n.type === "reaction") n.type = "favourite";
return n;
} else {
return n;
}
});
ctx.body = ret;
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertTimelinesArgsId(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']);
const data = NotificationHelpers.getNotifications(user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id)
.then(p => NotificationConverter.encodeMany(p, user, cache))
.then(p => p.map(n => convertNotification(n)));
ctx.body = await data;
} catch (e: any) {
console.error(e);
ctx.status = 401;

View file

@ -61,11 +61,14 @@ export function convertTimelinesArgsId(q: ParsedUrlQuery) {
return q;
}
export function normalizeUrlQuery(q: ParsedUrlQuery): any {
export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []): any {
const dict: any = {};
for (const k in q) {
dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k];
if (arrayKeys.includes(k))
dict[k] = Array.isArray(q[k]) ? q[k] : [q[k]];
else
dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k];
}
return dict;

View file

@ -11,5 +11,5 @@ namespace MastodonEntity {
type: NotificationType;
};
export type NotificationType = string;
export type NotificationType = 'follow' | 'favourite' | 'reblog' | 'mention' | 'reaction' | 'follow_request' | 'status' | 'poll';
}

View file

@ -0,0 +1,46 @@
import { ILocalUser } from "@/models/entities/user.js";
import { Notifications } from "@/models/index.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { Notification } from "@/models/entities/notification.js";
export class NotificationHelpers {
public static async getNotifications(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 15, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined): Promise<Notification[]> {
if (limit > 30) limit = 30;
if (types && excludeTypes) throw new Error("types and exclude_types can not be used simultaneously");
let requestedTypes = types
? this.decodeTypes(types)
: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest'];
if (excludeTypes) {
const excludedTypes = this.decodeTypes(excludeTypes);
requestedTypes = requestedTypes.filter(p => !excludedTypes.includes(p));
}
const query = PaginationHelpers.makePaginationQuery(
Notifications.createQueryBuilder("notification"),
sinceId,
maxId,
minId
)
.andWhere("notification.notifieeId = :userId", { userId: user.id })
.andWhere("notification.type IN (:...types)", { types: requestedTypes });
if (accountId !== undefined)
query.andWhere("notification.notifierId = :notifierId", { notifierId: accountId });
query.leftJoinAndSelect("notification.note", "note");
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
private static decodeTypes(types: string[]) {
const result: string[] = [];
if (types.includes('follow')) result.push('follow');
if (types.includes('mention')) result.push('mention', 'reply');
if (types.includes('reblog')) result.push('renote', 'quote');
if (types.includes('favourite')) result.push('reaction');
if (types.includes('poll')) result.push('pollEnded');
if (types.includes('follow_request')) result.push('receiveFollowRequest');
return result;
}
}