diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 49274f707..32534d9ef 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; -import { convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; +import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; import { getInstance } from './endpoints/meta.js'; import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -102,8 +102,8 @@ export class MastodonApiServerService { }, order: { id: 'ASC' }, }); - const contact = admin == null ? null : convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch())); + const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data); + reply.send(await getInstance(data.data, contact as Entity.Account, this.config, await this.metaService.fetch())); } catch (e: any) { /* console.error(e); */ reply.code(401).send(e.response.data); @@ -252,7 +252,7 @@ export class MastodonApiServerService { // displayed without being logged in try { const data = await client.updateCredentials(_request.body!); - reply.send(convertAccount(data.data)); + reply.send(await this.mastoConverter.convertAccount(data.data)); } catch (e: any) { /* console.error(e); */ reply.code(401).send(e.response.data); @@ -268,7 +268,7 @@ export class MastodonApiServerService { const data = await client.search((_request.query as any).acct, { type: 'accounts' }); const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || []; - reply.send(convertAccount(data.data.accounts[0])); + reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0])); } catch (e: any) { /* console.error(e); */ reply.code(401).send(e.response.data); @@ -544,7 +544,7 @@ export class MastodonApiServerService { const client = getClient(BASE_URL, accessTokens); try { const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); - reply.send(data.data.map((account) => convertAccount(account as Entity.Account))); + reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverter.convertAccount(account as Entity.Account)))); } catch (e: any) { /* console.error(e); console.error(e.response.data); */ @@ -587,7 +587,7 @@ export class MastodonApiServerService { const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL); + const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.SearchV1()); } catch (e: any) { /* console.error(e); @@ -601,7 +601,7 @@ export class MastodonApiServerService { const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL); + const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.SearchV2()); } catch (e: any) { /* console.error(e); @@ -615,7 +615,7 @@ export class MastodonApiServerService { const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL); + const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.getStatusTrends()); } catch (e: any) { /* console.error(e); @@ -629,7 +629,7 @@ export class MastodonApiServerService { const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const search = new ApiSearchMastodon(_request, client, BASE_URL); + const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); reply.send(await search.getSuggestions()); } catch (e: any) { /* console.error(e); diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 22bc3cf6f..51556adf2 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -7,11 +7,11 @@ import type { Config } from '@/config.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; import type { NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { GetterService } from '../GetterService.js'; -import { ReactionService } from '@/core/ReactionService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { IdService } from '@/core/IdService.js'; export enum IdConvertType { MastodonId, @@ -39,10 +39,14 @@ export class MastoConverters { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.noteEditRepository) + private noteEditRepository: NoteEditRepository, + private mfmService: MfmService, private getterService: GetterService, private customEmojiService: CustomEmojiService, - private reactionService: ReactionService, + private idService: IdService, + private driveFileEntityService: DriveFileEntityService, ) { } @@ -64,6 +68,39 @@ export class MastoConverters { }; } + public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' { + if (s === 'image/gif') { + return 'gifv'; + } + if (s.includes('image')) { + return 'image'; + } + if (s.includes('video')) { + return 'video'; + } + if (s.includes('audio')) { + return 'audio'; + } + return 'unknown'; + } + + public encodeFile(f: any): Entity.Attachment { + return { + id: f.id, + type: this.fileType(f.type), + url: f.url, + remote_url: f.url, + preview_url: f.thumbnailUrl, + text_url: f.url, + meta: { + width: f.properties.width, + height: f.properties.height + }, + description: f.comment ? f.comment : null, + blurhash: f.blurhash ? f.blurhash : null + }; + } + public async getUser(id: string): Promise { return this.getterService.getUser(id).then(p => { return p; @@ -78,7 +115,7 @@ export class MastoConverters { }; } - public async convertAccount(account: Entity.Account) { + public async convertAccount(account: Entity.Account | MiUser) { const user = await this.getUser(account.id); const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host); @@ -93,19 +130,26 @@ export class MastoConverters { category: undefined, }); }); + const fqn = `${user.username}@${user.host ?? this.config.hostname}`; + let acct = user.username; + let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; + if (user.host) { + acct = `${user.username}@${user.host}`; + acctUrl = `https://${user.host}/@${user.username}`; + } return awaitAll({ id: account.id, - username: account.username, - acct: account.acct, - fqn: account.fqn, - display_name: account.display_name || account.username, + username: user.username, + acct: acct, + fqn: fqn, + display_name: user.name ?? user.username, locked: user.isLocked, - created_at: account.created_at, + created_at: this.idService.parse(user.id).date.toISOString(), followers_count: user.followersCount, following_count: user.followingCount, statuses_count: user.notesCount, - note: profile?.description ?? account.note, - url: account.url, + note: profile?.description ?? '', + url: user.uri ?? acctUrl, avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', @@ -118,6 +162,36 @@ export class MastoConverters { }); } + public async getEdits(id: string) { + const note = await this.getterService.getNote(id); + if (!note) { + return {}; + } + + const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); + const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); + const history: Promise[] = []; + + let lastDate = this.idService.parse(note.id).date; + for (const edit of edits) { + const files = this.driveFileEntityService.packManyByIds(edit.fileIds); + const item = { + account: noteUser, + content: this.mfmService.toMastoHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), + created_at: lastDate.toISOString(), + emojis: [], + sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), + spoiler_text: edit.cw ?? '', + poll: null, + media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []) + }; + lastDate = edit.updatedAt; + history.push(awaitAll(item)); + } + + return await Promise.all(history); + } + public async convertStatus(status: Entity.Status) { const convertedAccount = this.convertAccount(status.account); const note = await this.getterService.getNote(status.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index d5839ff1c..772ef3307 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,5 +1,5 @@ import { Converter } from 'megalodon'; -import { convertAccount, convertStatus } from '../converters.js'; +import { MastoConverters, convertAccount, convertStatus } from '../converters.js'; import { limitToInt } from './timeline.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; @@ -63,7 +63,7 @@ export class ApiSearchMastodon { private client: MegalodonInterface; private BASE_URL: string; - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { + constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoConverter: MastoConverters) { this.request = request; this.client = client; this.BASE_URL = BASE_URL; @@ -89,8 +89,8 @@ export class ApiSearchMastodon { const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null; const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null; const data = { - accounts: acct?.data.accounts.map((account) => convertAccount(account)) ?? [], - statuses: stat?.data.statuses.map((status) => convertStatus(status)) ?? [], + accounts: await Promise.all(acct?.data.accounts.map(async (account) => await this.mastoConverter.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map(async (status) => await this.mastoConverter.convertStatus(status)) ?? []), hashtags: tags?.data.hashtags ?? [], }; return data; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index e5202244e..fe77646af 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -74,7 +74,8 @@ export class ApiStatusMastodon { public async getHistory() { this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => { try { - reply.send([]); + const edits = await this.mastoconverter.getEdits(_request.params.id); + reply.send(edits); } catch (e: any) { console.error(e); reply.code(401).send(e.response.data); diff --git a/packages/megalodon/src/entities/list.ts b/packages/megalodon/src/entities/list.ts index 58c264aba..281f02b11 100644 --- a/packages/megalodon/src/entities/list.ts +++ b/packages/megalodon/src/entities/list.ts @@ -2,7 +2,8 @@ namespace Entity { export type List = { id: string title: string - replies_policy: RepliesPolicy | null + replies_policy?: RepliesPolicy | null + exclusive?: RepliesPolicy | null } export type RepliesPolicy = 'followed' | 'list' | 'none' diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 66347fc46..520928c9f 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -391,7 +391,7 @@ namespace MisskeyAPI { export const list = (l: Entity.List): MegalodonEntity.List => ({ id: l.id, title: l.name, - replies_policy: null + exclusive: null }) export const encodeNotificationType = (