From 3fb3f405eadb205408108caf2498e01e447b08f5 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Mon, 2 Oct 2023 21:22:40 +0200 Subject: [PATCH] [mastodon-client] GET /v1/conversations --- .../server/api/mastodon/endpoints/timeline.ts | 20 ++++--- .../server/api/mastodon/helpers/pagination.ts | 30 ++++++----- .../server/api/mastodon/helpers/timeline.ts | 53 ++++++++++++++++++- 3 files changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 087e32721..0beb77fd9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -8,6 +8,7 @@ import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { UserLists } from "@/models/index.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) { let object: any = q; @@ -180,12 +181,19 @@ export function setupEndpointsTimeline(router: Router): void { const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getConversationTimeline( - convertPaginationArgsIds(limitToInt(ctx.query)), - ); - ctx.body = data.data.map((conversation) => - convertConversation(conversation), - ); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const res = await TimelineHelpers.getConversations(user, args.max_id, args.since_id, args.min_id, args.limit); + + ctx.body = res.data.map(c => convertConversation(c)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); } catch (e: any) { console.error(e); console.error(e.response.data); diff --git a/packages/backend/src/server/api/mastodon/helpers/pagination.ts b/packages/backend/src/server/api/mastodon/helpers/pagination.ts index aae35b669..ad6e70f99 100644 --- a/packages/backend/src/server/api/mastodon/helpers/pagination.ts +++ b/packages/backend/src/server/api/mastodon/helpers/pagination.ts @@ -8,30 +8,32 @@ export class PaginationHelpers { sinceId?: string, maxId?: string, minId?: string, - idField: string = "id" + idField: string = "id", + autoPrefix: boolean = true ) { if (sinceId && minId) throw new Error("Can't user both sinceId and minId params"); + if (autoPrefix) idField = `${q.alias}.${idField}`; if (sinceId && maxId) { - q.andWhere(`${q.alias}.${idField} > :sinceId`, {sinceId: sinceId}); - q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId}); - q.orderBy(`${q.alias}.${idField}`, "DESC"); + q.andWhere(`${idField} > :sinceId`, {sinceId: sinceId}); + q.andWhere(`${idField} < :maxId`, {maxId: maxId}); + q.orderBy(`${idField}`, "DESC"); } if (minId && maxId) { - q.andWhere(`${q.alias}.${idField} > :minId`, {minId: minId}); - q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId}); - q.orderBy(`${q.alias}.${idField}`, "ASC"); + q.andWhere(`${idField} > :minId`, {minId: minId}); + q.andWhere(`${idField} < :maxId`, {maxId: maxId}); + q.orderBy(`${idField}`, "ASC"); } else if (sinceId) { - q.andWhere(`${q.alias}.${idField} > :sinceId`, {sinceId: sinceId}); - q.orderBy(`${q.alias}.${idField}`, "DESC"); + q.andWhere(`${idField} > :sinceId`, {sinceId: sinceId}); + q.orderBy(`${idField}`, "DESC"); } else if (minId) { - q.andWhere(`${q.alias}.${idField} > :minId`, {minId: minId}); - q.orderBy(`${q.alias}.${idField}`, "ASC"); + q.andWhere(`${idField} > :minId`, {minId: minId}); + q.orderBy(`${idField}`, "ASC"); } else if (maxId) { - q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId}); - q.orderBy(`${q.alias}.${idField}`, "DESC"); + q.andWhere(`${idField} < :maxId`, {maxId: maxId}); + q.orderBy(`${idField}`, "DESC"); } else { - q.orderBy(`${q.alias}.${idField}`, "DESC"); + q.orderBy(`${idField}`, "DESC"); } return q; } diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index ed2c7a612..c0b1b502a 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -1,5 +1,5 @@ import { Note } from "@/models/entities/note.js"; -import { ILocalUser } from "@/models/entities/user.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; import { Followings, Notes, UserListJoinings } from "@/models/index.js"; import { Brackets } from "typeorm"; import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; @@ -12,6 +12,11 @@ import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/gener import { fetchMeta } from "@/misc/fetch-meta.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { UserList } from "@/models/entities/user-list.js"; +import { LinkPaginationObject, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { unique } from "@/prelude/array.js"; export class TimelineHelpers { public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise { @@ -153,4 +158,50 @@ export class TimelineHelpers { return PaginationHelpers.execQuery(query, limit, minId !== undefined); } + + public static async getConversations(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise> { + if (limit > 40) limit = 40; + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId, + "COALESCE(note.threadId, note.id)", + false + ) + .distinctOn(["COALESCE(note.threadId, note.id)"]) + .orderBy({"COALESCE(note.threadId, note.id)": minId ? "ASC" : "DESC", "note.id": "DESC"}) + .andWhere("note.visibility = 'specified'") + .andWhere( + new Brackets(qb => { + qb.where("note.userId = :userId"); + qb.orWhere("note.visibleUserIds @> array[:userId]::varchar[]"); + })) + .setParameters({userId: user.id}) + + return query.take(limit).getMany().then(p => { + if (minId !== undefined) p = p.reverse(); + const cache = UserHelpers.getFreshAccountCache(); + const conversations = p.map(c => { + // Gather all unique IDs except for the local user + const userIds = unique([c.userId].concat(c.visibleUserIds).filter(p => p != user.id)); + const users = userIds.map(id => UserHelpers.getUserCached(id, cache).catch(_ => null)); + const accounts = Promise.all(users).then(u => UserConverter.encodeMany(u.filter(u => u) as User[], cache)); + + return { + id: c.threadId ?? c.id, + accounts: accounts.then(u => u.length > 0 ? u : UserConverter.encodeMany([user], cache)), // failsafe to prevent apps from crashing case when all participant users have been deleted + last_status: NoteConverter.encode(c, user, cache), + unread: false //FIXME implement this (also the /v1/conversations/:id/read endpoint) + } + }); + const res = { + data: Promise.all(conversations.map(c => awaitAll(c))), + maxId: p.map(p => p.threadId ?? p.id).at(-1), + minId: p.map(p => p.threadId ?? p.id)[0], + }; + + return awaitAll(res); + }); + } }