diff options
Diffstat (limited to 'packages/frontend/src/components')
25 files changed, 1135 insertions, 1181 deletions
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index d0b50f04f2..5562be682b 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: PagingCtx; noGap?: boolean; extractor?: (item: any) => any; }>(), { diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 1cf6f0b744..82561055bc 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -7,8 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; -import type { PropType } from 'vue'; -import type { MisskeyEntity } from '@/types/date-separated-list.js'; import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; import * as os from '@/os.js'; @@ -19,7 +17,7 @@ import { getDateText } from '@/utility/timeline-date-separate.js'; export default defineComponent({ props: { items: { - type: Array as PropType<MisskeyEntity[]>, + type: Array, required: true, }, direction: { diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 57946aaf2b..a2843a3503 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -53,7 +53,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = ref<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const username = ref(''); const email = ref(''); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 980636f551..9b7658292d 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -6,11 +6,10 @@ 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]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" - :tabindex="isDeleted ? '-1' : '0'" + tabindex="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> @@ -87,7 +86,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/> + <MkPoll + v-if="appearNote.poll" + :noteId="appearNote.id" + :multiple="appearNote.poll.multiple" + :expiresAt="appearNote.poll.expiresAt" + :choices="$appearNote.pollChoices" + :author="appearNote.user" + :emojiUrls="appearNote.emojis" + :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> @@ -101,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only </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'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer + v-if="appearNote.reactionAcceptance !== 'likeOnly'" + style="margin-top: 6px;" + :reactions="$appearNote.reactions" + :reactionEmojis="$appearNote.reactionEmojis" + :myReaction="$appearNote.myReaction" + :noteId="appearNote.id" + :maxNumber="16" + @mockUpdateMyReaction="emitUpdReaction" + > <template #more> <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> @@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only <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(--MI_THEME-love);"></i> - <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> + <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-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' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p> </button> <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> @@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; @@ -210,7 +227,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; import { useTooltip } from '@/use/use-tooltip.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -245,29 +263,33 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true)); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); -const note = ref(deepClone(props.note)); +let note = deepClone(props.note); // plugin const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note.value); + let result: Misskey.entities.Note | null = deepClone(note); 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; + note = result as Misskey.entities.Note; }); } -const isRenote = Misskey.note.isPureRenote(note.value); +const isRenote = Misskey.note.isPureRenote(note); +const appearNote = getAppearNote(note); +const $appearNote = reactive({ + reactions: appearNote.reactions, + reactionCount: appearNote.reactionCount, + reactionEmojis: appearNote.reactionEmojis, + myReaction: appearNote.myReaction, + pollChoices: appearNote.poll?.choices, +}); const rootEl = useTemplateRef('rootEl'); const menuButton = useTemplateRef('menuButton'); @@ -275,32 +297,30 @@ const renoteButton = useTemplateRef('renoteButton'); const renoteTime = useTemplateRef('renoteTime'); const reactButton = useTemplateRef('reactButton'); const clipButton = useTemplateRef('clipButton'); -const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = useTemplateRef('galleryEl'); -const isMyRenote = $i && ($i.id === note.value.userId); +const isMyRenote = $i && ($i.id === note.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 parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null); +const isLong = shouldCollapsed(appearNote, urls.value ?? []); +const collapsed = ref(appearNote.cw == null && isLong); +const muted = ref(checkMute(appearNote, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true)); const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.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 showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)); const renoteCollapsed = ref( prefer.s.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) + ($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + ($appearNote.myReaction != null) ), ); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: `https://${host}/notes/${appearNote.id}`, })); /* Overload FunctionにLintが対応していないのでコメントアウト @@ -357,7 +377,7 @@ const keymap = { 'v|enter': () => { if (renoteCollapsed.value) { renoteCollapsed.value = false; - } else if (appearNote.value.cw != null) { + } else if (appearNote.cw != null) { showContent.value = !showContent.value; } else if (isLong) { collapsed.value = !collapsed.value; @@ -380,28 +400,28 @@ const keymap = { provide(DI.mfmEmojiReactCallback, (reaction) => { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); -if (props.mock) { - watch(() => props.note, (to) => { - note.value = deepClone(to); - }, { deep: true }); -} else { +if (!props.mock) { useNoteCapture({ - rootEl: rootEl, note: appearNote, - pureNote: note, - isDeletedRef: isDeleted, + parentNote: note, + $note: $appearNote, }); } if (!props.mock) { useTooltip(renoteButton, async (showing) => { const renotes = await misskeyApi('notes/renotes', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 11, }); @@ -412,19 +432,19 @@ if (!props.mock) { const { dispose } = os.popup(MkUsersTooltip, { showing, users, - count: appearNote.value.renoteCount, + count: appearNote.renoteCount, targetElement: renoteButton.value, }, { closed: () => dispose(), }); }); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { useTooltip(reactButton, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 10, - _cacheKey_: appearNote.value.reactionCount, + _cacheKey_: $appearNote.reactionCount, }); const users = reactions.map(x => x.user); @@ -435,7 +455,7 @@ if (!props.mock) { showing, reaction: '❤️', users, - count: appearNote.value.reactionCount, + count: $appearNote.reactionCount, targetElement: reactButton.value!, }, { closed: () => dispose(), @@ -448,7 +468,7 @@ function renote(viaKeyboard = false) { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); + const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); os.popupMenu(menu, renoteButton.value, { viaKeyboard, }); @@ -460,8 +480,8 @@ function reply(): void { return; } os.post({ - reply: appearNote.value, - channel: appearNote.value.channel, + reply: appearNote, + channel: appearNote.channel, }).then(() => { focus(); }); @@ -470,7 +490,7 @@ function reply(): void { function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); if (props.mock) { @@ -478,8 +498,13 @@ function react(): void { } misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -492,7 +517,7 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + reactionPicker.show(reactButton.value ?? null, note, async (reaction) => { if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', @@ -506,14 +531,23 @@ function react(): void { if (props.mock) { emit('reaction', reaction); + $appearNote.reactions[reaction] = 1; + $appearNote.reactionCount++; + $appearNote.myReaction = reaction; return; } misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); - if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -522,8 +556,8 @@ function react(): void { } } -function undoReact(targetNote: Misskey.entities.Note): void { - const oldReaction = targetNote.myReaction; +function undoReact(): void { + const oldReaction = $appearNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -532,15 +566,15 @@ function undoReact(targetNote: Misskey.entities.Note): void { } misskeyApi('notes/reactions/delete', { - noteId: targetNote.id, + noteId: appearNote.id, }); } function toggleReact() { - if (appearNote.value.myReaction == null) { + if ($appearNote.myReaction == null) { react(); } else { - undoReact(appearNote.value); + undoReact(); } } @@ -556,7 +590,7 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } @@ -566,7 +600,7 @@ function showMenu(): void { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } @@ -575,7 +609,7 @@ async function clip(): Promise<void> { return; } - os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(): void { @@ -590,9 +624,10 @@ function showRenoteMenu(): void { danger: true, action: () => { misskeyApi('notes/delete', { - noteId: note.value.id, + noteId: note.id, + }).then(() => { + globalEvents.emit('noteDeleted', note.id); }); - isDeleted.value = true; }, }; } @@ -601,23 +636,23 @@ function showRenoteMenu(): void { type: 'link', text: i18n.ts.renoteDetails, icon: 'ti ti-info-circle', - to: notePage(note.value), + to: notePage(note), }; if (isMyRenote) { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([ renoteDetailsMenu, - getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, getUnrenote(), ], renoteTime.value); } else { os.popupMenu([ renoteDetailsMenu, - getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, - getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), + getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value); } @@ -641,9 +676,8 @@ function focusAfter() { function readPromo() { misskeyApi('promo/read', { - noteId: appearNote.value.id, + noteId: appearNote.id, }); - isDeleted.value = true; } function emitUpdReaction(emoji: string, delta: number) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 17a348affe..93e79e7c1f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!muted" - v-show="!isDeleted" + v-if="!muted && !isDeleted" ref="rootEl" v-hotkey="keymap" :class="$style.root" - :tabindex="isDeleted ? '-1' : '0'" + tabindex="0" > <div v-if="appearNote.reply && appearNote.reply.replyId"> <div v-if="!conversationLoaded" style="padding: 16px"> @@ -110,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> + <MkPoll + v-if="appearNote.poll" + :noteId="appearNote.id" + :multiple="appearNote.poll.multiple" + :expiresAt="appearNote.poll.expiresAt" + :choices="$appearNote.pollChoices" + :author="appearNote.user" + :emojiUrls="appearNote.emojis" + :class="$style.poll" + /> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> </div> @@ -124,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> + <MkReactionsViewer + v-if="appearNote.reactionAcceptance !== 'likeOnly'" + style="margin-top: 6px;" + :reactions="$appearNote.reactions" + :reactionEmojis="$appearNote.reactionEmojis" + :myReaction="$appearNote.myReaction" + :noteId="appearNote.id" + :maxNumber="16" + @mockUpdateMyReaction="emitUpdReaction" + /> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> @@ -143,11 +160,11 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-ban"></i> </button> <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> - <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> + <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-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' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p> </button> <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> @@ -182,9 +199,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div :class="$style.reactionTabs"> - <button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> + <button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> <MkReactionIcon :reaction="reaction"/> - <span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> + <span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span> </button> </div> <MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true"> @@ -199,7 +216,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> -<div v-else class="_panel" :class="$style.muted" @click="muted = false"> +<div v-else-if="muted" class="_panel" :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)"> @@ -211,13 +228,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue'; +import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; -import type { Paging } from '@/components/MkPagination.vue'; import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -242,7 +258,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; -import { useNoteCapture } from '@/use/use-note-capture.js'; +import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; import { useTooltip } from '@/use/use-tooltip.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -257,6 +273,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -267,29 +284,33 @@ const props = withDefaults(defineProps<{ const inChannel = inject('inChannel', null); -const note = ref(deepClone(props.note)); +let note = deepClone(props.note); // plugin const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note.value); + let result: Misskey.entities.Note | null = deepClone(note); 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; + note = result as Misskey.entities.Note; }); } -const isRenote = Misskey.note.isPureRenote(note.value); +const isRenote = Misskey.note.isPureRenote(note); +const appearNote = getAppearNote(note); +const $appearNote = reactive({ + reactions: appearNote.reactions, + reactionCount: appearNote.reactionCount, + reactionEmojis: appearNote.reactionEmojis, + myReaction: appearNote.myReaction, + pollChoices: appearNote.poll?.choices, +}); const rootEl = useTemplateRef('rootEl'); const menuButton = useTemplateRef('menuButton'); @@ -297,24 +318,29 @@ const renoteButton = useTemplateRef('renoteButton'); const renoteTime = useTemplateRef('renoteTime'); const reactButton = useTemplateRef('reactButton'); const clipButton = useTemplateRef('clipButton'); -const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = useTemplateRef('galleryEl'); -const isMyRenote = $i && ($i.id === note.value.userId); +const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; -const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); +const parsed = appearNote.text ? mfm.parse(appearNote.text) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null; +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id); + +useGlobalEvent('noteDeleted', (noteId) => { + if (noteId === note.id || noteId === appearNote.id) { + isDeleted.value = true; + } +}); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', - url: `https://${host}/notes/${appearNote.value.id}`, + url: `https://${host}/notes/${appearNote.id}`, })); const keymap = { @@ -328,7 +354,7 @@ const keymap = { }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { - if (appearNote.value.cw != null) { + if (appearNote.cw != null) { showContent.value = !showContent.value; } }, @@ -341,41 +367,45 @@ const keymap = { provide(DI.mfmEmojiReactCallback, (reaction) => { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); }); const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); -const renotesPagination = computed<Paging>(() => ({ +const renotesPagination = computed(() => ({ endpoint: 'notes/renotes', limit: 10, params: { - noteId: appearNote.value.id, + noteId: appearNote.id, }, })); -const reactionsPagination = computed<Paging>(() => ({ +const reactionsPagination = computed(() => ({ endpoint: 'notes/reactions', limit: 10, params: { - noteId: appearNote.value.id, + noteId: appearNote.id, type: reactionTabType.value, }, })); useNoteCapture({ - rootEl: rootEl, note: appearNote, - pureNote: note, - isDeletedRef: isDeleted, + parentNote: note, + $note: $appearNote, }); useTooltip(renoteButton, async (showing) => { const renotes = await misskeyApi('notes/renotes', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 11, }); @@ -386,19 +416,19 @@ useTooltip(renoteButton, async (showing) => { const { dispose } = os.popup(MkUsersTooltip, { showing, users, - count: appearNote.value.renoteCount, + count: appearNote.renoteCount, targetElement: renoteButton.value, }, { closed: () => dispose(), }); }); -if (appearNote.value.reactionAcceptance === 'likeOnly') { +if (appearNote.reactionAcceptance === 'likeOnly') { useTooltip(reactButton, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 10, - _cacheKey_: appearNote.value.reactionCount, + _cacheKey_: $appearNote.reactionCount, }); const users = reactions.map(x => x.user); @@ -409,7 +439,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') { showing, reaction: '❤️', users, - count: appearNote.value.reactionCount, + count: $appearNote.reactionCount, targetElement: reactButton.value!, }, { closed: () => dispose(), @@ -421,7 +451,7 @@ function renote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - const { menu } = getRenoteMenu({ note: note.value, renoteButton }); + const { menu } = getRenoteMenu({ note: note, renoteButton }); os.popupMenu(menu, renoteButton.value); } @@ -429,8 +459,8 @@ function reply(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); os.post({ - reply: appearNote.value, - channel: appearNote.value.channel, + reply: appearNote, + channel: appearNote.channel, }).then(() => { focus(); }); @@ -439,12 +469,17 @@ function reply(): void { function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); - if (appearNote.value.reactionAcceptance === 'likeOnly') { + if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: '❤️', + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: '❤️', + }); }); const el = reactButton.value; if (el && prefer.s.animation) { @@ -457,7 +492,7 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { + reactionPicker.show(reactButton.value ?? null, note, async (reaction) => { if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', @@ -470,10 +505,15 @@ function react(): void { sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { - noteId: appearNote.value.id, + noteId: appearNote.id, reaction: reaction, + }).then(() => { + noteEvents.emit(`reacted:${appearNote.id}`, { + userId: $i!.id, + reaction: reaction, + }); }); - if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -491,10 +531,10 @@ function undoReact(targetNote: Misskey.entities.Note): void { } function toggleReact() { - if (appearNote.value.myReaction == null) { + if (appearNote.myReaction == null) { react(); } else { - undoReact(appearNote.value); + undoReact(appearNote); } } @@ -506,18 +546,18 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function showMenu(): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { - os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus); } function showRenoteMenu(): void { @@ -529,9 +569,10 @@ function showRenoteMenu(): void { danger: true, action: () => { misskeyApi('notes/delete', { - noteId: note.value.id, + noteId: note.id, + }).then(() => { + globalEvents.emit('noteDeleted', note.id); }); - isDeleted.value = true; }, }], renoteTime.value); } @@ -549,7 +590,7 @@ const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; misskeyApi('notes/children', { - noteId: appearNote.value.id, + noteId: appearNote.id, limit: 30, }).then(res => { replies.value = res; @@ -560,9 +601,9 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - if (appearNote.value.replyId == null) return; + if (appearNote.replyId == null) return; misskeyApi('notes/conversation', { - noteId: appearNote.value.replyId, + noteId: appearNote.replyId, }).then(res => { conversation.value = res.reverse(); }); diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotesTimeline.vue index 509099e0b9..71dd8e51a0 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotesTimeline.vue @@ -4,13 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> +<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh"> <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> - <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> + <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"> <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> + <div v-if="i > 0 && isSeparatorNeeded(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> + <div :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + </div> + <div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> <div :class="$style.ad"> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> @@ -25,30 +33,38 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { useTemplateRef } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; -const props = defineProps<{ - pagination: Paging; +const props = withDefaults(defineProps<{ + pagination: PagingCtx; noGap?: boolean; disableAutoLoad?: boolean; -}>(); + pullToRefresh?: boolean; +}>(), { + pullToRefresh: true, +}); const pagingComponent = useTemplateRef('pagingComponent'); +useGlobalEvent('noteDeleted', (noteId) => { + pagingComponent.value?.paginator.removeItem(noteId); +}); + +function reload() { + return pagingComponent.value?.paginator.reload(); +} + defineExpose({ - pagingComponent, + reload, }); </script> <style lang="scss" module> -.reverse { - display: flex; - flex-direction: column-reverse; -} - .root { container-type: inline-size; @@ -77,6 +93,18 @@ defineExpose({ } } +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + .ad:empty { display: none; } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue deleted file mode 100644 index 3c88b8af0d..0000000000 --- a/packages/frontend/src/components/MkNotifications.vue +++ /dev/null @@ -1,142 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> - <MkPagination ref="pagingComponent" :pagination="pagination"> - <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template> - - <template #default="{ items: notifications }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" - :enterActiveClass="$style.transition_x_enterActive" - :leaveActiveClass="$style.transition_x_leaveActive" - :enterFromClass="$style.transition_x_enterFrom" - :leaveToClass="$style.transition_x_leaveTo" - :moveClass=" $style.transition_x_move" - tag="div" - > - <template v-for="(notification, i) in notifications" :key="notification.id"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/> - <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/> - </template> - </component> - </template> - </MkPagination> -</component> -</template> - -<script lang="ts" setup> -import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue'; -import * as Misskey from 'misskey-js'; -import type { notificationTypes } from '@@/js/const.js'; -import MkPagination from '@/components/MkPagination.vue'; -import XNotification from '@/components/MkNotification.vue'; -import MkNote from '@/components/MkNote.vue'; -import { useStream } from '@/stream.js'; -import { i18n } from '@/i18n.js'; -import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { prefer } from '@/preferences.js'; - -const props = defineProps<{ - excludeTypes?: typeof notificationTypes[number][]; -}>(); - -const pagingComponent = useTemplateRef('pagingComponent'); - -const pagination = computed(() => prefer.r.useGroupedNotifications.value ? { - endpoint: 'i/notifications-grouped' as const, - limit: 20, - params: computed(() => ({ - excludeTypes: props.excludeTypes ?? undefined, - })), -} : { - endpoint: 'i/notifications' as const, - limit: 20, - params: computed(() => ({ - excludeTypes: props.excludeTypes ?? undefined, - })), -}); - -function onNotification(notification) { - const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; - if (isMuted || window.document.visibilityState === 'visible') { - useStream().send('readNotification'); - } - - if (!isMuted) { - pagingComponent.value?.prepend(notification); - } -} - -function reload() { - return new Promise<void>((res) => { - pagingComponent.value?.reload().then(() => { - res(); - }); - }); -} - -let connection: Misskey.ChannelConnection<Misskey.Channels['main']>; - -onMounted(() => { - connection = useStream().useChannel('main'); - connection.on('notification', onNotification); - connection.on('notificationFlushed', reload); -}); - -onUnmounted(() => { - if (connection) connection.dispose(); -}); - -defineExpose({ - reload, -}); -</script> - -<style lang="scss" module> -.transition_x_move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); -} - -.transition_x_enterActive { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - - &.item, - .item { - /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ - content-visibility: visible !important; - } -} - -.transition_x_leaveActive { - transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); -} - -.transition_x_enterFrom { - opacity: 0; - transform: translateY(max(-64px, -100%)); -} - -@supports (interpolate-size: allow-keywords) { - .transition_x_enterFrom { - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 - height: 0; - } -} - -.transition_x_leaveTo { - opacity: 0; -} - -.notifications { - container-type: inline-size; - background: var(--MI_THEME-panel); -} - -.item { - border-bottom: solid 0.5px var(--MI_THEME-divider); -} -</style> diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 54da5a889d..37e15df39b 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -4,483 +4,74 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition - :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" - mode="out-in" -> - <MkLoading v-if="fetching"/> +<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()"> + <Transition + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" + :css="prefer.s.animation" + mode="out-in" + > + <MkLoading v-if="paginator.fetching.value"/> - <MkError v-else-if="error" @retry="init()"/> + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> - <div v-else-if="empty" key="_empty_"> - <slot name="empty"><MkResult type="empty"/></slot> - </div> - - <div v-else ref="rootEl" class="_gaps"> - <div v-show="pagination.reversed && more" key="_more_"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead"> - {{ i18n.ts.loadMore }} - </MkButton> - <MkLoading v-else/> + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty"/></slot> </div> - <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <div v-show="!pagination.reversed && more" key="_more_"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore"> - {{ i18n.ts.loadMore }} - </MkButton> - <MkLoading v-else/> + + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_"> + <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-else/> + </div> + <slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> + <div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_"> + <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder"> + {{ i18n.ts.loadMore }} + </MkButton> + <MkLoading v-else/> + </div> </div> - </div> -</Transition> + </Transition> +</component> </template> -<script lang="ts"> -import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue'; -import * as Misskey from 'misskey-js'; -import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; -import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js'; -import type { ComputedRef } from 'vue'; -import type { MisskeyEntity } from '@/types/date-separated-list.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { i18n } from '@/i18n.js'; -import { prefer } from '@/preferences.js'; - -const SECOND_FETCH_LIMIT = 30; -const TOLERANCE = 16; -const APPEAR_MINIMUM_INTERVAL = 600; - -export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { - endpoint: E; - limit: number; - params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; - - /** - * 検索APIのような、ページング不可なエンドポイントを利用する場合 - * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) - */ - noPaging?: boolean; - - /** - * items 配列の中身を逆順にする(新しい方が最後) - */ - reversed?: boolean; - - offsetMode?: boolean; -}; - -type MisskeyEntityMap = Map<string, MisskeyEntity>; - -function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] { - return entities.map(en => [en.id, en]); -} - -function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { - return new Map([...map, ...arrayToEntries(entities)]); -} - -</script> <script lang="ts" setup> +import type { PagingCtx } from '@/use/use-pagination.js'; import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { usePagination } from '@/use/use-pagination.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: PagingCtx; disableAutoLoad?: boolean; displayLimit?: number; + pullToRefresh?: boolean; }>(), { displayLimit: 20, + pullToRefresh: true, }); -const emit = defineEmits<{ - (ev: 'queue', count: number): void; - (ev: 'status', error: boolean): void; -}>(); - -const rootEl = useTemplateRef('rootEl'); - -// 遡り中かどうか -const backed = ref(false); - -const scrollRemove = ref<(() => void) | null>(null); - -/** - * 表示するアイテムのソース - * 最新が0番目 - */ -const items = ref<MisskeyEntityMap>(new Map()); - -/** - * タブが非アクティブなどの場合に更新を貯めておく - * 最新が0番目 - */ -const queue = ref<MisskeyEntityMap>(new Map()); - -/** - * 初期化中かどうか(trueならMkLoadingで全て隠す) - */ -const fetching = ref(true); - -const moreFetching = ref(false); -const more = ref(false); -const preventAppearFetchMore = ref(false); -const preventAppearFetchMoreTimer = ref<number | null>(null); -const isBackTop = ref(false); -const empty = computed(() => items.value.size === 0); -const error = ref(false); -const { - enableInfiniteScroll, -} = prefer.r; - -const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); - -const visibility = useDocumentVisibility(); - -let isPausingUpdate = false; -let timerForSetPause: number | null = null; -const BACKGROUND_PAUSE_WAIT_SEC = 10; - -// 先頭が表示されているかどうかを検出 -// https://qiita.com/mkataigi/items/0154aefd2223ce23398e -const scrollObserver = ref<IntersectionObserver>(); - -watch([() => props.pagination.reversed, scrollableElement], () => { - if (scrollObserver.value) scrollObserver.value.disconnect(); - - scrollObserver.value = new IntersectionObserver(entries => { - backed.value = entries[0].isIntersecting; - }, { - root: scrollableElement.value, - rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', - threshold: 0.01, - }); -}, { immediate: true }); - -watch(rootEl, () => { - scrollObserver.value?.disconnect(); - nextTick(() => { - if (rootEl.value) scrollObserver.value?.observe(rootEl.value); - }); -}); - -watch([backed, rootEl], () => { - if (!backed.value) { - if (!rootEl.value) return; - - scrollRemove.value = props.pagination.reversed - ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) - : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); - } else { - if (scrollRemove.value) scrollRemove.value(); - scrollRemove.value = null; - } -}); - -// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) -watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true }); - -watch(queue, (a, b) => { - if (a.size === 0 && b.size === 0) return; - emit('queue', queue.value.size); -}, { deep: true }); - -watch(error, (n, o) => { - if (n === o) return; - emit('status', n); -}); - -async function init(): Promise<void> { - items.value = new Map(); - queue.value = new Map(); - fetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: props.pagination.limit ?? 10, - allowPartial: true, - }).then(res => { - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 3) item._shouldInsertAd_ = true; - } - - if (res.length === 0 || props.pagination.noPaging) { - concatItems(res); - more.value = false; - } else { - if (props.pagination.reversed) moreFetching.value = true; - concatItems(res); - more.value = true; - } - - error.value = false; - fetching.value = false; - }, err => { - error.value = true; - fetching.value = false; - }); -} - -const reload = (): Promise<void> => { - return init(); -}; - -const fetchMore = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; - moreFetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { - offset: items.value.size, - } : { - untilId: Array.from(items.value.keys()).at(-1), - }), - }).then(res => { - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 10) item._shouldInsertAd_ = true; - } - - const reverseConcat = _res => { - const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight(); - const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY; - - items.value = concatMapWithArray(items.value, _res); - - return nextTick(() => { - if (scrollableElement.value) { - scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); - } else { - window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); - } - - return nextTick(); - }); - }; - - if (res.length === 0) { - if (props.pagination.reversed) { - reverseConcat(res).then(() => { - more.value = false; - moreFetching.value = false; - }); - } else { - items.value = concatMapWithArray(items.value, res); - more.value = false; - moreFetching.value = false; - } - } else { - if (props.pagination.reversed) { - reverseConcat(res).then(() => { - more.value = true; - moreFetching.value = false; - }); - } else { - items.value = concatMapWithArray(items.value, res); - more.value = true; - moreFetching.value = false; - } - } - }, err => { - moreFetching.value = false; - }); -}; - -const fetchMoreAhead = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; - moreFetching.value = true; - const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { - ...params, - limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { - offset: items.value.size, - } : { - sinceId: Array.from(items.value.keys()).at(-1), - }), - }).then(res => { - if (res.length === 0) { - items.value = concatMapWithArray(items.value, res); - more.value = false; - } else { - items.value = concatMapWithArray(items.value, res); - more.value = true; - } - moreFetching.value = false; - }, err => { - moreFetching.value = false; - }); -}; - -/** - * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、 - * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ - */ -const fetchMoreApperTimeoutFn = (): void => { - preventAppearFetchMore.value = false; - preventAppearFetchMoreTimer.value = null; -}; -const fetchMoreAppearTimeout = (): void => { - preventAppearFetchMore.value = true; - preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL); -}; - -const appearFetchMore = async (): Promise<void> => { - if (preventAppearFetchMore.value) return; - await fetchMore(); - fetchMoreAppearTimeout(); -}; - -const appearFetchMoreAhead = async (): Promise<void> => { - if (preventAppearFetchMore.value) return; - await fetchMoreAhead(); - fetchMoreAppearTimeout(); -}; - -const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); - -watch(visibility, () => { - if (visibility.value === 'hidden') { - timerForSetPause = window.setTimeout(() => { - isPausingUpdate = true; - timerForSetPause = null; - }, - BACKGROUND_PAUSE_WAIT_SEC * 1000); - } else { // 'visible' - if (timerForSetPause) { - window.clearTimeout(timerForSetPause); - timerForSetPause = null; - } else { - isPausingUpdate = false; - if (isHead()) { - executeQueue(); - } - } - } +const paginator = usePagination({ + ctx: props.pagination, }); -/** - * 最新のものとして1つだけアイテムを追加する - * ストリーミングから降ってきたアイテムはこれで追加する - * @param item アイテム - */ -function prepend(item: MisskeyEntity): void { - if (items.value.size === 0) { - items.value.set(item.id, item); - fetching.value = false; - return; - } - - if (_DEV_) console.log(isHead(), isPausingUpdate); - - if (isHead() && !isPausingUpdate) unshiftItems([item]); - else prependQueue(item); +function appearFetchMoreAhead() { + paginator.fetchNewer(); } -/** - * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する - * @param newItems 新しいアイテムの配列 - */ -function unshiftItems(newItems: MisskeyEntity[]) { - const length = newItems.length + items.value.size; - items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); - - if (length >= props.displayLimit) more.value = true; +function appearFetchMore() { + paginator.fetchOlder(); } -/** - * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する - * @param oldItems 古いアイテムの配列 - */ -function concatItems(oldItems: MisskeyEntity[]) { - const length = oldItems.length + items.value.size; - items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); - - if (length >= props.displayLimit) more.value = true; -} - -function executeQueue() { - unshiftItems(Array.from(queue.value.values())); - queue.value = new Map(); -} - -function prependQueue(newItem: MisskeyEntity) { - queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); -} - -/* - * アイテムを末尾に追加する(使うの?) - */ -const appendItem = (item: MisskeyEntity): void => { - items.value.set(item.id, item); -}; - -const removeItem = (id: string) => { - items.value.delete(id); - queue.value.delete(id); -}; - -const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { - const item = items.value.get(id); - if (item) items.value.set(id, replacer(item)); - - const queueItem = queue.value.get(id); - if (queueItem) queue.value.set(id, replacer(queueItem)); -}; - -onActivated(() => { - isBackTop.value = false; -}); - -onDeactivated(() => { - isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; -}); - -function toBottom() { - scrollToBottom(rootEl.value!); -} - -onBeforeMount(() => { - init().then(() => { - if (props.pagination.reversed) { - nextTick(() => { - window.setTimeout(toBottom, 800); - - // scrollToBottomでmoreFetchingボタンが画面外まで出るまで - // more = trueを遅らせる - window.setTimeout(() => { - moreFetching.value = false; - }, 2000); - }); - } - }); -}); - -onBeforeUnmount(() => { - if (timerForSetPause) { - window.clearTimeout(timerForSetPause); - timerForSetPause = null; - } - if (preventAppearFetchMoreTimer.value) { - window.clearTimeout(preventAppearFetchMoreTimer.value); - preventAppearFetchMoreTimer.value = null; - } - scrollObserver.value?.disconnect(); -}); - defineExpose({ - items, - queue, - backed: backed.value, - more, - reload, - prepend, - append: appendItem, - removeItem, - updateItem, + paginator: paginator, }); </script> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 2d3ec45bca..359ee08812 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="{ [$style.done]: closed || isVoted }"> <ul :class="$style.choices"> - <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <li v-for="(choice, i) in choices" :key="i" :class="$style.choice" @click="vote(i)"> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template> @@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js'; const props = defineProps<{ noteId: string; - poll: NonNullable<Misskey.entities.Note['poll']>; + multiple: NonNullable<Misskey.entities.Note['poll']>['multiple']; + expiresAt: NonNullable<Misskey.entities.Note['poll']>['expiresAt']; + choices: NonNullable<Misskey.entities.Note['poll']>['choices']; readOnly?: boolean; emojiUrls?: Record<string, string>; author?: Misskey.entities.UserLite; @@ -48,9 +50,9 @@ const props = defineProps<{ const remaining = ref(-1); -const total = computed(() => sum(props.poll.choices.map(x => x.votes))); +const total = computed(() => sum(props.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); -const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); +const isVoted = computed(() => !props.multiple && props.choices.some(c => c.isVoted)); const timer = computed(() => i18n.tsx._poll[ remaining.value >= 86400 ? 'remainingDays' : remaining.value >= 3600 ? 'remainingHours' : @@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ })); // 期限付きアンケート -if (props.poll.expiresAt) { +if (props.expiresAt) { const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); + remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000); if (remaining.value === 0) { showResult.value = true; } @@ -91,7 +93,7 @@ const vote = async (id) => { const { canceled } = await os.confirm({ type: 'question', - text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), + text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }), }); if (canceled) return; @@ -99,7 +101,7 @@ const vote = async (id) => { noteId: props.noteId, choice: id, }); - if (!showResult.value) showResult.value = !props.poll.multiple; + if (!showResult.value) showResult.value = !props.multiple; }; </script> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c4857b7f65..5114e98494 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -137,6 +137,7 @@ import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; const $i = ensureSignin(); @@ -883,12 +884,15 @@ async function post(ev?: MouseEvent) { } posting.value = true; - misskeyApi('notes/create', postData, token).then(() => { + misskeyApi('notes/create', postData, token).then((res) => { if (props.freezeAfterPosted) { posted.value = true; } else { clear(); } + + globalEvents.emit('notePosted', res.createdNote); + nextTick(() => { deleteDraft(); emit('posted'); diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 4b2e6910db..f36e68b687 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <slot name="label"></slot> </div> <div v-adaptive-border class="body"> + <slot name="prefix"></slot> <div ref="containerEl" class="container"> <div class="track"> <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> @@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only @touchstart="onMousedown" ></div> </div> + <slot name="suffix"></slot> </div> <div class="caption"> <slot name="caption"></slot> @@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) { $thumbWidth: 20px; > .body { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; padding: 7px 12px; background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; > .container { + flex: 1; position: relative; height: $thumbHeight; diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 951447f15a..9027ffd0ae 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -8,11 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only ref="buttonEl" v-ripple="canToggle" class="_button" - :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" + :class="[$style.root, { [$style.reacted]: myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" @contextmenu.prevent.stop="menu" > - <MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> + <MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -29,19 +29,21 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { useTooltip } from '@/use/use-tooltip.js'; import { $i } from '@/i.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; -import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.js'; import * as sound from '@/utility/sound.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { noteEvents } from '@/use/use-note-capture.js'; const props = defineProps<{ + noteId: Misskey.entities.Note['id']; reaction: string; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; count: number; isInitial: boolean; - note: Misskey.entities.Note; }>(); const mock = inject(DI.mock, false); @@ -56,14 +58,16 @@ const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); const canToggle = computed(() => { - return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); + // TODO + //return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); + return !props.reaction.match(/@\w/) && $i && emoji.value; }); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); async function toggleReaction() { if (!canToggle.value) return; - const oldReaction = props.note.myReaction; + const oldReaction = props.myReaction; if (oldReaction) { const confirm = await os.confirm({ type: 'warning', @@ -81,12 +85,23 @@ async function toggleReaction() { } misskeyApi('notes/reactions/delete', { - noteId: props.note.id, + noteId: props.noteId, }).then(() => { + noteEvents.emit(`unreacted:${props.noteId}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); if (oldReaction !== props.reaction) { misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: props.noteId, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.noteId}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); } }); @@ -108,12 +123,19 @@ async function toggleReaction() { } misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: props.noteId, reaction: props.reaction, + }).then(() => { + noteEvents.emit(`reacted:${props.noteId}`, { + userId: $i!.id, + reaction: props.reaction, + emoji: emoji.value, + }); }); - if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); - } + // TODO: 上位コンポーネントでやる + //if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + // claimAchievement('reactWithoutRead'); + //} } } @@ -157,7 +179,7 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { const reactions = await misskeyApiGet('notes/reactions', { - noteId: props.note.id, + noteId: props.noteId, type: props.reaction, limit: 10, _cacheKey_: props.count, diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index e8cf6c36db..725978179e 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -13,7 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass="$style.transition_x_move" tag="div" :class="$style.root" > - <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> + <XReaction + v-for="[reaction, count] in _reactions" + :key="reaction" + :reaction="reaction" + :reactionEmojis="props.reactionEmojis" + :count="count" + :isInitial="initialReactions.has(reaction)" + :noteId="props.noteId" + :myReaction="props.myReaction" + @reactionToggled="onMockToggleReaction" + /> <slot v-if="hasMoreReactions" name="more"/> </component> </template> @@ -27,7 +37,10 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ - note: Misskey.entities.Note; + noteId: Misskey.entities.Note['id']; + reactions: Misskey.entities.Note['reactions']; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; maxNumber?: number; }>(), { maxNumber: Infinity, @@ -39,33 +52,33 @@ const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; }>(); -const initialReactions = new Set(Object.keys(props.note.reactions)); +const initialReactions = new Set(Object.keys(props.reactions)); -const reactions = ref<[string, number][]>([]); +const _reactions = ref<[string, number][]>([]); const hasMoreReactions = ref(false); -if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { - reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) { + _reactions.value[props.myReaction] = props.reactions[props.myReaction]; } function onMockToggleReaction(emoji: string, count: number) { if (!mock) return; - const i = reactions.value.findIndex((item) => item[0] === emoji); + const i = _reactions.value.findIndex((item) => item[0] === emoji); if (i < 0) return; - emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1])); + emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1])); } -watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { +watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { let newReactions: [string, number][] = []; hasMoreReactions.value = Object.keys(newSource).length > maxNumber; - for (let i = 0; i < reactions.value.length; i++) { - const reaction = reactions.value[i][0]; + for (let i = 0; i < _reactions.value.length; i++) { + const reaction = _reactions.value[i][0]; if (reaction in newSource && newSource[reaction] !== 0) { - reactions.value[i][1] = newSource[reaction]; - newReactions.push(reactions.value[i]); + _reactions.value[i][1] = newSource[reaction]; + newReactions.push(_reactions.value[i]); } } @@ -79,11 +92,11 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe newReactions = newReactions.slice(0, props.maxNumber); - if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) { - newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); + if (props.myReaction && !newReactions.map(([x]) => x).includes(props.myReaction)) { + newReactions.push([props.myReaction, newSource[props.myReaction]]); } - reactions.value = newReactions; + _reactions.value = newReactions; }, { immediate: true, deep: true }); </script> diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index cb50df1743..abe6466971 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, ref, useTemplateRef } from 'vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -81,7 +81,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const name = computed(() => props.emoji.name); const host = computed(() => props.emoji.host); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 6888824437..fc7ba50fb3 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ref, toRefs } from 'vue'; +import { computed, ref, toRefs, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props); -const windowEl = ref<InstanceType<typeof MkModalWindow>>(); +const windowEl = useTemplateRef('windowEl'); const roles = ref<Misskey.entities.Role[]>([]); const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []); const fetching = ref(false); diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue new file mode 100644 index 0000000000..75b2d10100 --- /dev/null +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -0,0 +1,531 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> + <MkLoading v-if="paginator.fetching.value"/> + + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> + + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotes"/></slot> + </div> + + <div v-else ref="rootEl"> + <div v-if="paginator.queuedAheadItemsCount.value > 0" :class="$style.new"> + <div :class="$style.newBg1"></div> + <div :class="$style.newBg2"></div> + <button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button> + </div> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" + :class="$style.notes" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" + tag="div" + > + <template v-for="(note, i) in paginator.items.value" :key="note.id"> + <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> + <div :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + </div> + <div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id"> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + <div :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> + </div> + <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> + </template> + </component> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> + <MkLoading v-else :inline="true"/> + </button> + </div> +</component> +</template> + +<script lang="ts" setup> +import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import { getScrollContainer, scrollToTop } from '@@/js/scroll.js'; +import type { BasicTimelineType } from '@/timelines.js'; +import type { PagingCtx } from '@/use/use-pagination.js'; +import { usePagination } from '@/use/use-pagination.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import { useStream } from '@/stream.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import MkNote from '@/components/MkNote.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; + +const props = withDefaults(defineProps<{ + src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; + list?: string; + antenna?: string; + channel?: string; + role?: string; + sound?: boolean; + withRenotes?: boolean; + withReplies?: boolean; + withSensitive?: boolean; + onlyFiles?: boolean; +}>(), { + withRenotes: true, + withReplies: false, + withSensitive: true, + onlyFiles: false, +}); + +provide('inTimeline', true); +provide('tl_withSensitive', computed(() => props.withSensitive)); +provide('inChannel', computed(() => props.src === 'channel')); + +function isTop() { + if (scrollContainer == null) return true; + if (rootEl.value == null) return true; + const scrollTop = scrollContainer.scrollTop; + const tlTop = rootEl.value.offsetTop - scrollContainer.offsetTop; + return scrollTop <= tlTop; +} + +let scrollContainer: HTMLElement | null = null; + +function onScrollContainerScroll() { + if (isTop()) { + paginator.releaseQueue(); + } +} + +const rootEl = useTemplateRef('rootEl'); +watch(rootEl, (el) => { + if (el && scrollContainer == null) { + scrollContainer = getScrollContainer(el); + if (scrollContainer == null) return; + scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // ほんとはscrollendにしたいけどiosが非対応 + } +}, { immediate: true }); + +onUnmounted(() => { + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', onScrollContainerScroll); + } +}); + +type TimelineQueryType = { + antennaId?: string, + withRenotes?: boolean, + withReplies?: boolean, + withFiles?: boolean, + visibility?: string, + listId?: string, + channelId?: string, + roleId?: string +}; + +let adInsertionCounter = 0; + +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +if (!store.s.realtimeMode) { + // TODO: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす + useInterval(async () => { + paginator.fetchNewer({ + toQueue: !isTop(), + }); + }, POLLING_INTERVAL, { + immediate: false, + afterMounted: true, + }); + + useGlobalEvent('notePosted', (note) => { + paginator.fetchNewer({ + toQueue: !isTop(), + }); + }); +} + +useGlobalEvent('noteDeleted', (noteId) => { + paginator.removeItem(noteId); +}); + +function releaseQueue() { + paginator.releaseQueue(); + scrollToTop(rootEl.value); +} + +function prepend(note: Misskey.entities.Note) { + adInsertionCounter++; + + if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) { + note._shouldInsertAd_ = true; + } + + if (isTop()) { + paginator.prepend(note); + } else { + paginator.enqueue(note); + } + + if (props.sound) { + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + } +} + +let connection: Misskey.ChannelConnection | null = null; +let connection2: Misskey.ChannelConnection | null = null; +let paginationQuery: PagingCtx; + +const stream = store.s.realtimeMode ? useStream() : null; + +function connectChannel() { + if (props.src === 'antenna') { + if (props.antenna == null) return; + connection = stream.useChannel('antenna', { + antennaId: props.antenna, + }); + } else if (props.src === 'home') { + connection = stream.useChannel('homeTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }); + connection2 = stream.useChannel('main'); + } else if (props.src === 'local') { + connection = stream.useChannel('localTimeline', { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'social') { + connection = stream.useChannel('hybridTimeline', { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'global') { + connection = stream.useChannel('globalTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }); + } else if (props.src === 'mentions') { + connection = stream.useChannel('main'); + connection.on('mention', prepend); + } else if (props.src === 'directs') { + const onNote = note => { + if (note.visibility === 'specified') { + prepend(note); + } + }; + connection = stream.useChannel('main'); + connection.on('mention', onNote); + } else if (props.src === 'list') { + if (props.list == null) return; + connection = stream.useChannel('userList', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + listId: props.list, + }); + } else if (props.src === 'channel') { + if (props.channel == null) return; + connection = stream.useChannel('channel', { + channelId: props.channel, + }); + } else if (props.src === 'role') { + if (props.role == null) return; + connection = stream.useChannel('roleTimeline', { + roleId: props.role, + }); + } + if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); +} + +function disconnectChannel() { + if (connection) connection.dispose(); + if (connection2) connection2.dispose(); +} + +function updatePaginationQuery() { + let endpoint: keyof Misskey.Endpoints | null; + let query: TimelineQueryType | null; + + if (props.src === 'antenna') { + endpoint = 'antennas/notes'; + query = { + antennaId: props.antenna, + }; + } else if (props.src === 'home') { + endpoint = 'notes/timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'local') { + endpoint = 'notes/local-timeline'; + query = { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'social') { + endpoint = 'notes/hybrid-timeline'; + query = { + withRenotes: props.withRenotes, + withReplies: props.withReplies, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'global') { + endpoint = 'notes/global-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + }; + } else if (props.src === 'mentions') { + endpoint = 'notes/mentions'; + query = null; + } else if (props.src === 'directs') { + endpoint = 'notes/mentions'; + query = { + visibility: 'specified', + }; + } else if (props.src === 'list') { + endpoint = 'notes/user-list-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + listId: props.list, + }; + } else if (props.src === 'channel') { + endpoint = 'channels/timeline'; + query = { + channelId: props.channel, + }; + } else if (props.src === 'role') { + endpoint = 'roles/notes'; + query = { + roleId: props.role, + }; + } else { + throw new Error('Unrecognized timeline type: ' + props.src); + } + + paginationQuery = { + endpoint: endpoint, + limit: 10, + params: query, + }; +} + +function refreshEndpointAndChannel() { + if (store.s.realtimeMode) { + disconnectChannel(); + connectChannel(); + } + + updatePaginationQuery(); +} + +// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる +// IDが切り替わったら切り替え先のTLを表示させたい +watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); + +// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK +watch(() => props.withSensitive, reloadTimeline); + +// 初回表示用 +refreshEndpointAndChannel(); + +const paginator = usePagination({ + ctx: paginationQuery, + useShallowRef: true, +}); + +onUnmounted(() => { + disconnectChannel(); +}); + +function reloadTimeline() { + return new Promise<void>((res) => { + adInsertionCounter = 0; + + paginator.reload().then(() => { + res(); + }); + }); +} + +defineExpose({ + reloadTimeline, +}); +</script> + +<style lang="scss" module> +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.note, + .note { + /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + +.transition_x_leaveActive { + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); +} + +.transition_x_enterFrom { + opacity: 0; + transform: translateY(max(-64px, -100%)); +} + +@supports (interpolate-size: allow-keywords) { + .transition_x_leaveTo { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; +} + +.notes { + container-type: inline-size; + background: var(--MI_THEME-panel); +} + +.note { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.new { + --gapFill: 0.5px; // 上位ヘッダーの高さにフォントの関係などで少数が含まれると、レンダリングエンジンによっては隙間が表示されてしまうため、隙間を隠すために少しずらす + + position: sticky; + top: calc(var(--MI-stickyTop, 0px) - var(--gapFill)); + z-index: 1000; + width: 100%; + box-sizing: border-box; + padding: calc(10px + var(--gapFill)) 0 10px 0; +} + +/* 疑似progressive blur */ +.newBg1, .newBg2 { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.newBg1 { + height: 100%; + -webkit-backdrop-filter: var(--MI-blur, blur(2px)); + backdrop-filter: var(--MI-blur, blur(2px)); + mask-image: linear-gradient( /* 疑似Easing Linear Gradients */ + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 7.75%, + rgb(0 0 0 / 10.4%) 11.25%, + rgb(0 0 0 / 45%) 23.55%, + rgb(0 0 0 / 55%) 26.45%, + rgb(0 0 0 / 89.6%) 38.75%, + rgb(0 0 0 / 95.1%) 42.25%, + rgb(0 0 0 / 100%) 50% + ); +} + +.newBg2 { + height: 75%; + -webkit-backdrop-filter: var(--MI-blur, blur(4px)); + backdrop-filter: var(--MI-blur, blur(4px)); + mask-image: linear-gradient( /* 疑似Easing Linear Gradients */ + to top, + rgb(0 0 0 / 0%) 0%, + rgb(0 0 0 / 4.9%) 15.5%, + rgb(0 0 0 / 10.4%) 22.5%, + rgb(0 0 0 / 45%) 47.1%, + rgb(0 0 0 / 55%) 52.9%, + rgb(0 0 0 / 89.6%) 77.5%, + rgb(0 0 0 / 95.1%) 91.9%, + rgb(0 0 0 / 100%) 100% + ); +} + +.newButton { + position: relative; + display: block; + padding: 6px 12px; + border-radius: 999px; + width: max-content; + margin: auto; + background: var(--MI_THEME-accent); + color: var(--MI_THEME-fgOnAccent); + font-size: 90%; + + &:hover { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); + } + + &:active { + background: hsl(from var(--MI_THEME-accent) h s calc(l - 5)); + } +} + +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.ad { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); + border-bottom: solid 0.5px var(--MI_THEME-divider); + + &:empty { + display: none; + } +} + +.more { + display: block; + width: 100%; + box-sizing: border-box; + padding: 16px; + background: var(--MI_THEME-panel); +} +</style> diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue new file mode 100644 index 0000000000..931f6ae115 --- /dev/null +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -0,0 +1,199 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> + <MkLoading v-if="paginator.fetching.value"/> + + <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/> + + <div v-else-if="paginator.items.value.length === 0" key="_empty_"> + <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotifications"/></slot> + </div> + + <div v-else ref="rootEl"> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" + tag="div" + > + <div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item"> + <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/> + <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/> + </div> + </component> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> + <MkLoading v-else/> + </button> + </div> +</component> +</template> + +<script lang="ts" setup> +import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import type { notificationTypes } from '@@/js/const.js'; +import XNotification from '@/components/MkNotification.vue'; +import MkNote from '@/components/MkNote.vue'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import { usePagination } from '@/use/use-pagination.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; + +const props = defineProps<{ + excludeTypes?: typeof notificationTypes[number][]; +}>(); + +const rootEl = useTemplateRef('rootEl'); + +const paginator = usePagination({ + ctx: prefer.s.useGroupedNotifications ? { + endpoint: 'i/notifications-grouped' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), + } : { + endpoint: 'i/notifications' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), + }, +}); + +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +if (!store.s.realtimeMode) { + useInterval(async () => { + paginator.fetchNewer({ + toQueue: false, + }); + }, POLLING_INTERVAL, { + immediate: false, + afterMounted: true, + }); +} + +function onNotification(notification) { + const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; + if (isMuted || window.document.visibilityState === 'visible') { + if (store.s.realtimeMode) { + useStream().send('readNotification'); + } + } + + if (!isMuted) { + paginator.prepend(notification); + } +} + +function reload() { + return paginator.reload(); +} + +let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null; + +onMounted(() => { + if (store.s.realtimeMode) { + connection = useStream().useChannel('main'); + connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); + } +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); + +defineExpose({ + reload, +}); +</script> + +<style lang="scss" module> +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.content, + .content { + /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + +.transition_x_leaveActive { + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); +} + +.transition_x_enterFrom { + opacity: 0; + transform: translateY(max(-64px, -100%)); +} + +@supports (interpolate-size: allow-keywords) { + .transition_x_enterFrom { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; +} + +.notifications { + container-type: inline-size; + background: var(--MI_THEME-panel); +} + +.item { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.date { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.more { + display: block; + width: 100%; + box-sizing: border-box; + padding: 16px; + background: var(--MI_THEME-panel); + border-top: solid 0.5px var(--MI_THEME-divider); +} +</style> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue deleted file mode 100644 index 6a265aa836..0000000000 --- a/packages/frontend/src/components/MkTimeline.vue +++ /dev/null @@ -1,372 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> - <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)"> - <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> - - <template #default="{ items: notes }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" - :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]" - :enterActiveClass="$style.transition_x_enterActive" - :leaveActiveClass="$style.transition_x_leaveActive" - :enterFromClass="$style.transition_x_enterFrom" - :leaveToClass="$style.transition_x_leaveTo" - :moveClass="$style.transition_x_move" - tag="div" - > - <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> - <MkNote :class="$style.note" :note="note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> - </template> - </component> - </template> - </MkPagination> -</component> -</template> - -<script lang="ts" setup> -import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup } from 'vue'; -import * as Misskey from 'misskey-js'; -import type { BasicTimelineType } from '@/timelines.js'; -import type { Paging } from '@/components/MkPagination.vue'; -import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { useStream } from '@/stream.js'; -import * as sound from '@/utility/sound.js'; -import { $i } from '@/i.js'; -import { instance } from '@/instance.js'; -import { prefer } from '@/preferences.js'; -import MkNote from '@/components/MkNote.vue'; -import MkPagination from '@/components/MkPagination.vue'; -import { i18n } from '@/i18n.js'; - -const props = withDefaults(defineProps<{ - src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; - list?: string; - antenna?: string; - channel?: string; - role?: string; - sound?: boolean; - withRenotes?: boolean; - withReplies?: boolean; - withSensitive?: boolean; - onlyFiles?: boolean; -}>(), { - withRenotes: true, - withReplies: false, - withSensitive: true, - onlyFiles: false, -}); - -const emit = defineEmits<{ - (ev: 'note'): void; - (ev: 'queue', count: number): void; -}>(); - -provide('inTimeline', true); -provide('tl_withSensitive', computed(() => props.withSensitive)); -provide('inChannel', computed(() => props.src === 'channel')); - -type TimelineQueryType = { - antennaId?: string, - withRenotes?: boolean, - withReplies?: boolean, - withFiles?: boolean, - visibility?: string, - listId?: string, - channelId?: string, - roleId?: string -}; - -const pagingComponent = useTemplateRef('pagingComponent'); - -let tlNotesCount = 0; - -function prepend(note) { - if (pagingComponent.value == null) return; - - tlNotesCount++; - - if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) { - note._shouldInsertAd_ = true; - } - - pagingComponent.value.prepend(note); - - emit('note'); - - if (props.sound) { - sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); - } -} - -let connection: Misskey.ChannelConnection | null = null; -let connection2: Misskey.ChannelConnection | null = null; -let paginationQuery: Paging | null = null; -const noGap = !prefer.s.showGapBetweenNotesInTimeline; - -const stream = useStream(); - -function connectChannel() { - if (props.src === 'antenna') { - if (props.antenna == null) return; - connection = stream.useChannel('antenna', { - antennaId: props.antenna, - }); - } else if (props.src === 'home') { - connection = stream.useChannel('homeTimeline', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }); - connection2 = stream.useChannel('main'); - } else if (props.src === 'local') { - connection = stream.useChannel('localTimeline', { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'social') { - connection = stream.useChannel('hybridTimeline', { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'global') { - connection = stream.useChannel('globalTimeline', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }); - } else if (props.src === 'mentions') { - connection = stream.useChannel('main'); - connection.on('mention', prepend); - } else if (props.src === 'directs') { - const onNote = note => { - if (note.visibility === 'specified') { - prepend(note); - } - }; - connection = stream.useChannel('main'); - connection.on('mention', onNote); - } else if (props.src === 'list') { - if (props.list == null) return; - connection = stream.useChannel('userList', { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - listId: props.list, - }); - } else if (props.src === 'channel') { - if (props.channel == null) return; - connection = stream.useChannel('channel', { - channelId: props.channel, - }); - } else if (props.src === 'role') { - if (props.role == null) return; - connection = stream.useChannel('roleTimeline', { - roleId: props.role, - }); - } - if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); -} - -function disconnectChannel() { - if (connection) connection.dispose(); - if (connection2) connection2.dispose(); -} - -function updatePaginationQuery() { - let endpoint: keyof Misskey.Endpoints | null; - let query: TimelineQueryType | null; - - if (props.src === 'antenna') { - endpoint = 'antennas/notes'; - query = { - antennaId: props.antenna, - }; - } else if (props.src === 'home') { - endpoint = 'notes/timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'local') { - endpoint = 'notes/local-timeline'; - query = { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'social') { - endpoint = 'notes/hybrid-timeline'; - query = { - withRenotes: props.withRenotes, - withReplies: props.withReplies, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'global') { - endpoint = 'notes/global-timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - }; - } else if (props.src === 'mentions') { - endpoint = 'notes/mentions'; - query = null; - } else if (props.src === 'directs') { - endpoint = 'notes/mentions'; - query = { - visibility: 'specified', - }; - } else if (props.src === 'list') { - endpoint = 'notes/user-list-timeline'; - query = { - withRenotes: props.withRenotes, - withFiles: props.onlyFiles ? true : undefined, - listId: props.list, - }; - } else if (props.src === 'channel') { - endpoint = 'channels/timeline'; - query = { - channelId: props.channel, - }; - } else if (props.src === 'role') { - endpoint = 'roles/notes'; - query = { - roleId: props.role, - }; - } else { - endpoint = null; - query = null; - } - - if (endpoint && query) { - paginationQuery = { - endpoint: endpoint, - limit: 10, - params: query, - }; - } else { - paginationQuery = null; - } -} - -function refreshEndpointAndChannel() { - if (!prefer.s.disableStreamingTimeline) { - disconnectChannel(); - connectChannel(); - } - - updatePaginationQuery(); -} - -// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる -// IDが切り替わったら切り替え先のTLを表示させたい -watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); - -// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK -watch(() => props.withSensitive, reloadTimeline); - -// 初回表示用 -refreshEndpointAndChannel(); - -onUnmounted(() => { - disconnectChannel(); -}); - -function reloadTimeline() { - return new Promise<void>((res) => { - if (pagingComponent.value == null) return; - - tlNotesCount = 0; - - pagingComponent.value.reload().then(() => { - res(); - }); - }); -} - -defineExpose({ - reloadTimeline, -}); -</script> - -<style lang="scss" module> -.transition_x_move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); -} - -.transition_x_enterActive { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - - &.note, - .note { - /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ - content-visibility: visible !important; - } -} - -.transition_x_leaveActive { - transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); -} - -.transition_x_enterFrom { - opacity: 0; - transform: translateY(max(-64px, -100%)); -} - -@supports (interpolate-size: allow-keywords) { - .transition_x_leaveTo { - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 - height: 0; - } -} - -.transition_x_leaveTo { - opacity: 0; -} - -.reverse { - display: flex; - flex-direction: column-reverse; -} - -.root { - container-type: inline-size; - - &.noGap { - background: var(--MI_THEME-panel); - - .note { - border-bottom: solid 0.5px var(--MI_THEME-divider); - } - - .ad { - padding: 8px; - background-size: auto auto; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); - border-bottom: solid 0.5px var(--MI_THEME-divider); - } - } - - &:not(.noGap) { - background: var(--MI_THEME-bg); - - .note { - background: var(--MI_THEME-panel); - border-radius: var(--MI-radius); - } - } -} - -.ad:empty { - display: none; -} -</style> diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 59e1b096ae..95f53e7635 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -76,8 +76,6 @@ const onceReacted = ref<boolean>(false); function addReaction(emoji) { onceReacted.value = true; emit('reacted'); - exampleNote.reactions[emoji] = 1; - exampleNote.myReaction = emoji; doNotification(emoji); } diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index aaefa5036a..8ec48dcc3f 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -74,7 +74,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); +const dialog = useTemplateRef('dialog'); const title = ref(props.announcement ? props.announcement.title : ''); const text = ref(props.announcement ? props.announcement.text : ''); const icon = ref(props.announcement ? props.announcement.icon : 'info'); diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 90087cb000..03ffd7e470 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -16,13 +16,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - pagination: Paging; + pagination: PagingCtx; noGap?: boolean; extractor?: (item: any) => any; }>(), { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 67a06c70db..1c1247e3e8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -39,15 +39,15 @@ import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; -const pinnedUsers: Paging = { +const pinnedUsers: PagingCtx = { endpoint: 'pinned-users', noPaging: true, limit: 10, }; -const popularUsers: Paging = { +const popularUsers: PagingCtx = { endpoint: 'users', limit: 10, noPaging: true, diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 1a4d14a3f0..a809e9040d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> <div :class="$style.tlBody"> - <MkTimeline src="local"/> + <MkStreamingNotesTimeline src="local"/> </div> </div> <div :class="$style.panel"> @@ -58,7 +58,7 @@ import * as Misskey from 'misskey-js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index f80f037285..a175485a7e 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, toRefs, watch } from 'vue'; +import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue'; import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js'; import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; @@ -130,7 +130,7 @@ const bus = new GridEventEmitter(); */ const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries))); -const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); +const rootEl = useTemplateRef('rootEl'); /** * グリッドの最も上位にある状態。 */ diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue index 69a68b6f2c..e1faed904a 100644 --- a/packages/frontend/src/components/grid/MkHeaderCell.vue +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; -import { GridEventEmitter } from '@/components/grid/grid.js'; +import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue'; import type { Size } from '@/components/grid/grid.js'; import type { GridColumn } from '@/components/grid/column.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; const emit = defineEmits<{ (ev: 'operation:beginWidthChange', sender: GridColumn): void; @@ -50,8 +50,8 @@ const props = defineProps<{ const { column, bus } = toRefs(props); -const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>(); -const contentEl = ref<InstanceType<typeof HTMLDivElement>>(); +const rootEl = useTemplateRef('rootEl'); +const contentEl = useTemplateRef('contentEl'); const resizing = ref<boolean>(false); |