diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-05-09 17:40:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-09 17:40:08 +0900 |
| commit | 8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 (patch) | |
| tree | ae0d3573bd5a3175bc6174d33129dc64205a1436 /packages/frontend/src/components/MkStreamingNotesTimeline.vue | |
| parent | refactor (diff) | |
| download | misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.tar.gz misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.tar.bz2 misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.zip | |
Feat: No websocket mode (#15851)
* wip
* wip
* wip
* wip
* Update MkTimeline.vue
* wip
* wip
* wip
* Update MkTimeline.vue
* Update use-pagination.ts
* wip
* wip
* Update MkTimeline.vue
* Update MkTimeline.vue
* wip
* wip
* Update MkTimeline.vue
* Update MkTimeline.vue
* Update MkTimeline.vue
* wip
* Update use-pagination.ts
* wip
* Update use-pagination.ts
* Update MkNotifications.vue
* Update MkNotifications.vue
* wip
* wip
* wip
* Update use-note-capture.ts
* Update use-note-capture.ts
* Update use-note-capture.ts
* wip
* wip
* wip
* wip
* Update MkNoteDetailed.vue
* wip
* wip
* Update MkTimeline.vue
* wip
* fix
* Update MkTimeline.vue
* wip
* test
* Revert "test"
This reverts commit 3375619396c54dcda5e564eb1da444c2391208c9.
* Update use-pagination.ts
* test
* Revert "test"
This reverts commit 42c53c830e28485d2fb49061fa7cdeee31bc6a22.
* test
* Revert "test"
This reverts commit c4f8cda4aa1cec9d1eb97557145f3ad3d2d0e469.
* Update style.scss
* Update MkTimeline.vue
* Update MkTimeline.vue
* Update MkTimeline.vue
* ✌️
* Update MkTimeline.vue
* wip
* wip
* test
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkTimeline.vue
* wip
* tweak navbar
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update home.vue
* wip
* refactor
* wip
* wip
* Update note.vue
* Update navbar.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* wip
* Update MkStreamingNotificationsTimeline.vue
* Update use-pagination.ts
* wip
* improve perf
* wip
* Update MkNotesTimeline.vue
* wip
* megre
* Update use-pagination.ts
* Update use-pagination.ts
* Update MkStreamingNotesTimeline.vue
* Update use-pagination.ts
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
Diffstat (limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')
| -rw-r--r-- | packages/frontend/src/components/MkStreamingNotesTimeline.vue | 531 |
1 files changed, 531 insertions, 0 deletions
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> |