diff --git a/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts b/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts new file mode 100644 index 000000000..a6c1ae9aa --- /dev/null +++ b/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserProfileMentions1697302438587 implements MigrationInterface { + name = 'AddUserProfileMentions1697302438587' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mentions" jsonb NOT NULL DEFAULT '[]'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mentions"`); + } +} diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts index 259f78e57..474afc4f5 100644 --- a/packages/backend/src/misc/extract-mentions.ts +++ b/packages/backend/src/misc/extract-mentions.ts @@ -10,4 +10,4 @@ export function extractMentions( const mentions = mentionNodes.map((x) => x.props); return mentions; -} +} \ No newline at end of file diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index cf13fa1c5..4226a429f 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -269,9 +269,11 @@ export class Note { } } -export type IMentionedRemoteUsers = { +export type IMentionedRemoteUser = { uri: string; url?: string; username: string; host: string; -}[]; +}; + +export type IMentionedRemoteUsers = IMentionedRemoteUser[]; diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 002247d3a..278aec958 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -54,6 +54,11 @@ export class UserProfile { verified?: boolean; }[]; + @Column("jsonb", { + default: [], + }) + public mentions: IMentionedRemoteUsers; + @Column("varchar", { length: 32, nullable: true, @@ -257,3 +262,10 @@ export class UserProfile { } } } + +type IMentionedRemoteUsers = { + uri: string; + url?: string; + username: string; + host: string; +}[] \ No newline at end of file diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index bef3d270f..1ca1857cd 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -68,6 +68,7 @@ import { UserIp } from "./entities/user-ip.js"; import { NoteEdit } from "./entities/note-edit.js"; import { OAuthApp } from "@/models/entities/oauth-app.js"; import { OAuthToken } from "@/models/entities/oauth-token.js"; +import { UserProfileRepository } from "@/models/repositories/user-profile.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -82,7 +83,7 @@ export const NoteUnreads = db.getRepository(NoteUnread); export const Polls = db.getRepository(Poll); export const PollVotes = db.getRepository(PollVote); export const Users = UserRepository; -export const UserProfiles = db.getRepository(UserProfile); +export const UserProfiles = UserProfileRepository; export const UserKeypairs = db.getRepository(UserKeypair); export const UserPendings = db.getRepository(UserPending); export const AttestationChallenges = db.getRepository(AttestationChallenge); diff --git a/packages/backend/src/models/repositories/user-profile.ts b/packages/backend/src/models/repositories/user-profile.ts new file mode 100644 index 000000000..ef540c277 --- /dev/null +++ b/packages/backend/src/models/repositories/user-profile.ts @@ -0,0 +1,42 @@ +import { db } from "@/db/postgre.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import mfm from "mfm-js"; +import { extractMentions } from "@/misc/extract-mentions.js"; +import { resolveMentionToUserAndProfile } from "@/remote/resolve-user.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { unique } from "@/prelude/array.js"; + +export const UserProfileRepository = db.getRepository(UserProfile).extend({ + async updateMentions(id: UserProfile["userId"]){ + const profile = await this.findOneBy({ userId: id }); + if (!profile) return; + const tokens: mfm.MfmNode[] = []; + + if (profile.description) + tokens.push(...mfm.parse(profile.description)); + if (profile.fields.length > 0) + tokens.push(...profile.fields.map(p => mfm.parse(p.value).concat(mfm.parse(p.name))).flat()); + + const partial = { + mentions: await populateMentions(tokens, profile.userHost) + }; + + return UserProfileRepository.update(profile.userId, partial) + }, +}); + +async function populateMentions(tokens: mfm.MfmNode[], objectHost: string | null): Promise { + const mentions = extractMentions(tokens); + const resolved = await Promise.all(mentions.map(m => resolveMentionToUserAndProfile(m.username, m.host, objectHost))); + const remote = resolved.filter(p => p && p.data.host !== null).map(p => p!); + const res = remote.map(m => { + return { + uri: m.user.uri!, + url: m.profile?.url ?? undefined, + username: m.data.username, + host: m.data.host! + }; + }); + + return unique(res); +} diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts index f3beb789e..7046ab066 100644 --- a/packages/backend/src/remote/activitypub/renderer/person.ts +++ b/packages/backend/src/remote/activitypub/renderer/person.ts @@ -73,7 +73,7 @@ export async function renderPerson(user: ILocalUser) { preferredUsername: user.username, name: user.name, summary: profile.description - ? await toHtml(mfm.parse(profile.description), [], profile.userHost) + ? await toHtml(mfm.parse(profile.description), profile.mentions, profile.userHost) : null, icon: avatar ? renderImage(avatar) : null, image: banner ? renderImage(banner) : null, diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts index b1cb5ca13..9a4068563 100644 --- a/packages/backend/src/remote/resolve-user.ts +++ b/packages/backend/src/remote/resolve-user.ts @@ -178,13 +178,33 @@ export async function resolveUser( return user; } -export async function resolveMentionWithFallback(username: string, host: string | null, objectHost: string | null, cache: IMentionedRemoteUsers): Promise { +export async function resolveMentionToUserAndProfile(username: string, host: string | null, objectHost: string | null) { + try { + //const fallback = getMentionFallbackUri(username, host, objectHost); + const user = await resolveUser(username, host ?? objectHost, false); + const profile = await UserProfiles.findOneBy({ userId: user.id }); + const data = { username, host: host ?? objectHost }; + + return { user, profile, data }; + } + catch { + return null; + } +} + +export function getMentionFallbackUri(username: string, host: string | null, objectHost: string | null): string { let fallback = `${config.url}/@${username}`; if (host !== null && host !== config.domain) fallback += `@${host}`; else if (objectHost !== null && objectHost !== config.domain && host !== config.domain) fallback += `@${objectHost}`; + return fallback; +} + +export async function resolveMentionWithFallback(username: string, host: string | null, objectHost: string | null, cache: IMentionedRemoteUsers): Promise { + const fallback = getMentionFallbackUri(username, host, objectHost); + const cached = cache.find(r => r.username.toLowerCase() === username.toLowerCase() && r.host === host); if (cached) return cached.url ?? cached.uri; if ((host === null && objectHost === null) || host === config.domain) return fallback; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 6d3bde2b8..edcb36f9d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -306,8 +306,10 @@ export default define(meta, paramDef, async (ps, _user, token) => { //#endregion if (Object.keys(updates).length > 0) await Users.update(user.id, updates); - if (Object.keys(profileUpdates).length > 0) + if (Object.keys(profileUpdates).length > 0) { await UserProfiles.update(user.id, profileUpdates); + await UserProfiles.updateMentions(user.id); + } const iObj = await Users.pack(user.id, user, { detail: true, diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index 01f6e4467..71622cbf5 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -9,6 +9,7 @@ import { awaitAll } from "@/prelude/await-all.js"; import { AccountCache } from "@/server/api/mastodon/helpers/user.js"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; type Field = { name: string; @@ -31,7 +32,7 @@ export class UserConverter { acctUrl = `https://${u.host}/@${u.username}`; } const profile = UserProfiles.findOneBy({ userId: u.id }); - const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), [], u.host).then(p => p ?? escapeMFM(profile?.description ?? ""))); + const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then(p => p ?? escapeMFM(profile?.description ?? ""))); const avatar = u.avatarId ? (DriveFiles.findOneBy({ id: u.avatarId })) .then(p => p?.url ?? Users.getIdenticonUrl(u.id)) @@ -92,7 +93,7 @@ export class UserConverter { header_static: banner, emojis: populateEmojis(u.emojis, u.host).then(emoji => emoji.map((e) => EmojiConverter.encode(e))), moved: null, //FIXME - fields: profile.then(profile => Promise.all(profile?.fields.map(async p => this.encodeField(p, u.host)) ?? [])), + fields: profile.then(profile => Promise.all(profile?.fields.map(async p => this.encodeField(p, u.host, profile?.mentions)) ?? [])), bot: u.isBot, discoverable: u.isExplorable }).then(p => { @@ -107,10 +108,10 @@ export class UserConverter { return Promise.all(encoded); } - private static async encodeField(f: Field, host: string | null): Promise { + private static async encodeField(f: Field, host: string | null, mentions: IMentionedRemoteUsers): Promise { return { name: f.name, - value: await MfmHelpers.toHtml(mfm.parse(f.value), [], host, true) ?? escapeMFM(f.value), + value: await MfmHelpers.toHtml(mfm.parse(f.value), mentions, host, true) ?? escapeMFM(f.value), verified_at: f.verified ? (new Date()).toISOString() : null, } } diff --git a/packages/backend/src/server/api/mastodon/helpers/mfm.ts b/packages/backend/src/server/api/mastodon/helpers/mfm.ts index a2f59ac42..d4a97bbf4 100644 --- a/packages/backend/src/server/api/mastodon/helpers/mfm.ts +++ b/packages/backend/src/server/api/mastodon/helpers/mfm.ts @@ -10,7 +10,7 @@ export class MfmHelpers { nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], objectHost: string | null, - inline: boolean = false, + inline: boolean = false ) { if (nodes == null) { return null; diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts index 2d989dce3..4a000e405 100644 --- a/packages/backend/src/server/api/mastodon/helpers/user.ts +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -193,7 +193,10 @@ export class UserHelpers { if (formData.discoverable) updates.isExplorable = formData.discoverable; if (Object.keys(updates).length > 0) await Users.update(user.id, updates); - if (Object.keys(profileUpdates).length > 0) await UserProfiles.update({ userId: user.id }, profileUpdates); + if (Object.keys(profileUpdates).length > 0) { + await UserProfiles.update({ userId: user.id }, profileUpdates); + await UserProfiles.updateMentions(user.id); + } return this.verifyCredentials(ctx); }