import * as mfm from "mfm-js"; import { publishNoteStream, publishNoteUpdatesStream } from "../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 { Users, Notes, UserProfiles, Polls, NoteEdits, PollVotes } from "../../models/index.js"; import { In } from "typeorm"; import { genId } from "../../misc/gen-id.js"; import { deliverToRelays } from "../relay.js"; import renderUpdate from "../../remote/activitypub/renderer/update.js"; import { extractMentionedUsers } from "./create.js"; import { normalizeForSearch } from "../../misc/normalize-for-search.js"; import { NoteConverter } from "../../server/api/mastodon/converters/note.js"; export default async function(user, note, data, suppressEvidence = false) { 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 = extractHashtags(tokens).filter((tag)=>Array.from(tag || "").length <= 128).splice(0, 32).map(normalizeForSearch); 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 }; }); let publishing = false; const update = {}; if (data.text !== null && data.text !== note.text) { update.text = data.text; } if (data.cw !== note.cw) { update.cw = data.cw ?? null; } if (data.files !== undefined && 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 (data.poll !== undefined && 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: new Array(data.poll?.choices.length).fill(0), expiresAt: data.poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, userId: user.id, userHost: user.host }); publishing = true; } else { const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(data.poll.choices); if (dbPoll.multiple !== data.poll.multiple || dbPoll.expiresAt !== data.poll.expiresAt || dbPoll.noteVisibility !== note.visibility || choicesChanged) { await Polls.update({ noteId: note.id }, { choices: data.poll?.choices, multiple: data.poll?.multiple, votes: choicesChanged ? new Array(data.poll.choices.length).fill(0) : undefined, expiresAt: data.poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility }); // Reset votes if (JSON.stringify(dbPoll.choices) !== JSON.stringify(data.poll.choices)) { await PollVotes.delete({ noteId: dbPoll.noteId }); } publishing = true; } } } if (data.quoteAuthorization !== undefined) { update.quoteAuthorization = data.quoteAuthorization; } if (notEmpty(update)) { if (!suppressEvidence) update.updatedAt = new Date(); await Notes.update(note.id, update); if (!suppressEvidence) { // 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 ?? undefined }); } publishing = true; } note = await Notes.findOneByOrFail({ id: note.id }); if (publishing) { NoteConverter.prewarmCache(note); // Publish update event for the updated note details if (!suppressEvidence) { publishNoteStream(note.id, "updated", { updatedAt: update.updatedAt }); publishNoteUpdatesStream("updated", note); } (async ()=>{ if (note.localOnly) return; const noteActivity = await renderNote(note, false); if (!suppressEvidence) 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); } // 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) { return Object.keys(partial).length > 0; }