From 5a2bce041c5207e7ef20827e22550b46c3e8b54e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:52:44 +0900 Subject: [PATCH] wip --- .../frontend/src/embed/components/EmNote.vue | 1095 +++++++++++++++++ .../frontend/src/embed/components/EmNotes.vue | 77 ++ .../src/embed/pages/user-timeline.vue | 8 +- 3 files changed, 1176 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/src/embed/components/EmNote.vue create mode 100644 packages/frontend/src/embed/components/EmNotes.vue diff --git a/packages/frontend/src/embed/components/EmNote.vue b/packages/frontend/src/embed/components/EmNote.vue new file mode 100644 index 0000000000..67c006f83c --- /dev/null +++ b/packages/frontend/src/embed/components/EmNote.vue @@ -0,0 +1,1095 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-if="!hardMuted && muted === false" + v-show="!isDeleted" + ref="rootEl" + v-hotkey="keymap" + :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" + :tabindex="isDeleted ? '-1' : '0'" +> + <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="!inEmbedPage && !mock"/> + <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="inEmbedPage ? undefined : 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" @mousedown.prevent="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="!inEmbedPage && !mock"/> + <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="!inEmbedPage && !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" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" 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="!inEmbedPage" + :enableEmojiMenuReaction="true" + /> + <div v-if="translating || translation" :class="$style.translation"> + <MkLoading v-if="translating" mini/> + <div v-else-if="translation"> + <b>{{ i18n.tsx.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 && appearNote.files.length > 0"> + <EmMediaList v-if="inEmbedPage" ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + <MkMediaList v-else ref="galleryEl" :mediaList="appearNote.files"/> + </div> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="inEmbedPage" :class="$style.poll"/> + <div v-if="isEnabledUrlPreview"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> + </div> + <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 v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction"> + <template #more> + <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> + </template> + </MkReactionsViewer> + <footer v-if="inEmbedPage" :class="$style.footer"> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-arrow-back-up"></i> + </a> + <a v-if="canRenote" :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-repeat"></i> + </a> + <a v-else :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button" disabled> + <i class="ti ti-ban"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-dots"></i> + </a> + </footer> + <footer v-else :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">{{ number(appearNote.repliesCount) }}</p> + </button> + <button + v-if="canRenote" + ref="renoteButton" + :class="$style.footerButton" + class="_button" + @mousedown.prevent="renote()" + > + <i class="ti ti-repeat"></i> + <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p> + </button> + <button v-else :class="$style.footerButton" class="_button" disabled> + <i class="ti ti-ban"></i> + </button> + <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i> + <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i> + <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <i class="ti ti-paperclip"></i> + </button> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> + <i class="ti ti-dots"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> + <I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> + <template #name> + <MkA v-user-preview="inEmbedPage ? undefined : appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> + <I18n v-else :src="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkA v-user-preview="inEmbedPage ? undefined : appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +<div v-else> + <!-- + MkDateSeparatedList uses TransitionGroup which requires single element in the child elements + so MkNote create empty div instead of no elements + --> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, ref, shallowRef, Ref, 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 MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; +import EmMediaList from '@/embed/components/EmMediaList.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, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { userPage } from '@/filters/user.js'; +import number from '@/filters/number.js'; +import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.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'; +import { host } from '@/config.js'; +import { isEnabledUrlPreview } from '@/instance.js'; +import { url } from '@/config.js'; +import { type Keymap } from '@/scripts/hotkey.js'; +import { focusPrev, focusNext } from '@/scripts/focus.js'; +import { getAppearNote } from '@/scripts/get-appear-note.js'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + pinned?: boolean; + mock?: boolean; + withHardMute?: boolean; +}>(), { + mock: false, +}); + +provide('mock', props.mock); + +const emit = defineEmits<{ + (ev: 'reaction', emoji: string): void; + (ev: 'removeReaction', emoji: string): void; +}>(); + +const inTimeline = inject<boolean>('inTimeline', false); +const inChannel = inject('inChannel', null); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); +const inEmbedPage = inject<boolean>('EMBED_PAGE', false); + +const note = ref(deepClone(props.note)); + +// plugin +if (noteViewInterruptors.length > 0) { + onMounted(async () => { + let result: Misskey.entities.Note | null = deepClone(note.value); + for (const interruptor of noteViewInterruptors) { + try { + result = await interruptor.handler(result!) as Misskey.entities.Note | null; + if (result === null) { + isDeleted.value = true; + return; + } + } catch (err) { + console.error(err); + } + } + note.value = result as Misskey.entities.Note; + }); +} + +const isRenote = Misskey.note.isPureRenote(note.value); + +const rootEl = shallowRef<HTMLElement>(); +const menuButton = shallowRef<HTMLElement>(); +const renoteButton = shallowRef<HTMLElement>(); +const renoteTime = shallowRef<HTMLElement>(); +const reactButton = shallowRef<HTMLElement>(); +const clipButton = shallowRef<HTMLElement>(); +const appearNote = computed(() => getAppearNote(note.value)); +const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const isMyRenote = $i && ($i.id === note.value.userId); +const showContent = ref(false); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); +const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); +const collapsed = ref(appearNote.value.cw == null && isLong); +const isDeleted = ref(false); +const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translating = ref(false); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref( + defaultStore.state.collapseRenotes && isRenote && ( + ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + (appearNote.value.myReaction != null) + ), +); + +const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ + type: 'lookup', + url: `https://${host}/notes/${appearNote.value.id}`, +})); + +/* Overload FunctionにLintが対応していないのでコメントアウト +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; +*/ +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' { + if (mutedWords == null) return false; + + if (checkWordMute(noteToCheck, $i, mutedWords)) return true; + if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true; + if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true; + + if (checkOnly) return false; + + if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute'; + return false; +} + +const keymap = { + 'r': () => { + if (renoteCollapsed.value) return; + reply(); + }, + 'e|a|plus': () => { + if (renoteCollapsed.value) return; + react(); + }, + 'q': () => { + if (renoteCollapsed.value) return; + renote(); + }, + 'm': () => { + if (renoteCollapsed.value) return; + showMenu(); + }, + 'c': () => { + if (renoteCollapsed.value) return; + if (!defaultStore.state.showClipButtonInNoteFooter) return; + clip(); + }, + 'o': () => { + if (renoteCollapsed.value) return; + galleryEl.value?.openGallery(); + }, + 'v|enter': () => { + if (renoteCollapsed.value) { + renoteCollapsed.value = false; + } else if (appearNote.value.cw != null) { + showContent.value = !showContent.value; + } else if (isLong) { + collapsed.value = !collapsed.value; + } + }, + 'esc': { + allowRepeat: true, + callback: () => blur(), + }, + 'up|k|shift+tab': { + allowRepeat: true, + callback: () => focusBefore(), + }, + 'down|j|tab': { + allowRepeat: true, + callback: () => focusAfter(), + }, +} as const satisfies Keymap; + +provide('react', (reaction: string) => { + misskeyApi('notes/reactions/create', { + noteId: appearNote.value.id, + reaction: reaction, + }); +}); + +if (props.mock || inEmbedPage) { + watch(() => props.note, (to) => { + note.value = deepClone(to); + }, { deep: true }); +} else { + useNoteCapture({ + rootEl: rootEl, + note: appearNote, + pureNote: note, + isDeletedRef: isDeleted, + }); +} + +if (!props.mock && !inEmbedPage) { + useTooltip(renoteButton, async (showing) => { + const renotes = await misskeyApi('notes/renotes', { + noteId: appearNote.value.id, + limit: 11, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + const { dispose } = os.popup(MkUsersTooltip, { + showing, + users, + count: appearNote.value.renoteCount, + targetElement: renoteButton.value, + }, { + closed: () => dispose(), + }); + }); + + if (appearNote.value.reactionAcceptance === 'likeOnly') { + useTooltip(reactButton, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: appearNote.value.id, + limit: 10, + _cacheKey_: appearNote.value.reactionCount, + }); + + const users = reactions.map(x => x.user); + + if (users.length < 1) return; + + const { dispose } = os.popup(MkReactionsViewerDetails, { + showing, + reaction: '❤️', + users, + count: appearNote.value.reactionCount, + targetElement: reactButton.value!, + }, { + closed: () => dispose(), + }); + }); + } +} + +function renote(viaKeyboard = false) { + pleaseLogin(undefined, pleaseLoginContext.value); + showMovedDialog(); + + const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); + os.popupMenu(menu, renoteButton.value, { + viaKeyboard, + }); +} + +function reply(): void { + pleaseLogin(undefined, pleaseLoginContext.value); + if (props.mock) { + return; + } + os.post({ + reply: appearNote.value, + channel: appearNote.value.channel, + }).then(() => { + focus(); + }); +} + +function react(): void { + pleaseLogin(undefined, pleaseLoginContext.value); + showMovedDialog(); + if (appearNote.value.reactionAcceptance === 'likeOnly') { + sound.playMisskeySfx('reaction'); + + if (props.mock) { + return; + } + + misskeyApi('notes/reactions/create', { + noteId: appearNote.value.id, + reaction: '❤️', + }); + const el = reactButton.value; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } + } else { + blur(); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); + + if (props.mock) { + emit('reaction', reaction); + return; + } + + misskeyApi('notes/reactions/create', { + noteId: appearNote.value.id, + reaction: reaction, + }); + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } +} + +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; + if (!oldReaction) return; + + if (props.mock) { + emit('removeReaction', oldReaction); + return; + } + + misskeyApi('notes/reactions/delete', { + noteId: targetNote.id, + }); +} + +function toggleReact() { + if (appearNote.value.myReaction == null) { + react(); + } else { + undoReact(appearNote.value); + } +} + +function onContextmenu(ev: MouseEvent): void { + if (props.mock || inEmbedPage) { + return; + } + + const isLink = (el: HTMLElement): boolean => { + if (el.tagName === 'A') return true; + // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 + if (el.tagName === 'AUDIO') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + return false; + }; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + os.contextMenu(menu, ev).then(focus).finally(cleanup); + } +} + +function showMenu(): void { + if (props.mock) { + return; + } + + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); +} + +async function clip(): Promise<void> { + if (props.mock) { + return; + } + + os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); +} + +function showRenoteMenu(): void { + if (props.mock) { + return; + } + + function getUnrenote(): MenuItem { + return { + text: i18n.ts.unrenote, + icon: 'ti ti-trash', + danger: true, + action: () => { + misskeyApi('notes/delete', { + noteId: note.value.id, + }); + isDeleted.value = true; + }, + }; + } + + if (isMyRenote) { + pleaseLogin(undefined, pleaseLoginContext.value); + os.popupMenu([ + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, + getUnrenote(), + ], renoteTime.value); + } else { + os.popupMenu([ + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, + getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, + ], renoteTime.value); + } +} + +function focus() { + rootEl.value?.focus(); +} + +function blur() { + rootEl.value?.blur(); +} + +function focusBefore() { + focusPrev(rootEl.value); +} + +function focusAfter() { + focusNext(rootEl.value); +} + +function readPromo() { + misskeyApi('promo/read', { + noteId: appearNote.value.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 2px 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), color(from var(--panel) srgb r g b / 0)); + + &: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); + } +} + +.footerButtonLink:hover, +.footerButtonLink:focus, +.footerButtonLink:active { + text-decoration: none; +} + +.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; + margin-left: 8px; + opacity: .8; + font-size: 95%; +} +</style> diff --git a/packages/frontend/src/embed/components/EmNotes.vue b/packages/frontend/src/embed/components/EmNotes.vue new file mode 100644 index 0000000000..710a14111d --- /dev/null +++ b/packages/frontend/src/embed/components/EmNotes.vue @@ -0,0 +1,77 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items: notes }"> + <div :class="[$style.root, { [$style.noGap]: noGap }]"> + <MkDateSeparatedList + ref="notes" + v-slot="{ item: note }" + :items="notes" + :direction="pagination.reversed ? 'up' : 'down'" + :reversed="pagination.reversed" + :noGap="noGap" + :ad="ad" + :class="$style.notes" + > + <EmNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> + </MkDateSeparatedList> + </div> + </template> +</MkPagination> +</template> + +<script lang="ts" setup> +import { shallowRef } from 'vue'; +import EmNote from '@/components/EmNote.vue'; +import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import { i18n } from '@/i18n.js'; +import { infoImageUrl } from '@/instance.js'; + +const props = withDefaults(defineProps<{ + pagination: Paging; + noGap?: boolean; + disableAutoLoad?: boolean; + ad?: boolean; +}>(), { + ad: true, +}); + +const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); + +defineExpose({ + pagingComponent, +}); +</script> + +<style lang="scss" module> +.root { + &.noGap { + > .notes { + background: var(--panel); + } + } + + &:not(.noGap) { + > .notes { + background: var(--bg); + + .note { + background: var(--panel); + border-radius: var(--radius); + } + } + } +} +</style> diff --git a/packages/frontend/src/embed/pages/user-timeline.vue b/packages/frontend/src/embed/pages/user-timeline.vue index 1fd75cd461..22ff4c3b3d 100644 --- a/packages/frontend/src/embed/pages/user-timeline.vue +++ b/packages/frontend/src/embed/pages/user-timeline.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> <template #body> - <MkNotes + <MkEmNotes ref="notesEl" :pagination="pagination" :disableAutoLoad="!embedParams.autoload" @@ -48,10 +48,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, computed, shallowRef, inject, onActivated } from 'vue'; import * as Misskey from 'misskey-js'; -import MkNotes from '@/components/MkNotes.vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { ParsedEmbedParams } from '@/scripts/embed-page.js'; +import MkEmNotes from '@/components/MkEmNotes.vue'; import XNotFound from '@/pages/not-found.vue'; import EmTimelineContainer from '@/embed/components/EmTimelineContainer.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; @@ -60,7 +61,6 @@ import { scrollToTop } from '@/scripts/scroll.js'; import { isLink } from '@/scripts/is-link.js'; import { useRouter } from '@/router/supplier.js'; import { defaultEmbedParams } from '@/scripts/embed-page.js'; -import type { ParsedEmbedParams } from '@/scripts/embed-page.js'; const props = defineProps<{ username: string;