Merge branch 'feat/note-edit-history' into 'develop'
feat: Add post edit history Co-authored-by: Lhcfl <Lhcfl@outlook.com> See merge request firefish/firefish!10714
This commit is contained in:
commit
82dff9beb1
24 changed files with 409 additions and 21 deletions
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
Breaking changes are indicated by the :warning: icon.
|
Breaking changes are indicated by the :warning: icon.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Added `notes/history` endpoint.
|
||||||
|
|
||||||
## v20240319
|
## v20240319
|
||||||
|
|
||||||
- :warning: `followingCount` and `followersCount` in `users/show` will be `null` (instead of 0) if these values are unavailable.
|
- :warning: `followingCount` and `followersCount` in `users/show` will be `null` (instead of 0) if these values are unavailable.
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
DELETE FROM "migrations" WHERE name IN (
|
DELETE FROM "migrations" WHERE name IN (
|
||||||
|
'ExpandNoteEdit1711936358554',
|
||||||
'markLocalFilesNsfwByDefault1709305200000',
|
'markLocalFilesNsfwByDefault1709305200000',
|
||||||
'FixMutingIndices1710690239308',
|
'FixMutingIndices1710690239308',
|
||||||
'NoteFile1710304584214',
|
'NoteFile1710304584214',
|
||||||
|
@ -19,6 +20,9 @@ DELETE FROM "migrations" WHERE name IN (
|
||||||
'RemoveNativeUtilsMigration1705877093218'
|
'RemoveNativeUtilsMigration1705877093218'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- expand-note-edit
|
||||||
|
ALTER TABLE "note_edit" DROP COLUMN "emojis";
|
||||||
|
|
||||||
-- markLocalFilesNsfwByDefault
|
-- markLocalFilesNsfwByDefault
|
||||||
ALTER TABLE "meta" DROP COLUMN "markLocalFilesNsfwByDefault";
|
ALTER TABLE "meta" DROP COLUMN "markLocalFilesNsfwByDefault";
|
||||||
|
|
||||||
|
|
|
@ -2234,3 +2234,4 @@ autocorrectNoteLanguage: "Show a warning if the post language does not match the
|
||||||
result"
|
result"
|
||||||
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
|
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
|
||||||
{current}.\nWould you like to set the language to {detected} instead?"
|
{current}.\nWould you like to set the language to {detected} instead?"
|
||||||
|
noteEditHistory: "Post edit history"
|
||||||
|
|
|
@ -2060,3 +2060,4 @@ noAltTextWarning: 有些附件没有描述。您是否忘记写描述了?
|
||||||
showNoAltTextWarning: 当您尝试发布没有描述的帖子附件时显示警告
|
showNoAltTextWarning: 当您尝试发布没有描述的帖子附件时显示警告
|
||||||
autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候显示警告
|
autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候显示警告
|
||||||
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
|
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
|
||||||
|
noteEditHistory: "帖子编辑历史"
|
||||||
|
|
|
@ -16,6 +16,7 @@ pub struct Model {
|
||||||
pub file_ids: Vec<String>,
|
pub file_ids: Vec<String>,
|
||||||
#[sea_orm(column_name = "updatedAt")]
|
#[sea_orm(column_name = "updatedAt")]
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
pub emojis: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class ExpandNoteEdit1711936358554 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "note_edit" ADD "emojis" character varying(128) array NOT NULL DEFAULT '{}'::varchar[]
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "note_edit" DROP COLUMN "emojis"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { decodeReaction } from "./reaction-lib.js";
|
||||||
import config from "@/config/index.js";
|
import config from "@/config/index.js";
|
||||||
import { query } from "@/prelude/url.js";
|
import { query } from "@/prelude/url.js";
|
||||||
import { redisClient } from "@/db/redis.js";
|
import { redisClient } from "@/db/redis.js";
|
||||||
|
import type { NoteEdit } from "@/models/entities/note-edit.js";
|
||||||
|
|
||||||
const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
|
const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
|
||||||
|
|
||||||
|
@ -110,6 +111,23 @@ export async function populateEmojis(
|
||||||
return emojis.filter((x): x is PopulatedEmoji => x != null);
|
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[]) {
|
export function aggregateNoteEmojis(notes: Note[]) {
|
||||||
let emojis: { name: string | null; host: string | null }[] = [];
|
let emojis: { name: string | null; host: string | null }[] = [];
|
||||||
for (const note of notes) {
|
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(
|
export async function prefetchEmojis(
|
||||||
emojis: { name: string; host: string | null }[],
|
emojis: { name: string; host: string | null }[],
|
||||||
|
|
|
@ -50,4 +50,11 @@ export class NoteEdit {
|
||||||
comment: "The updated date of the Note.",
|
comment: "The updated date of the Note.",
|
||||||
})
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
@Column("varchar", {
|
||||||
|
length: 128,
|
||||||
|
array: true,
|
||||||
|
default: "{}",
|
||||||
|
})
|
||||||
|
public emojis: string[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,14 +65,14 @@ import { UserPending } from "./entities/user-pending.js";
|
||||||
import { InstanceRepository } from "./repositories/instance.js";
|
import { InstanceRepository } from "./repositories/instance.js";
|
||||||
import { Webhook } from "./entities/webhook.js";
|
import { Webhook } from "./entities/webhook.js";
|
||||||
import { UserIp } from "./entities/user-ip.js";
|
import { UserIp } from "./entities/user-ip.js";
|
||||||
import { NoteEdit } from "./entities/note-edit.js";
|
|
||||||
import { NoteFileRepository } from "./repositories/note-file.js";
|
import { NoteFileRepository } from "./repositories/note-file.js";
|
||||||
|
import { NoteEditRepository } from "./repositories/note-edit.js";
|
||||||
|
|
||||||
export const Announcements = db.getRepository(Announcement);
|
export const Announcements = db.getRepository(Announcement);
|
||||||
export const AnnouncementReads = db.getRepository(AnnouncementRead);
|
export const AnnouncementReads = db.getRepository(AnnouncementRead);
|
||||||
export const Apps = AppRepository;
|
export const Apps = AppRepository;
|
||||||
export const Notes = NoteRepository;
|
export const Notes = NoteRepository;
|
||||||
export const NoteEdits = db.getRepository(NoteEdit);
|
export const NoteEdits = NoteEditRepository;
|
||||||
export const NoteFiles = NoteFileRepository;
|
export const NoteFiles = NoteFileRepository;
|
||||||
export const NoteFavorites = NoteFavoriteRepository;
|
export const NoteFavorites = NoteFavoriteRepository;
|
||||||
export const NoteWatchings = db.getRepository(NoteWatching);
|
export const NoteWatchings = db.getRepository(NoteWatching);
|
||||||
|
|
44
packages/backend/src/models/repositories/note-edit.ts
Normal file
44
packages/backend/src/models/repositories/note-edit.ts
Normal file
|
@ -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] : [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -16,7 +16,7 @@ export const packedNoteEdit = {
|
||||||
},
|
},
|
||||||
note: {
|
note: {
|
||||||
type: "object",
|
type: "object",
|
||||||
optional: false,
|
optional: true,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
ref: "Note",
|
ref: "Note",
|
||||||
},
|
},
|
||||||
|
@ -39,11 +39,27 @@ export const packedNoteEdit = {
|
||||||
fileIds: {
|
fileIds: {
|
||||||
type: "array",
|
type: "array",
|
||||||
optional: true,
|
optional: true,
|
||||||
nullable: true,
|
nullable: false,
|
||||||
items: {
|
items: {
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "id",
|
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;
|
} as const;
|
||||||
|
|
|
@ -773,6 +773,7 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
|
||||||
cw: note.cw,
|
cw: note.cw,
|
||||||
fileIds: note.fileIds,
|
fileIds: note.fileIds,
|
||||||
updatedAt: update.updatedAt,
|
updatedAt: update.updatedAt,
|
||||||
|
emojis: note.emojis,
|
||||||
});
|
});
|
||||||
|
|
||||||
publishing = true;
|
publishing = true;
|
||||||
|
|
|
@ -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_create from "./endpoints/notes/create.js";
|
||||||
import * as ep___notes_delete from "./endpoints/notes/delete.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_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_create from "./endpoints/notes/favorites/create.js";
|
||||||
import * as ep___notes_favorites_delete from "./endpoints/notes/favorites/delete.js";
|
import * as ep___notes_favorites_delete from "./endpoints/notes/favorites/delete.js";
|
||||||
import * as ep___notes_featured from "./endpoints/notes/featured.js";
|
import * as ep___notes_featured from "./endpoints/notes/featured.js";
|
||||||
|
@ -583,6 +584,7 @@ const eps = [
|
||||||
["notes/create", ep___notes_create],
|
["notes/create", ep___notes_create],
|
||||||
["notes/delete", ep___notes_delete],
|
["notes/delete", ep___notes_delete],
|
||||||
["notes/edit", ep___notes_edit],
|
["notes/edit", ep___notes_edit],
|
||||||
|
["notes/history", ep___notes_history],
|
||||||
["notes/favorites/create", ep___notes_favorites_create],
|
["notes/favorites/create", ep___notes_favorites_create],
|
||||||
["notes/favorites/delete", ep___notes_favorites_delete],
|
["notes/favorites/delete", ep___notes_favorites_delete],
|
||||||
["notes/featured", ep___notes_featured],
|
["notes/featured", ep___notes_featured],
|
||||||
|
|
|
@ -621,6 +621,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
cw: note.cw,
|
cw: note.cw,
|
||||||
fileIds: note.fileIds,
|
fileIds: note.fileIds,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
emojis: note.emojis,
|
||||||
});
|
});
|
||||||
|
|
||||||
publishing = true;
|
publishing = true;
|
||||||
|
@ -639,7 +640,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const noteActivity = await renderNote(note, false);
|
const noteActivity = await renderNote(note, false);
|
||||||
noteActivity.updated = note.updatedAt.toISOString();
|
noteActivity.updated = new Date().toISOString();
|
||||||
const updateActivity = renderUpdate(noteActivity, user);
|
const updateActivity = renderUpdate(noteActivity, user);
|
||||||
updateActivity.to = noteActivity.to;
|
updateActivity.to = noteActivity.to;
|
||||||
updateActivity.cc = noteActivity.cc;
|
updateActivity.cc = noteActivity.cc;
|
||||||
|
|
67
packages/backend/src/server/api/endpoints/notes/history.ts
Normal file
67
packages/backend/src/server/api/endpoints/notes/history.ts
Normal file
|
@ -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);
|
||||||
|
});
|
|
@ -10,7 +10,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<
|
type: Array as PropType<
|
||||||
{ id: string; createdAt: string; _shouldInsertAd_: boolean }[]
|
{ id: string; createdAt: string; _shouldInsertAd_?: boolean }[]
|
||||||
>,
|
>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div
|
<div
|
||||||
v-if="!muted.muted"
|
v-if="!muted.muted"
|
||||||
v-show="!isDeleted"
|
v-show="!isDeleted"
|
||||||
:id="appearNote.id"
|
:id="appearNote.historyId || appearNote.id"
|
||||||
ref="el"
|
ref="el"
|
||||||
v-hotkey="keymap"
|
v-hotkey="keymap"
|
||||||
v-size="{ max: [500, 350] }"
|
v-size="{ max: [500, 350] }"
|
||||||
|
@ -91,6 +91,9 @@
|
||||||
:style="{
|
:style="{
|
||||||
cursor: expandOnNoteClick && !detailedView ? 'pointer' : '',
|
cursor: expandOnNoteClick && !detailedView ? 'pointer' : '',
|
||||||
}"
|
}"
|
||||||
|
:class="{
|
||||||
|
history: appearNote.historyId,
|
||||||
|
}"
|
||||||
@contextmenu.stop="onContextmenu"
|
@contextmenu.stop="onContextmenu"
|
||||||
@click="noteClick"
|
@click="noteClick"
|
||||||
>
|
>
|
||||||
|
@ -154,7 +157,12 @@
|
||||||
{{ appearNote.channel.name }}</MkA
|
{{ appearNote.channel.name }}</MkA
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<footer ref="footerEl" class="footer" tabindex="-1">
|
<footer
|
||||||
|
v-show="!hideFooter"
|
||||||
|
ref="footerEl"
|
||||||
|
class="footer"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
<XReactionsViewer
|
<XReactionsViewer
|
||||||
v-if="enableEmojiReactions"
|
v-if="enableEmojiReactions"
|
||||||
ref="reactionsViewer"
|
ref="reactionsViewer"
|
||||||
|
@ -312,6 +320,7 @@ const props = defineProps<{
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
detailedView?: boolean;
|
detailedView?: boolean;
|
||||||
collapsedReply?: boolean;
|
collapsedReply?: boolean;
|
||||||
|
hideFooter?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const inChannel = inject("inChannel", null);
|
const inChannel = inject("inChannel", null);
|
||||||
|
@ -420,11 +429,13 @@ const keymap = {
|
||||||
s: () => showContent.value !== showContent.value,
|
s: () => showContent.value !== showContent.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
useNoteCapture({
|
if (appearNote.value.historyId == null) {
|
||||||
rootEl: el,
|
useNoteCapture({
|
||||||
note: appearNote,
|
rootEl: el,
|
||||||
isDeletedRef: isDeleted,
|
note: appearNote,
|
||||||
});
|
isDeletedRef: isDeleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function reply(viaKeyboard = false): void {
|
function reply(viaKeyboard = false): void {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
|
@ -851,6 +862,9 @@ defineExpose({
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
padding: 20px 32px 10px;
|
padding: 20px 32px 10px;
|
||||||
margin-top: -16px;
|
margin-top: -16px;
|
||||||
|
&.history {
|
||||||
|
margin-top: -90px !important;
|
||||||
|
}
|
||||||
|
|
||||||
&:first-child,
|
&:first-child,
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
|
|
|
@ -118,10 +118,9 @@ const emit = defineEmits<{
|
||||||
(ev: "status", error: boolean): void;
|
(ev: "status", error: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface Item {
|
type Item = Endpoints[typeof props.pagination.endpoint]["res"] & {
|
||||||
id: string;
|
id: string;
|
||||||
[another: string]: unknown;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const rootEl = ref<HTMLElement>();
|
const rootEl = ref<HTMLElement>();
|
||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
|
|
|
@ -1,5 +1,16 @@
|
||||||
import type { entities } from "firefish-js";
|
import type { entities } from "firefish-js";
|
||||||
|
|
||||||
export const notePage = (note: entities.Note) => {
|
export function notePage(
|
||||||
|
note: entities.Note,
|
||||||
|
options?: {
|
||||||
|
historyPage?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (options?.historyPage) {
|
||||||
|
return `/notes/${note.id}/history`;
|
||||||
|
}
|
||||||
|
if (note.historyId) {
|
||||||
|
return `/notes/${note.id}/history#${note.historyId}`;
|
||||||
|
}
|
||||||
return `/notes/${note.id}`;
|
return `/notes/${note.id}`;
|
||||||
};
|
}
|
||||||
|
|
135
packages/client/src/pages/note-history.vue
Normal file
135
packages/client/src/pages/note-history.vue
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header
|
||||||
|
><MkPageHeader :display-back-button="true"
|
||||||
|
/></template>
|
||||||
|
<MkSpacer :content-max="800">
|
||||||
|
<MkLoading v-if="!loaded" />
|
||||||
|
<MkPagination
|
||||||
|
v-else
|
||||||
|
ref="pagingComponent"
|
||||||
|
v-slot="{ items }: { items: entities.NoteEdit[] }"
|
||||||
|
:pagination="pagination"
|
||||||
|
>
|
||||||
|
<div ref="tlEl" class="giivymft noGap">
|
||||||
|
<XList
|
||||||
|
v-slot="{ item }: { item: entities.Note }"
|
||||||
|
:items="convertNoteEditsToNotes(items)"
|
||||||
|
class="notes"
|
||||||
|
:no-gap="true"
|
||||||
|
>
|
||||||
|
<XNote
|
||||||
|
:key="item.id"
|
||||||
|
class="qtqtichx"
|
||||||
|
:note="item"
|
||||||
|
:hide-footer="true"
|
||||||
|
:detailed-view="true"
|
||||||
|
/>
|
||||||
|
</XList>
|
||||||
|
</div>
|
||||||
|
</MkPagination>
|
||||||
|
</MkSpacer>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import MkPagination from "@/components/MkPagination.vue";
|
||||||
|
import type { Paging } from "@/components/MkPagination.vue";
|
||||||
|
import { api } from "@/os";
|
||||||
|
import XList from "@/components/MkDateSeparatedList.vue";
|
||||||
|
import XNote from "@/components/MkNote.vue";
|
||||||
|
import { i18n } from "@/i18n";
|
||||||
|
import { definePageMetadata } from "@/scripts/page-metadata";
|
||||||
|
import icon from "@/scripts/icon";
|
||||||
|
import type { entities } from "firefish-js";
|
||||||
|
|
||||||
|
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
noteId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pagination: Paging = {
|
||||||
|
endpoint: "notes/history" as const,
|
||||||
|
limit: 10,
|
||||||
|
offsetMode: true,
|
||||||
|
params: computed(() => ({
|
||||||
|
noteId: props.noteId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
definePageMetadata(
|
||||||
|
computed(() => ({
|
||||||
|
title: i18n.ts.noteEditHistory,
|
||||||
|
icon: `${icon("ph-clock-countdown")}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const note = ref<entities.Note>({} as entities.Note);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
api("notes/show", {
|
||||||
|
noteId: props.noteId,
|
||||||
|
}).then((res) => {
|
||||||
|
// Remove unnecessary parts
|
||||||
|
res.renote = undefined;
|
||||||
|
res.renoteId = null;
|
||||||
|
res.reply = undefined;
|
||||||
|
res.replyId = null;
|
||||||
|
|
||||||
|
note.value = res;
|
||||||
|
loaded.value = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function convertNoteEditsToNotes(noteEdits: entities.NoteEdit[]) {
|
||||||
|
const now: entities.NoteEdit = {
|
||||||
|
id: "EditionNow",
|
||||||
|
noteId: note.value.id,
|
||||||
|
updatedAt: note.value.createdAt,
|
||||||
|
text: note.value.text,
|
||||||
|
cw: note.value.cw,
|
||||||
|
files: note.value.files,
|
||||||
|
fileIds: note.value.fileIds,
|
||||||
|
emojis: note.value.emojis,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [now]
|
||||||
|
.concat(noteEdits)
|
||||||
|
.map((noteEdit: entities.NoteEdit, index, arr): entities.Note => {
|
||||||
|
return Object.assign({}, note.value, {
|
||||||
|
historyId: noteEdit.id,
|
||||||
|
// Conversion from updatedAt to createdAt
|
||||||
|
// The createdAt of a edition's content is actually the updatedAt of the previous one.
|
||||||
|
createdAt: arr[(index + 1) % arr.length].updatedAt,
|
||||||
|
text: noteEdit.text,
|
||||||
|
cw: noteEdit.cw,
|
||||||
|
_shouldInsertAd_: false,
|
||||||
|
files: noteEdit.files,
|
||||||
|
fileIds: noteEdit.fileIds,
|
||||||
|
emojis: note.value.emojis.concat(noteEdit.emojis),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.giivymft {
|
||||||
|
&.noGap {
|
||||||
|
> .notes {
|
||||||
|
background: var(--panel) !important;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:not(.noGap) {
|
||||||
|
> .notes {
|
||||||
|
.qtqtichx {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -39,6 +39,11 @@ export const routes = [
|
||||||
path: "/notes/:noteId",
|
path: "/notes/:noteId",
|
||||||
component: page(() => import("./pages/note.vue")),
|
component: page(() => import("./pages/note.vue")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "note-history",
|
||||||
|
path: "/notes/:noteId/history",
|
||||||
|
component: page(() => import("./pages/note-history.vue")),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/clips/:clipId",
|
path: "/clips/:clipId",
|
||||||
component: page(() => import("./pages/clip.vue")),
|
component: page(() => import("./pages/clip.vue")),
|
||||||
|
|
|
@ -11,6 +11,10 @@ import { noteActions } from "@/store";
|
||||||
import { shareAvailable } from "@/scripts/share-available";
|
import { shareAvailable } from "@/scripts/share-available";
|
||||||
import { getUserMenu } from "@/scripts/get-user-menu";
|
import { getUserMenu } from "@/scripts/get-user-menu";
|
||||||
import icon from "@/scripts/icon";
|
import icon from "@/scripts/icon";
|
||||||
|
import { useRouter } from "@/router";
|
||||||
|
import { notePage } from "@/filters/note";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
export function getNoteMenu(props: {
|
export function getNoteMenu(props: {
|
||||||
note: entities.Note;
|
note: entities.Note;
|
||||||
|
@ -73,6 +77,10 @@ export function getNoteMenu(props: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showEditHistory(): void {
|
||||||
|
router.push(notePage(appearNote, { historyPage: true }));
|
||||||
|
}
|
||||||
|
|
||||||
function makePrivate(): void {
|
function makePrivate(): void {
|
||||||
os.confirm({
|
os.confirm({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
|
@ -288,6 +296,8 @@ export function getNoteMenu(props: {
|
||||||
noteId: appearNote.id,
|
noteId: appearNote.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isEdited = !!appearNote.updatedAt;
|
||||||
|
|
||||||
const isAppearAuthor = appearNote.userId === me.id;
|
const isAppearAuthor = appearNote.userId === me.id;
|
||||||
|
|
||||||
menu = [
|
menu = [
|
||||||
|
@ -361,6 +371,13 @@ export function getNoteMenu(props: {
|
||||||
action: () => togglePin(true),
|
action: () => togglePin(true),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
isEdited
|
||||||
|
? {
|
||||||
|
icon: `${icon("ph-clock-countdown")}`,
|
||||||
|
text: i18n.ts.noteEditHistory,
|
||||||
|
action: () => showEditHistory(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
instance.translatorAvailable
|
instance.translatorAvailable
|
||||||
? {
|
? {
|
||||||
icon: `${icon("ph-translate")}`,
|
icon: `${icon("ph-translate")}`,
|
||||||
|
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
MeDetailed,
|
MeDetailed,
|
||||||
MessagingMessage,
|
MessagingMessage,
|
||||||
Note,
|
Note,
|
||||||
|
NoteEdit,
|
||||||
NoteFavorite,
|
NoteFavorite,
|
||||||
NoteReaction,
|
NoteReaction,
|
||||||
Notification,
|
Notification,
|
||||||
|
@ -657,6 +658,14 @@ export type Endpoints = {
|
||||||
};
|
};
|
||||||
res: Note[];
|
res: Note[];
|
||||||
};
|
};
|
||||||
|
"notes/history": {
|
||||||
|
req: {
|
||||||
|
noteId: Note["id"];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
res: NoteEdit[];
|
||||||
|
};
|
||||||
"notes/recommended-timeline": {
|
"notes/recommended-timeline": {
|
||||||
req: {
|
req: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
|
@ -143,9 +143,9 @@ export type Note = {
|
||||||
user: User;
|
user: User;
|
||||||
userId: User["id"];
|
userId: User["id"];
|
||||||
reply?: Note;
|
reply?: Note;
|
||||||
replyId: Note["id"];
|
replyId: Note["id"] | null;
|
||||||
renote?: Note;
|
renote?: Note;
|
||||||
renoteId: Note["id"];
|
renoteId: Note["id"] | null;
|
||||||
files: DriveFile[];
|
files: DriveFile[];
|
||||||
fileIds: DriveFile["id"][];
|
fileIds: DriveFile["id"][];
|
||||||
visibility: "public" | "home" | "followers" | "specified";
|
visibility: "public" | "home" | "followers" | "specified";
|
||||||
|
@ -174,6 +174,22 @@ export type Note = {
|
||||||
url?: string;
|
url?: string;
|
||||||
updatedAt?: DateString;
|
updatedAt?: DateString;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
|
/** if the note is a history */
|
||||||
|
historyId?: ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteEdit = {
|
||||||
|
id: string;
|
||||||
|
noteId: string;
|
||||||
|
text: string | null;
|
||||||
|
cw: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
fileIds: DriveFile["id"][];
|
||||||
|
files: DriveFile[];
|
||||||
|
emojis: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NoteReaction = {
|
export type NoteReaction = {
|
||||||
|
|
Loading…
Reference in a new issue