<!-- SPDX-FileCopyrightText: syuilo and other misskey contributors SPDX-License-Identifier: AGPL-3.0-only --> <template> <div v-if="!muted" v-show="!isDeleted" ref="el" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" > <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText"> <template #user> <MkA v-user-preview="note.userId" :class="$style.renoteUserName" :to="userPage(note.user)"> <MkUserName :user="note.user"/> </MkA> </template> </I18n> <div :class="$style.renoteInfo"> <button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()"> <i class="ti ti-dots" :class="$style.renoteMenu"></i> <MkTime :time="note.createdAt"/> </button> <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> <i v-if="note.visibility === 'home'" class="ti ti-home"></i> <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> </span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> </div> </div> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> <MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/> <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/> </div> <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> <div :class="$style.main"> <MkNoteHeader :note="appearNote" :mini="true"/> <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> <MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/> </p> <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <Mfm v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" /> <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" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> </div> <div v-if="appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> </button> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> <MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction"> <template #more> <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> </template> </MkReactionsViewer> <footer :class="$style.footer"> <button :class="$style.footerButton" class="_button" @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> </button> <button v-if="canRenote" ref="renoteButton" :class="$style.footerButton" class="_button" @mousedown="renote()" > <i class="ti ti-repeat"></i> <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p> </button> <button v-else :class="$style.footerButton" class="_button" disabled> <i class="ti ti-ban"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ti ti-plus"></i> </button> <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ti ti-paperclip"></i> </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> <i class="ti ti-dots"></i> </button> </footer> </div> </article> </div> <div v-else :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> </MkA> </template> </I18n> </div> </template> <script lang="ts" setup> import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; pinned?: boolean; mock?: boolean; }>(), { mock: false, }); provide('mock', props.mock); const emit = defineEmits<{ (ev: 'reaction', emoji: string): void; (ev: 'removeReaction', emoji: string): void; }>(); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); let note = $ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result); if (result === null) { isDeleted.value = true; return; } } catch (err) { console.error(err); } } note = result; }); } const isRenote = ( note.renote != null && note.text == null && note.fileIds.length === 0 && note.poll == null ); const el = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null); const isLong = shouldCollapsed(appearNote, urls ?? []); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const translation = ref<any>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), 'q': () => renoteButton.value.renote(true), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, 'm|o': () => menu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { os.api('notes/reactions/create', { noteId: appearNote.id, reaction: reaction, }); }); if (props.mock) { watch(() => props.note, (to) => { note = deepClone(to); }, { deep: true }); } else { useNoteCapture({ rootEl: el, note: $$(appearNote), pureNote: $$(note), isDeletedRef: isDeleted, }); } if (!props.mock) { useTooltip(renoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { noteId: appearNote.id, limit: 11, }); const users = renotes.map(x => x.user); if (users.length < 1) return; os.popup(MkUsersTooltip, { showing, users, count: appearNote.renoteCount, targetElement: renoteButton.value, }, {}, 'closed'); }); } function renote(viaKeyboard = false) { pleaseLogin(); showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); os.popupMenu(menu, renoteButton.value, { viaKeyboard, }); } function reply(viaKeyboard = false): void { pleaseLogin(); if (props.mock) { return; } os.post({ reply: appearNote, channel: appearNote.channel, animation: !viaKeyboard, }, () => { focus(); }); } function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { if (props.mock) { return; } os.api('notes/reactions/create', { noteId: appearNote.id, reaction: '❤️', }); const el = reactButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } } else { blur(); reactionPicker.show(reactButton.value, reaction => { if (props.mock) { emit('reaction', reaction); return; } os.api('notes/reactions/create', { noteId: appearNote.id, reaction: reaction, }); if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { focus(); }); } } function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; if (props.mock) { emit('removeReaction', oldReaction); return; } os.api('notes/reactions/delete', { noteId: note.id, }); } function onContextmenu(ev: MouseEvent): void { if (props.mock) { return; } const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } }; if (isLink(ev.target)) return; if (window.getSelection().toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function menu(viaKeyboard = false): void { if (props.mock) { return; } const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function clip() { if (props.mock) { return; } os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { if (props.mock) { return; } function getUnrenote(): MenuItem { return { text: i18n.ts.unrenote, icon: 'ti ti-trash', danger: true, action: () => { os.api('notes/delete', { noteId: note.id, }); isDeleted.value = true; }, }; } if (isMyRenote) { pleaseLogin(); os.popupMenu([ getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), null, getUnrenote(), ], renoteTime.value, { viaKeyboard: viaKeyboard, }); } else { os.popupMenu([ getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), null, getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); } } function focus() { el.value.focus(); } function blur() { el.value.blur(); } function focusBefore() { focusPrev(el.value); } function focusAfter() { focusNext(el.value); } function readPromo() { os.api('promo/read', { noteId: appearNote.id, }); isDeleted.value = true; } function emitUpdReaction(emoji: string, delta: number) { if (delta < 0) { emit('removeReaction', emoji); } else if (delta > 0) { emit('reaction', emoji); } } </script> <style lang="scss" module> .root { position: relative; transition: box-shadow 0.1s ease; font-size: 1.05em; 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; } } .footer { position: relative; z-index: 1; } &:hover > .article > .main > .footer > .footerButton { opacity: 1; } &.showActionsOnlyHover { .footer { visibility: hidden; position: absolute; top: 12px; right: 12px; padding: 0 4px; margin-bottom: 0 !important; background: var(--popup); border-radius: 8px; box-shadow: 0px 4px 32px var(--shadow); } .footerButton { font-size: 90%; &:not(:last-child) { margin-right: 0; } } } &.showActionsOnlyHover:hover { .footer { visibility: visible; } } } .tip { display: flex; align-items: center; padding: 16px 32px 8px 32px; line-height: 24px; font-size: 90%; white-space: pre; color: #d28a3f; } .tip + .article { padding-top: 8px; } .replyTo { opacity: 0.7; padding-bottom: 0; } .renote { position: relative; display: flex; align-items: center; padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; color: var(--renote); & + .article { padding-top: 8px; } > .colorBar { height: calc(100% - 6px); } } .renoteAvatar { flex-shrink: 0; display: inline-block; width: 28px; height: 28px; margin: 0 8px 0 0; } .renoteText { overflow: hidden; flex-shrink: 1; text-overflow: ellipsis; white-space: nowrap; } .renoteUserName { font-weight: bold; } .renoteInfo { margin-left: auto; font-size: 0.9em; } .renoteTime { flex-shrink: 0; color: inherit; } .renoteMenu { margin-right: 4px; } .collapsedRenoteTarget { display: flex; align-items: center; line-height: 28px; white-space: pre; padding: 0 32px 18px; } .collapsedRenoteTargetAvatar { flex-shrink: 0; display: inline-block; width: 28px; height: 28px; margin: 0 8px 0 0; } .collapsedRenoteTargetText { overflow: hidden; flex-shrink: 1; text-overflow: ellipsis; white-space: nowrap; font-size: 90%; opacity: 0.7; cursor: pointer; &:hover { text-decoration: underline; } } .article { position: relative; display: flex; padding: 28px 32px; } .colorBar { position: absolute; top: 8px; left: 8px; width: 5px; height: calc(100% - 16px); border-radius: 999px; pointer-events: none; } .avatar { flex-shrink: 0; display: block !important; margin: 0 14px 0 0; width: 58px; height: 58px; position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; } .main { flex: 1; min-width: 0; } .cw { cursor: default; display: block; margin: 0; padding: 0; overflow-wrap: break-word; } .showLess { width: 100%; margin-top: 14px; position: sticky; bottom: calc(var(--stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; background: var(--popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; box-shadow: 0 2px 6px rgb(0 0 0 / 20%); } .contentCollapsed { position: relative; max-height: 9em; overflow: clip; } .collapsed { display: block; position: absolute; bottom: 0; left: 0; z-index: 2; width: 100%; height: 64px; background: linear-gradient(0deg, var(--panel), var(--X15)); &:hover > .collapsedLabel { background: var(--panelHighlight); } } .collapsedLabel { 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%); } .text { overflow-wrap: break-word; } .replyIcon { color: var(--accent); margin-right: 0.5em; } .translation { border: solid 0.5px var(--divider); border-radius: var(--radius); padding: 12px; margin-top: 8px; } .urlPreview { margin-top: 8px; } .poll { font-size: 80%; } .quote { padding: 8px 0; } .quoteNote { padding: 16px; border: dashed 1px var(--renote); border-radius: 8px; overflow: clip; } .channel { opacity: 0.7; font-size: 80%; } .footer { margin-bottom: -14px; } .footerButton { margin: 0; padding: 8px; opacity: 0.7; &:not(:last-child) { margin-right: 28px; } &:hover { color: var(--fgHighlighted); } } .footerButtonCount { display: inline; margin: 0 0 0 8px; opacity: 0.7; } @container (max-width: 580px) { .root { font-size: 0.95em; } .renote { padding: 12px 26px 0 26px; } .article { padding: 24px 26px; } .avatar { width: 50px; height: 50px; } } @container (max-width: 500px) { .root { font-size: 0.9em; } .renote { padding: 10px 22px 0 22px; } .article { padding: 20px 22px; } .footer { margin-bottom: -8px; } } @container (max-width: 480px) { .renote { padding: 8px 16px 0 16px; } .tip { padding: 8px 16px 0 16px; } .collapsedRenoteTarget { padding: 0 16px 9px; margin-top: 4px; } .article { padding: 14px 16px; } } @container (max-width: 450px) { .avatar { margin: 0 10px 0 0; width: 46px; height: 46px; top: calc(14px + var(--stickyTop, 0px)); } } @container (max-width: 400px) { .root:not(.showActionsOnlyHover) { .footerButton { &:not(:last-child) { margin-right: 18px; } } } } @container (max-width: 350px) { .root:not(.showActionsOnlyHover) { .footerButton { &:not(:last-child) { margin-right: 12px; } } } .colorBar { top: 6px; left: 6px; width: 4px; height: calc(100% - 12px); } } @container (max-width: 300px) { .avatar { width: 44px; height: 44px; } .root:not(.showActionsOnlyHover) { .footerButton { &:not(:last-child) { margin-right: 8px; } } } } @container (max-width: 250px) { .quoteNote { padding: 12px; } } .muted { padding: 8px; text-align: center; opacity: 0.7; } .reactionOmitted { display: inline-block; height: 32px; margin: 2px; padding: 0 6px; opacity: .8; } </style>