diff options
Diffstat (limited to 'packages/client/src/components/note.vue')
| -rw-r--r-- | packages/client/src/components/note.vue | 1228 |
1 files changed, 1228 insertions, 0 deletions
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue new file mode 100644 index 0000000000..b1ec674b67 --- /dev/null +++ b/packages/client/src/components/note.vue @@ -0,0 +1,1228 @@ +<template> +<div + class="tkcbzcuz" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> + <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + <div class="translation" v-if="translating || translation"> + <MkLoading v-if="translating" mini/> + <div class="translated" v-else> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> + {{ translation.text }} + </div> + </div> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-simple.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + translation: null, + translating: false, + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( + (this.appearNote.text.split('\n').length > 9) || + (this.appearNote.text.length > 500) + ); + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.focus(); + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'fas fa-plus', + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.tkcbzcuz { + position: relative; + transition: box-shadow 0.1s ease; + overflow: clip; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .info { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + position: sticky; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + 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; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .info { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + top: calc(14px + var(--stickyTop, 0px)); + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> |