import * as mfm from "mfm-js"; import { publishNoteStream, } from "@/services/stream.js"; import DeliverManager from "@/remote/activitypub/deliver-manager.js"; import renderNote from "@/remote/activitypub/renderer/note.js"; import { renderActivity } from "@/remote/activitypub/renderer/index.js"; import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js"; import { extractHashtags } from "@/misc/extract-hashtags.js"; import type { IMentionedRemoteUsers } from "@/models/entities/note.js"; import { Note } from "@/models/entities/note.js"; import { Users, Notes, UserProfiles, Polls, NoteEdits, } from "@/models/index.js"; import type { DriveFile } from "@/models/entities/drive-file.js"; import { In } from "typeorm"; import type { ILocalUser, IRemoteUser } from "@/models/entities/user.js"; import { genId } from "@/misc/gen-id.js"; import type { IPoll } from "@/models/entities/poll.js"; import { deliverToRelays } from "../relay.js"; import renderUpdate from "@/remote/activitypub/renderer/update.js"; import { extractMentionedUsers, index } from "@/services/note/create.js"; type Option = { text?: string | null; files?: DriveFile[] | null; poll?: IPoll | null; cw?: string | null; }; export default async function ( user: ILocalUser, note: Note, data: Option, ): Promise { if (data.text !== undefined && data.text !== null) { data.text = data.text.trim(); } else { data.text = null; } const fileIds = data.files?.map((file) => file.id) ?? []; const fileTypes = data.files?.map((file) => file.type) ?? []; const tokens = mfm .parse(data.text || "") .concat(mfm.parse(data.cw || "")); const tags: string[] = extractHashtags(tokens); const emojis = extractCustomEmojisFromMfm(tokens); const mentionUsers = (await extractMentionedUsers(user, tokens)); const mentionUserIds = mentionUsers.map((user) => user.id); const remoteUsers = mentionUsers.filter((user) => user.host != null); const remoteUserIds = remoteUsers.map((user) => user.id); const remoteProfiles = await UserProfiles.findBy({ userId: In(remoteUserIds), }); const mentionedRemoteUsers = remoteUsers.map((user) => { const profile = remoteProfiles.find( (profile) => profile.userId === user.id, ); return { username: user.username, host: user.host ?? null, uri: user.uri, url: profile ? profile.url : undefined, } as IMentionedRemoteUsers[0]; }); let publishing = false; const update = {} as Partial; if (data.text !== null && data.text !== note.text) { update.text = data.text; } if (data.cw !== note.cw) { update.cw = data.cw ?? null; } if (fileIds.sort().join(",") !== note.fileIds.sort().join(",")) { update.fileIds = fileIds; update.attachedFileTypes = fileTypes; } if (tags.sort().join(",") !== note.tags.sort().join(",")) { update.tags = tags; } if (mentionUserIds.sort().join(",") !== note.mentions.sort().join(",")) { update.mentions = mentionUserIds; update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers); } if (emojis.sort().join(",") !== note.emojis.sort().join(",")) { update.emojis = emojis; } if (note.hasPoll !== !!data.poll) { update.hasPoll = !!data.poll; } if (data.poll) { const dbPoll = await Polls.findOneBy({ noteId: note.id }); if (dbPoll == null) { await Polls.insert({ noteId: note.id, choices: data.poll?.choices, multiple: data.poll?.multiple, votes: data.poll?.votes, expiresAt: data.poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, userId: user.id, userHost: user.host, }); publishing = true; } else if ( dbPoll.multiple !== data.poll.multiple || dbPoll.expiresAt !== data.poll.expiresAt || dbPoll.noteVisibility !== note.visibility || JSON.stringify(dbPoll.choices) !== JSON.stringify(data.poll.choices) ) { await Polls.update( { noteId: note.id }, { choices: data.poll?.choices, multiple: data.poll?.multiple, votes: data.poll?.votes, expiresAt: data.poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, }, ); publishing = true; } else { for (let i = 0; i < data.poll.choices.length; i++) { if (dbPoll.votes[i] !== data.poll.votes?.[i]) { await Polls.update({ noteId: note.id }, { votes: data.poll?.votes }); publishing = true; break; } } } } if (notEmpty(update)) { update.updatedAt = new Date(); await Notes.update(note.id, update); // Add previous note contents to NoteEdit history await NoteEdits.insert({ id: genId(), noteId: note.id, text: note.text || undefined, cw: note.cw, fileIds: note.fileIds, updatedAt: update.updatedAt, }); publishing = true; } note = await Notes.findOneByOrFail({ id: note.id }); if (publishing) { index(note, true); // Publish update event for the updated note details publishNoteStream(note.id, "updated", { updatedAt: update.updatedAt, }); (async () => { const noteActivity = await renderNote(note, false); noteActivity.updated = note.updatedAt.toISOString(); const updateActivity = renderUpdate(noteActivity, user); updateActivity.to = noteActivity.to; updateActivity.cc = noteActivity.cc; const activity = renderActivity(updateActivity); const dm = new DeliverManager(user, activity); // Delivery to remote mentioned users for (const u of mentionUsers.filter((u) => Users.isRemoteUser(u))) { dm.addDirectRecipe(u as IRemoteUser); } // Post is a reply and remote user is the contributor of the original post if (note.reply && note.reply.userHost !== null) { const u = await Users.findOneBy({ id: note.reply.userId }); if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); } // Post is a renote and remote user is the contributor of the original post if (note.renote && note.renote.userHost !== null) { const u = await Users.findOneBy({ id: note.renote.userId }); if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); } // Deliver to followers for non-direct posts. if (["public", "home", "followers"].includes(note.visibility)) { dm.addFollowersRecipe(); } // Deliver to relays for public posts. if (["public"].includes(note.visibility)) { deliverToRelays(user, activity); } // GO! dm.execute(); })(); } return note; } function notEmpty(partial: Partial) { return Object.keys(partial).length > 0; }