diff options
| author | Mar0xy <marie@kaifa.ch> | 2023-10-22 13:16:30 +0200 |
|---|---|---|
| committer | Mar0xy <marie@kaifa.ch> | 2023-10-22 13:16:30 +0200 |
| commit | d50e81e475b96343db230db97bbc80571cfd7690 (patch) | |
| tree | c2ae18b6d883ef2b218b846d8ebc674528e22499 | |
| parent | chore: remove debug from versions menu (diff) | |
| download | sharkey-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.js | 13 | ||||
| -rw-r--r-- | packages/backend/src/core/NoteEditService.ts | 177 | ||||
| -rw-r--r-- | packages/backend/src/models/NoteEdit.ts | 7 | ||||
| -rw-r--r-- | packages/backend/src/models/json-schema/note-edit.ts | 7 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/notes/versions.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkOldNoteWindow.vue | 328 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/get-note-versions-menu.ts | 9 | ||||
| -rw-r--r-- | packages/misskey-js/src/entities.ts | 3 |
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; |