diff --git a/docs/api-change.md b/docs/api-change.md index fdda2e78b4..b4cf9d39d6 100644 --- a/docs/api-change.md +++ b/docs/api-change.md @@ -2,6 +2,10 @@ Breaking changes are indicated by the :warning: icon. +## Unreleased + +- Added `notes/history` endpoint. + ## v20240319 - :warning: `followingCount` and `followersCount` in `users/show` will be `null` (instead of 0) if these values are unavailable. diff --git a/docs/downgrade.sql b/docs/downgrade.sql index 88e08c40ad..62cefb8132 100644 --- a/docs/downgrade.sql +++ b/docs/downgrade.sql @@ -1,6 +1,7 @@ BEGIN; DELETE FROM "migrations" WHERE name IN ( + 'ExpandNoteEdit1711936358554', 'markLocalFilesNsfwByDefault1709305200000', 'FixMutingIndices1710690239308', 'NoteFile1710304584214', @@ -19,6 +20,9 @@ DELETE FROM "migrations" WHERE name IN ( 'RemoveNativeUtilsMigration1705877093218' ); +-- expand-note-edit +ALTER TABLE "note_edit" DROP COLUMN "emojis"; + -- markLocalFilesNsfwByDefault ALTER TABLE "meta" DROP COLUMN "markLocalFilesNsfwByDefault"; diff --git a/locales/en-US.yml b/locales/en-US.yml index d736a7d881..9dba9a4363 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2234,3 +2234,4 @@ autocorrectNoteLanguage: "Show a warning if the post language does not match the result" incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected {current}.\nWould you like to set the language to {detected} instead?" +noteEditHistory: "Post edit history" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f67bf4a600..85b73d7b45 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2060,3 +2060,4 @@ noAltTextWarning: 有些附件没有描述。您是否忘记写描述了? showNoAltTextWarning: 当您尝试发布没有描述的帖子附件时显示警告 autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候显示警告 incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?" +noteEditHistory: "帖子编辑历史" diff --git a/packages/backend-rs/src/model/entity/note_edit.rs b/packages/backend-rs/src/model/entity/note_edit.rs index a403560b39..8a19202db3 100644 --- a/packages/backend-rs/src/model/entity/note_edit.rs +++ b/packages/backend-rs/src/model/entity/note_edit.rs @@ -16,6 +16,7 @@ pub struct Model { pub file_ids: Vec, #[sea_orm(column_name = "updatedAt")] pub updated_at: DateTimeWithTimeZone, + pub emojis: Vec, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/backend/src/migration/1711936358554-expand-note-edit.ts b/packages/backend/src/migration/1711936358554-expand-note-edit.ts new file mode 100644 index 0000000000..1f23736fd1 --- /dev/null +++ b/packages/backend/src/migration/1711936358554-expand-note-edit.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class ExpandNoteEdit1711936358554 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "note_edit" ADD "emojis" character varying(128) array NOT NULL DEFAULT '{}'::varchar[] + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "note_edit" DROP COLUMN "emojis" + `); + } +} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts index 0e21d0e2ab..45e36649a1 100644 --- a/packages/backend/src/misc/populate-emojis.ts +++ b/packages/backend/src/misc/populate-emojis.ts @@ -8,6 +8,7 @@ import { decodeReaction } from "./reaction-lib.js"; import config from "@/config/index.js"; import { query } from "@/prelude/url.js"; import { redisClient } from "@/db/redis.js"; +import type { NoteEdit } from "@/models/entities/note-edit.js"; const cache = new Cache("populateEmojis", 60 * 60 * 12); @@ -110,6 +111,23 @@ export async function populateEmojis( return emojis.filter((x): x is PopulatedEmoji => x != null); } +export function aggregateNoteEditEmojis( + noteEdits: NoteEdit[], + sourceHost: string | null, +) { + let emojis: string[] = []; + for (const noteEdit of noteEdits) { + emojis = emojis.concat(noteEdit.emojis); + } + emojis = Array.from(new Set(emojis)); + return emojis + .map((e) => parseEmojiStr(e, sourceHost)) + .filter((x) => x.name != null) as { + name: string; + host: string | null; + }[]; +} + export function aggregateNoteEmojis(notes: Note[]) { let emojis: { name: string | null; host: string | null }[] = []; for (const note of notes) { @@ -145,7 +163,7 @@ export function aggregateNoteEmojis(notes: Note[]) { } /** - * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + * Get the given list of emojis from the database and adds them to the cache */ export async function prefetchEmojis( emojis: { name: string; host: string | null }[], diff --git a/packages/backend/src/models/entities/note-edit.ts b/packages/backend/src/models/entities/note-edit.ts index 8761e2b153..ceb423411d 100644 --- a/packages/backend/src/models/entities/note-edit.ts +++ b/packages/backend/src/models/entities/note-edit.ts @@ -50,4 +50,11 @@ export class NoteEdit { comment: "The updated date of the Note.", }) public updatedAt: Date; + + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public emojis: string[]; } diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 5d4ff52198..c578d9d409 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -65,14 +65,14 @@ import { UserPending } from "./entities/user-pending.js"; import { InstanceRepository } from "./repositories/instance.js"; import { Webhook } from "./entities/webhook.js"; import { UserIp } from "./entities/user-ip.js"; -import { NoteEdit } from "./entities/note-edit.js"; import { NoteFileRepository } from "./repositories/note-file.js"; +import { NoteEditRepository } from "./repositories/note-edit.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); export const Apps = AppRepository; export const Notes = NoteRepository; -export const NoteEdits = db.getRepository(NoteEdit); +export const NoteEdits = NoteEditRepository; export const NoteFiles = NoteFileRepository; export const NoteFavorites = NoteFavoriteRepository; export const NoteWatchings = db.getRepository(NoteWatching); diff --git a/packages/backend/src/models/repositories/note-edit.ts b/packages/backend/src/models/repositories/note-edit.ts new file mode 100644 index 0000000000..210d489712 --- /dev/null +++ b/packages/backend/src/models/repositories/note-edit.ts @@ -0,0 +1,44 @@ +import { db } from "@/db/postgre.js"; +import { NoteEdit } from "@/models/entities/note-edit.js"; +import type { Note } from "@/models/entities/note.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import { DriveFiles } from "../index.js"; +import { + aggregateNoteEditEmojis, + populateEmojis, + prefetchEmojis, +} from "@/misc/populate-emojis.js"; + +export const NoteEditRepository = db.getRepository(NoteEdit).extend({ + async pack(noteEdit: NoteEdit, sourceNote: Note) { + const packed: Packed<"NoteEdit"> = await awaitAll({ + id: noteEdit.id, + noteId: noteEdit.noteId, + updatedAt: noteEdit.updatedAt.toISOString(), + text: noteEdit.text, + cw: noteEdit.cw, + fileIds: noteEdit.fileIds, + files: DriveFiles.packMany(noteEdit.fileIds), + emojis: populateEmojis(noteEdit.emojis, sourceNote.userHost), + }); + + return packed; + }, + async packMany(noteEdits: NoteEdit[], sourceNote: Note) { + if (noteEdits.length === 0) return []; + + await prefetchEmojis( + aggregateNoteEditEmojis(noteEdits, sourceNote.userHost), + ); + + const promises = await Promise.allSettled( + noteEdits.map((n) => this.pack(n, sourceNote)), + ); + + // filter out rejected promises, only keep fulfilled values + return promises.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + }, +}); diff --git a/packages/backend/src/models/schema/note-edit.ts b/packages/backend/src/models/schema/note-edit.ts index e877f3f946..478eece67c 100644 --- a/packages/backend/src/models/schema/note-edit.ts +++ b/packages/backend/src/models/schema/note-edit.ts @@ -16,7 +16,7 @@ export const packedNoteEdit = { }, note: { type: "object", - optional: false, + optional: true, nullable: false, ref: "Note", }, @@ -39,11 +39,27 @@ export const packedNoteEdit = { fileIds: { type: "array", optional: true, - nullable: true, + nullable: false, items: { type: "string", format: "id", }, }, + files: { + type: "array", + optional: true, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, + emojis: { + type: "object", + optional: true, + nullable: true, + }, }, } as const; diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index 0d706df5d7..3077ddb4f0 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -773,6 +773,7 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) { cw: note.cw, fileIds: note.fileIds, updatedAt: update.updatedAt, + emojis: note.emojis, }); publishing = true; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 52dd3382f7..9a0de00b8b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -240,6 +240,7 @@ import * as ep___notes_conversation from "./endpoints/notes/conversation.js"; import * as ep___notes_create from "./endpoints/notes/create.js"; import * as ep___notes_delete from "./endpoints/notes/delete.js"; import * as ep___notes_edit from "./endpoints/notes/edit.js"; +import * as ep___notes_history from "./endpoints/notes/history.js"; import * as ep___notes_favorites_create from "./endpoints/notes/favorites/create.js"; import * as ep___notes_favorites_delete from "./endpoints/notes/favorites/delete.js"; import * as ep___notes_featured from "./endpoints/notes/featured.js"; @@ -583,6 +584,7 @@ const eps = [ ["notes/create", ep___notes_create], ["notes/delete", ep___notes_delete], ["notes/edit", ep___notes_edit], + ["notes/history", ep___notes_history], ["notes/favorites/create", ep___notes_favorites_create], ["notes/favorites/delete", ep___notes_favorites_delete], ["notes/featured", ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 34d94157e6..012f22bbd7 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -621,6 +621,7 @@ export default define(meta, paramDef, async (ps, user) => { cw: note.cw, fileIds: note.fileIds, updatedAt: new Date(), + emojis: note.emojis, }); publishing = true; @@ -639,7 +640,7 @@ export default define(meta, paramDef, async (ps, user) => { (async () => { const noteActivity = await renderNote(note, false); - noteActivity.updated = note.updatedAt.toISOString(); + noteActivity.updated = new Date().toISOString(); const updateActivity = renderUpdate(noteActivity, user); updateActivity.to = noteActivity.to; updateActivity.cc = noteActivity.cc; diff --git a/packages/backend/src/server/api/endpoints/notes/history.ts b/packages/backend/src/server/api/endpoints/notes/history.ts new file mode 100644 index 0000000000..b6677743bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/history.ts @@ -0,0 +1,67 @@ +import { NoteEdits } from "@/models/index.js"; +import define from "@/server/api/define.js"; +import { ApiError } from "@/server/api/error.js"; +import { getNote } from "@/server/api/common/getters.js"; +import type { NoteEdit } from "@/models/entities/note-edit.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + description: "Get edit history of a note", + + res: { + type: "array", + optional: false, + nullable: true, + items: { + type: "object", + optional: false, + nullable: false, + ref: "NoteEdit", + }, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "e1035875-9551-45ec-afa8-1ded1fcb53c8", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { + type: "string", + format: "misskey:id", + }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const history: NoteEdit[] = await NoteEdits.find({ + where: { + noteId: note.id, + }, + take: ps.limit, + skip: ps.offset, + order: { + id: "DESC", + }, + }); + + return await NoteEdits.packMany(history, note); +}); diff --git a/packages/client/src/components/MkDateSeparatedList.vue b/packages/client/src/components/MkDateSeparatedList.vue index e40d64dd7d..c6b75a20fc 100644 --- a/packages/client/src/components/MkDateSeparatedList.vue +++ b/packages/client/src/components/MkDateSeparatedList.vue @@ -10,7 +10,7 @@ export default defineComponent({ props: { items: { type: Array as PropType< - { id: string; createdAt: string; _shouldInsertAd_: boolean }[] + { id: string; createdAt: string; _shouldInsertAd_?: boolean }[] >, required: true, }, diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index bcb978b0b7..828fafca05 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -2,7 +2,7 @@
@@ -154,7 +157,12 @@ {{ appearNote.channel.name }}
-