summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMar0xy <marie@kaifa.ch>2023-10-22 13:16:30 +0200
committerMar0xy <marie@kaifa.ch>2023-10-22 13:16:30 +0200
commitd50e81e475b96343db230db97bbc80571cfd7690 (patch)
treec2ae18b6d883ef2b218b846d8ebc674528e22499
parentchore: remove debug from versions menu (diff)
downloadsharkey-d50e81e475b96343db230db97bbc80571cfd7690.tar.gz
sharkey-d50e81e475b96343db230db97bbc80571cfd7690.tar.bz2
sharkey-d50e81e475b96343db230db97bbc80571cfd7690.zip
upd: improve note edit table & improve previous version view
Closes transfem-org/Sharkey#105
-rw-r--r--packages/backend/migration/1697970083000-alterNoteEdit.js13
-rw-r--r--packages/backend/src/core/NoteEditService.ts177
-rw-r--r--packages/backend/src/models/NoteEdit.ts7
-rw-r--r--packages/backend/src/models/json-schema/note-edit.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/notes/versions.ts2
-rw-r--r--packages/frontend/src/components/SkOldNoteWindow.vue328
-rw-r--r--packages/frontend/src/scripts/get-note-versions-menu.ts9
-rw-r--r--packages/misskey-js/src/entities.ts3
8 files changed, 454 insertions, 92 deletions
diff --git a/packages/backend/migration/1697970083000-alterNoteEdit.js b/packages/backend/migration/1697970083000-alterNoteEdit.js
new file mode 100644
index 0000000000..11accb3c54
--- /dev/null
+++ b/packages/backend/migration/1697970083000-alterNoteEdit.js
@@ -0,0 +1,13 @@
+export class AlterNoteEdit1697970083000 {
+ name = "AlterNoteEdit1697970083000";
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "text" TO "newText"`);
+ await queryRunner.query(`ALTER TABLE "note_edit" ADD COLUMN "oldText" text`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "newText" TO "text"`);
+ await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "oldText"`);
+ }
+}
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 1cbf0ee7f0..7b8aab7f3e 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -387,104 +387,109 @@ export class NoteEditService implements OnApplicationShutdown {
update.hasPoll = !!data.poll;
}
- await this.noteEditRepository.insert({
- id: this.idService.gen(),
- noteId: oldnote.id,
- text: data.text || undefined,
- cw: data.cw,
- fileIds: undefined,
- updatedAt: new Date(),
- });
+ if (Object.keys(update).length > 0) {
+ await this.noteEditRepository.insert({
+ id: this.idService.gen(),
+ noteId: oldnote.id,
+ oldText: update.text ? oldnote.text : undefined,
+ newText: update.text || undefined,
+ cw: update.cw || undefined,
+ fileIds: undefined,
+ updatedAt: new Date(),
+ });
- const note = new MiNote({
- id: oldnote.id,
- updatedAt: data.updatedAt ? data.updatedAt : new Date(),
- fileIds: data.files ? data.files.map(file => file.id) : [],
- replyId: data.reply ? data.reply.id : null,
- renoteId: data.renote ? data.renote.id : null,
- channelId: data.channel ? data.channel.id : null,
- threadId: data.reply
- ? data.reply.threadId
+ const note = new MiNote({
+ id: oldnote.id,
+ updatedAt: data.updatedAt ? data.updatedAt : new Date(),
+ fileIds: data.files ? data.files.map(file => file.id) : [],
+ replyId: data.reply ? data.reply.id : null,
+ renoteId: data.renote ? data.renote.id : null,
+ channelId: data.channel ? data.channel.id : null,
+ threadId: data.reply
? data.reply.threadId
- : data.reply.id
- : null,
- name: data.name,
- text: data.text,
- hasPoll: data.poll != null,
- cw: data.cw ?? null,
- tags: tags.map(tag => normalizeForSearch(tag)),
- emojis,
- reactions: oldnote.reactions,
- userId: user.id,
- localOnly: data.localOnly!,
- reactionAcceptance: data.reactionAcceptance,
- visibility: data.visibility as any,
- visibleUserIds: data.visibility === 'specified'
- ? data.visibleUsers
- ? data.visibleUsers.map(u => u.id)
- : []
- : [],
+ ? data.reply.threadId
+ : data.reply.id
+ : null,
+ name: data.name,
+ text: data.text,
+ hasPoll: data.poll != null,
+ cw: data.cw ?? null,
+ tags: tags.map(tag => normalizeForSearch(tag)),
+ emojis,
+ reactions: oldnote.reactions,
+ userId: user.id,
+ localOnly: data.localOnly!,
+ reactionAcceptance: data.reactionAcceptance,
+ visibility: data.visibility as any,
+ visibleUserIds: data.visibility === 'specified'
+ ? data.visibleUsers
+ ? data.visibleUsers.map(u => u.id)
+ : []
+ : [],
- attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
+ attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
- // 以下非正規化データ
- replyUserId: data.reply ? data.reply.userId : null,
- replyUserHost: data.reply ? data.reply.userHost : null,
- renoteUserId: data.renote ? data.renote.userId : null,
- renoteUserHost: data.renote ? data.renote.userHost : null,
- userHost: user.host,
- });
+ // 以下非正規化データ
+ replyUserId: data.reply ? data.reply.userId : null,
+ replyUserHost: data.reply ? data.reply.userHost : null,
+ renoteUserId: data.renote ? data.renote.userId : null,
+ renoteUserHost: data.renote ? data.renote.userHost : null,
+ userHost: user.host,
+ });
- if (data.uri != null) note.uri = data.uri;
- if (data.url != null) note.url = data.url;
+ if (data.uri != null) note.uri = data.uri;
+ if (data.url != null) note.url = data.url;
- if (mentionedUsers.length > 0) {
- note.mentions = mentionedUsers.map(u => u.id);
- const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) });
- note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
- const profile = profiles.find(p => p.userId === u.id);
- const url = profile != null ? profile.url : null;
- return {
- uri: u.uri,
- url: url ?? undefined,
- username: u.username,
- host: u.host,
- } as IMentionedRemoteUsers[0];
- }));
- }
+ if (mentionedUsers.length > 0) {
+ note.mentions = mentionedUsers.map(u => u.id);
+ const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) });
+ note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
+ const profile = profiles.find(p => p.userId === u.id);
+ const url = profile != null ? profile.url : null;
+ return {
+ uri: u.uri,
+ url: url ?? undefined,
+ username: u.username,
+ host: u.host,
+ } as IMentionedRemoteUsers[0];
+ }));
+ }
- if (data.poll != null) {
- // Start transaction
- await this.db.transaction(async transactionalEntityManager => {
- await transactionalEntityManager.update(MiNote, oldnote.id, note);
+ if (data.poll != null) {
+ // Start transaction
+ await this.db.transaction(async transactionalEntityManager => {
+ await transactionalEntityManager.update(MiNote, oldnote.id, note);
+
+ const poll = new MiPoll({
+ noteId: note.id,
+ choices: data.poll!.choices,
+ expiresAt: data.poll!.expiresAt,
+ multiple: data.poll!.multiple,
+ votes: new Array(data.poll!.choices.length).fill(0),
+ noteVisibility: note.visibility,
+ userId: user.id,
+ userHost: user.host,
+ });
- const poll = new MiPoll({
- noteId: note.id,
- choices: data.poll!.choices,
- expiresAt: data.poll!.expiresAt,
- multiple: data.poll!.multiple,
- votes: new Array(data.poll!.choices.length).fill(0),
- noteVisibility: note.visibility,
- userId: user.id,
- userHost: user.host,
+ if (!oldnote.hasPoll) {
+ await transactionalEntityManager.insert(MiPoll, poll);
+ } else {
+ await transactionalEntityManager.update(MiPoll, oldnote.id, poll);
+ }
});
+ } else {
+ await this.notesRepository.update(oldnote.id, note);
+ }
- if (!oldnote.hasPoll) {
- await transactionalEntityManager.insert(MiPoll, poll);
- } else {
- await transactionalEntityManager.update(MiPoll, oldnote.id, poll);
- }
- });
+ setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
+ () => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
+ () => { /* aborted, ignore this */ },
+ );
+
+ return note;
} else {
- await this.notesRepository.update(oldnote.id, note);
+ return oldnote;
}
-
- setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
- () => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
- () => { /* aborted, ignore this */ },
- );
-
- return note;
}
@bindThis
diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts
index 547b135e56..440f9b8208 100644
--- a/packages/backend/src/models/NoteEdit.ts
+++ b/packages/backend/src/models/NoteEdit.ts
@@ -24,7 +24,12 @@ export class NoteEdit {
@Column("text", {
nullable: true,
})
- public text: string | null;
+ public oldText: string | null;
+
+ @Column("text", {
+ nullable: true,
+ })
+ public newText: string | null;
@Column("varchar", {
length: 512,
diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts
index e877f3f946..a58e2aa1de 100644
--- a/packages/backend/src/models/json-schema/note-edit.ts
+++ b/packages/backend/src/models/json-schema/note-edit.ts
@@ -26,7 +26,12 @@ export const packedNoteEdit = {
nullable: false,
format: "id",
},
- text: {
+ oldText: {
+ type: "string",
+ optional: true,
+ nullable: true,
+ },
+ newText: {
type: "string",
optional: true,
nullable: true,
diff --git a/packages/backend/src/server/api/endpoints/notes/versions.ts b/packages/backend/src/server/api/endpoints/notes/versions.ts
index 9733d781a4..e6831f3208 100644
--- a/packages/backend/src/server/api/endpoints/notes/versions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/versions.ts
@@ -51,7 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
for (const edit of edits) {
editArray.push({
updatedAt: new Date(edit.updatedAt).toLocaleString('UTC', { hour: 'numeric', minute: 'numeric', second: 'numeric', year: 'numeric', month: 'short', day: 'numeric' }),
- text: edit.text,
+ text: edit.oldText,
});
}
diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue
new file mode 100644
index 0000000000..bd0b87bf6c
--- /dev/null
+++ b/packages/frontend/src/components/SkOldNoteWindow.vue
@@ -0,0 +1,328 @@
+<template>
+<MkWindow ref="window" :initialWidth="500" :initialHeight="300" :canResize="true" @closed="emit('closed')">
+ <template #header>
+ <i class="ph-warning-circle ph-bold ph-lg" style="margin-right: 0.5em;"></i>
+ <b>Previous Version from {{ appearNote.createdAt }}</b>
+ </template>
+ <div ref="el" :class="$style.root">
+ <article :class="$style.note">
+ <header :class="$style.noteHeader">
+ <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
+ <div :class="$style.noteHeaderBody">
+ <div>
+ <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
+ <MkUserName :nowrap="false" :user="appearNote.user"/>
+ </MkA>
+ <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
+ <div :class="$style.noteHeaderInfo">
+ <span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
+ <i v-if="appearNote.visibility === 'home'" class="ph-house ph-bold ph-lg"></i>
+ <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i>
+ <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i>
+ </span>
+ <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold pg-lg"></i></span>
+ </div>
+ </div>
+ <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
+ <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+ </div>
+ </header>
+ <div :class="$style.noteContent">
+ <p v-if="appearNote.cw != null" :class="$style.cw">
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
+ <MkCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div v-show="appearNote.cw == null || showContent">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+ <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold pg-lg"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
+ <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
+ <div v-if="translating || translation" :class="$style.translation">
+ <MkLoading v-if="translating" mini/>
+ <div v-else>
+ <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
+ <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
+ </div>
+ </div>
+ <div v-if="appearNote.files.length > 0">
+ <MkMediaList :mediaList="appearNote.files"/>
+ </div>
+ <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/>
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <footer :class="$style.footer">
+ <div :class="$style.noteFooterInfo">
+ <MkTime :time="appearNote.createdAt" mode="detail"/>
+ </div>
+ <button class="_button" :class="$style.noteFooterButton">
+ <i class="ph-arrow-u-up-left ph-bold pg-lg"></i>
+ </button>
+ <button class="_button" :class="$style.noteFooterButton">
+ <i class="ph-rocket-launch ph-bold ph-lg"></i>
+ </button>
+ <button class="_button" :class="$style.noteFooterButton">
+ <i class="ph-quotes ph-bold ph-lg"></i>
+ </button>
+ <button class="_button" :class="$style.noteFooterButton">
+ <i class="ph-heart ph-bold ph-lg"></i>
+ </button>
+ </footer>
+ </article>
+ </div>
+</MkWindow>
+</template>
+
+<script lang="ts" setup>
+import { inject, onMounted, ref, shallowRef } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import MkNoteSimple from '@/components/MkNoteSimple.vue';
+import MkMediaList from '@/components/MkMediaList.vue';
+import MkCwButton from '@/components/MkCwButton.vue';
+import MkWindow from '@/components/MkWindow.vue';
+import MkPoll from '@/components/MkPoll.vue';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
+import { userPage } from '@/filters/user.js';
+import { defaultStore, noteViewInterruptors } from '@/store.js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { deepClone } from '@/scripts/clone.js';
+
+const props = defineProps<{
+ note: Misskey.entities.Note;
+ oldText: string;
+ updatedAt: string;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+let note = $ref(deepClone(props.note));
+
+// plugin
+if (noteViewInterruptors.length > 0) {
+ onMounted(async () => {
+ let result = deepClone(note);
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(result);
+ }
+ note = result;
+ });
+}
+
+const replaceContent = () => {
+ note.text = props.oldText;
+ note.createdAt = props.updatedAt;
+};
+replaceContent();
+
+const isRenote = (
+ note.renote != null &&
+ note.text == null &&
+ note.fileIds.length === 0 &&
+ note.poll == null
+);
+
+const el = shallowRef<HTMLElement>();
+let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
+const renoteUrl = appearNote.renote ? appearNote.renote.url : null;
+const renoteUri = appearNote.renote ? appearNote.renote.uri : null;
+
+const showContent = ref(false);
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: clip;
+ contain: content;
+}
+
+.footer {
+ position: relative;
+ z-index: 1;
+ margin-top: 0.4em;
+ width: max-content;
+ min-width: max-content;
+}
+
+.note {
+ padding: 32px;
+ font-size: 1.2em;
+}
+
+.noteHeader {
+ display: flex;
+ position: relative;
+ margin-bottom: 16px;
+ align-items: center;
+}
+
+.noteHeaderAvatar {
+ display: block;
+ flex-shrink: 0;
+ width: 58px;
+ height: 58px;
+}
+
+.noteHeaderBody {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 16px;
+ font-size: 0.95em;
+}
+
+.noteHeaderName {
+ font-weight: bold;
+ line-height: 1.3;
+}
+
+.isBot {
+ display: inline-block;
+ margin: 0 0.5em;
+ padding: 4px 6px;
+ font-size: 80%;
+ line-height: 1;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+}
+
+.noteHeaderInfo {
+ float: right;
+}
+
+.noteFooterInfo {
+ margin: 16px 0;
+ opacity: 0.7;
+ font-size: 0.9em;
+}
+
+.noteHeaderUsername {
+ margin-bottom: 2px;
+ line-height: 1.3;
+ word-wrap: anywhere;
+}
+
+.noteContent {
+ container-type: inline-size;
+ overflow-wrap: break-word;
+}
+
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
+
+.noteReplyTarget {
+ color: var(--accent);
+ margin-right: 0.5em;
+}
+
+.rn {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+}
+
+.translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+}
+
+.poll {
+ font-size: 80%;
+}
+
+.quote {
+ padding: 8px 0;
+}
+
+.quoteNote {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 5px;
+ overflow: clip;
+}
+
+.channel {
+ opacity: 0.7;
+ font-size: 80%;
+}
+
+.noteFooterButton {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 1.5em;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+}
+
+@container (max-width: 350px) {
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 0.1em;
+ }
+ }
+}
+
+@container (max-width: 500px) {
+ .root {
+ font-size: 0.9em;
+ }
+}
+
+@container (max-width: 450px) {
+ .note {
+ padding: 16px;
+ }
+
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
+ }
+}
+
+@container (max-width: 300px) {
+ .root {
+ font-size: 0.825em;
+ }
+
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 0.1em;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/scripts/get-note-versions-menu.ts b/packages/frontend/src/scripts/get-note-versions-menu.ts
index 12b81c750d..4191920638 100644
--- a/packages/frontend/src/scripts/get-note-versions-menu.ts
+++ b/packages/frontend/src/scripts/get-note-versions-menu.ts
@@ -1,4 +1,4 @@
-import { Ref } from 'vue';
+import { Ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
@@ -20,7 +20,12 @@ export async function getNoteVersionsMenu(props: {
const cleanups = [] as (() => void)[];
function openVersion(info): void {
- os.alert({ type: 'info', title: `Edits from ${info.updatedAt}`, text: info.text });
+ os.popup(defineAsyncComponent(() => import('@/components/SkOldNoteWindow.vue')), {
+ note: appearNote,
+ oldText: info.text,
+ updatedAt: info.updatedAt,
+ }, {
+ }, 'closed');
}
const menu: MenuItem[] = [];
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index baf59e7283..8d954886d9 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -230,7 +230,8 @@ export type NoteReaction = {
export type NoteEdit = {
noteId: Note['id'];
note: Note;
- text: string;
+ newText: string;
+ oldText: string;
cw: string;
fileIds: DriveFile['id'][];
updatedAt?: DateString;