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 | |
| 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')
93 files changed, 2520 insertions, 2158 deletions
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 97f1c3d739..491e63d417 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -593,4 +593,42 @@ export class NoteEntityService implements OnModuleInit { relations: ['user'], }); } + + @bindThis + public async fetchDiffs(noteIds: MiNote['id'][]) { + if (noteIds.length === 0) return []; + + const notes = await this.notesRepository.find({ + where: { + id: In(noteIds), + }, + select: { + id: true, + userHost: true, + reactions: true, + reactionAndUserPairCache: true, + }, + }); + + const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null; + + const packings = notes.map(note => { + const bufferedReactions = bufferedReactionsMap?.get(note.id); + //const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/'))); + + const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {})); + + const reactionEmojiNames = Object.keys(reactions) + .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ + .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + + return this.customEmojiService.populateEmojis(reactionEmojiNames, note.userHost).then(reactionEmojis => ({ + id: note.id, + reactions, + reactionEmojis, + })); + }); + + return await Promise.all(packings); + } } diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index e5170aa2dc..bd466b3cad 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -323,6 +323,7 @@ export * as 'notes/replies' from './endpoints/notes/replies.js'; export * as 'notes/search' from './endpoints/notes/search.js'; export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js'; export * as 'notes/show' from './endpoints/notes/show.js'; +export * as 'notes/show-partial-bulk' from './endpoints/notes/show-partial-bulk.js'; export * as 'notes/state' from './endpoints/notes/state.js'; export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js'; export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts new file mode 100644 index 0000000000..87b368e17e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 }, + }, + required: ['noteIds'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.noteEntityService.fetchDiffs(ps.noteIds); + }); + } +} diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index fad6ce3825..ae4e0445db 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -79,39 +79,6 @@ export async function mainBoot() { } } - const stream = useStream(); - - let reloadDialogShowing = false; - stream.on('_disconnected_', async () => { - if (prefer.s.serverDisconnectedBehavior === 'reload') { - window.location.reload(); - } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await confirm({ - type: 'warning', - title: i18n.ts.disconnectedFromServer, - text: i18n.ts.reloadConfirm, - }); - reloadDialogShowing = false; - if (!canceled) { - window.location.reload(); - } - } - }); - - stream.on('emojiAdded', emojiData => { - addCustomEmoji(emojiData.emoji); - }); - - stream.on('emojiUpdated', emojiData => { - updateCustomEmojis(emojiData.emojis); - }); - - stream.on('emojiDeleted', emojiData => { - removeCustomEmojis(emojiData.emojis); - }); - launchPlugins(); try { @@ -169,8 +136,6 @@ export async function mainBoot() { } } - stream.on('announcementCreated', onAnnouncementCreated); - if ($i.isDeleted) { alert({ type: 'warning', @@ -348,50 +313,81 @@ export async function mainBoot() { } } - const main = markRaw(stream.useChannel('main', null, 'System')); + if (store.s.realtimeMode) { + const stream = useStream(); - // 自分の情報が更新されたとき - main.on('meUpdated', i => { - updateCurrentAccountPartial(i); - }); + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (prefer.s.serverDisconnectedBehavior === 'reload') { + window.location.reload(); + } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + window.location.reload(); + } + } + }); - main.on('readAllNotifications', () => { - updateCurrentAccountPartial({ - hasUnreadNotification: false, - unreadNotificationsCount: 0, + stream.on('emojiAdded', emojiData => { + addCustomEmoji(emojiData.emoji); }); - }); - main.on('unreadNotification', () => { - const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateCurrentAccountPartial({ - hasUnreadNotification: true, - unreadNotificationsCount, + stream.on('emojiUpdated', emojiData => { + updateCustomEmojis(emojiData.emojis); }); - }); - main.on('unreadAntenna', () => { - updateCurrentAccountPartial({ hasUnreadAntenna: true }); - sound.playMisskeySfx('antenna'); - }); + stream.on('emojiDeleted', emojiData => { + removeCustomEmojis(emojiData.emojis); + }); - main.on('newChatMessage', () => { - updateCurrentAccountPartial({ hasUnreadChatMessages: true }); - sound.playMisskeySfx('chatMessage'); - }); + stream.on('announcementCreated', onAnnouncementCreated); - main.on('readAllAnnouncements', () => { - updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); - }); + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateCurrentAccountPartial(i); + }); - // 個人宛てお知らせが発行されたとき - main.on('announcementCreated', onAnnouncementCreated); + main.on('readAllNotifications', () => { + updateCurrentAccountPartial({ + hasUnreadNotification: false, + unreadNotificationsCount: 0, + }); + }); - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - main.on('myTokenRegenerated', () => { - signout(); - }); + main.on('unreadNotification', () => { + const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; + updateCurrentAccountPartial({ + hasUnreadNotification: true, + unreadNotificationsCount, + }); + }); + + main.on('unreadAntenna', () => { + updateCurrentAccountPartial({ hasUnreadAntenna: true }); + sound.playMisskeySfx('antenna'); + }); + + main.on('newChatMessage', () => { + updateCurrentAccountPartial({ hasUnreadChatMessages: true }); + sound.playMisskeySfx('chatMessage'); + }); + + main.on('readAllAnnouncements', () => { + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); + }); + + // 個人宛てお知らせが発行されたとき + main.on('announcementCreated', onAnnouncementCreated); + } } // shortcut 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); diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index dfd3d4120c..26b1881d15 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -5,9 +5,24 @@ import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; +import { onBeforeUnmount } from 'vue'; -export const globalEvents = new EventEmitter<{ +type Events = { themeChanging: () => void; themeChanged: () => void; clientNotification: (notification: Misskey.entities.Notification) => void; -}>(); + notePosted: (note: Misskey.entities.Note) => void; + noteDeleted: (noteId: Misskey.entities.Note['id']) => void; +}; + +export const globalEvents = new EventEmitter<Events>(); + +export function useGlobalEvent<T extends keyof Events>( + event: T, + callback: Events[T], +): void { + globalEvents.on(event, callback); + onBeforeUnmount(() => { + globalEvents.off(event, callback); + }); +} diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index a232ced75e..20d44032df 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -12,7 +12,6 @@ import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { get, set } from '@/utility/idb-proxy.js'; import { store } from '@/store.js'; -import { useStream } from '@/stream.js'; import { deepClone } from '@/utility/clone.js'; import { deepMerge } from '@/utility/merge.js'; @@ -129,25 +128,6 @@ export class Pizzax<T extends StateDef> { if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; this.r[key].value = this.s[key] = value; }); - - if ($i) { - const connection = useStream().useChannel('main'); - - // streamingのuser storage updateイベントを監視して更新 - connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; - - this.r[key].value = this.s[key] = value; - - this.addIdbSetJob(async () => { - const cache = await get(this.registryCacheKeyName); - if (cache[key] !== value) { - cache[key] = value; - await set(this.registryCacheKeyName, cache); - } - }); - }); - } } private load(): Promise<void> { diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 97743995bf..dc3c906217 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -55,7 +55,7 @@ import { computed, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; @@ -81,7 +81,7 @@ const pagination = { state.value === 'notResponding' ? { notResponding: true } : {}), })), -} as Paging; +} as PagingCtx; function getStatus(instance) { if (instance.isSuspended) return 'Suspended'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 3dc5c2ef7e..14e8e600b0 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -87,7 +87,7 @@ const pagination = { }; function resolved(reportId) { - reports.value?.removeItem(reportId); + reports.value?.paginator.removeItem(reportId); } function closeTutorial() { diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 072175f3af..754bf74c2a 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, useTemplateRef } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -73,7 +73,7 @@ const pagingComponent = useTemplateRef('pagingComponent'); const type = ref('all'); const sort = ref('+createdAt'); -const pagination: Paging = { +const pagination: PagingCtx = { endpoint: 'admin/invite/list' as const, limit: 10, params: computed(() => ({ @@ -100,12 +100,12 @@ async function createWithOptions() { text: tickets.map(x => x.code).join('\n'), }); - tickets.forEach(ticket => pagingComponent.value?.prepend(ticket)); + tickets.forEach(ticket => pagingComponent.value?.paginator.prepend(ticket)); } function deleted(id: string) { if (pagingComponent.value) { - pagingComponent.value.items.delete(id); + pagingComponent.value.paginator.removeItem(id); } } diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 6eb3c04dde..56cf8876f0 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -121,7 +121,7 @@ async function addUser() { username: username, password: password, }).then(res => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index bb4730c606..2c671c6b34 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, useTemplateRef } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -71,7 +71,7 @@ const paginationPast = { }, }; -const paginationEl = ref<InstanceType<typeof MkPagination>>(); +const paginationEl = useTemplateRef('paginationEl'); const tab = ref('current'); @@ -86,10 +86,10 @@ async function read(target) { } if (!paginationEl.value) return; - paginationEl.value.updateItem(target.id, a => { - a.isRead = true; - return a; - }); + paginationEl.value.paginator.updateItem(target.id, a => ({ + ...a, + isRead: true, + })); misskeyApi('i/read-announcement', { announcementId: target.id }); updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 89ab1bf99a..7d2393dba5 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div ref="rootEl"> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlEl" :key="antennaId" - src="antenna" - :antenna="antennaId" - :sound="true" - @queue="queueUpdated" - /> - </div> + <div :class="$style.tl"> + <MkStreamingNotesTimeline + ref="tlEl" :key="antennaId" + src="antenna" + :antenna="antennaId" + :sound="true" + /> </div> </div> </PageWithHeader> @@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { scrollInContainer } from '@@/js/scroll.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; @@ -40,18 +35,8 @@ const props = defineProps<{ }>(); const antenna = ref<Misskey.entities.Antenna | null>(null); -const queue = ref(0); -const rootEl = useTemplateRef('rootEl'); const tlEl = useTemplateRef('tlEl'); -function queueUpdated(q) { - queue.value = q; -} - -function top() { - scrollInContainer(rootEl.value, { top: 0 }); -} - async function timetravel() { const { canceled, result: date } = await os.inputDate({ title: i18n.ts.date, @@ -94,25 +79,6 @@ definePage(() => ({ </script> <style lang="scss" module> -.new { - position: sticky; - top: calc(var(--MI-stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px) 0; - - &:first-child { - margin-top: calc(-0.675em - 8px - var(--MI-margin)); - } -} - -.newButton { - display: block; - margin: var(--MI-margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; -} - .tl { background: var(--MI_THEME-bg); border-radius: var(--MI-radius); diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index cb0e1666f8..ddc4e89ef1 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -86,7 +86,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : ''); const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : ''); const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : ''); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 1c411d2a2e..6eb390f743 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -37,10 +37,10 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> <MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> + <MkStreamingNotesTimeline :key="channelId" src="channel" :channel="channelId"/> </div> <div v-else-if="tab === 'featured'"> - <MkNotes :pagination="featuredPagination"/> + <MkNotesTimeline :pagination="featuredPagination"/> </div> <div v-else-if="tab === 'search'"> <div v-if="notesSearchAvailable" class="_gaps"> @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> </div> - <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> + <MkNotesTimeline v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> <div v-else> <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> @@ -73,9 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import { useInterval } from '@@/js/use-interval.js'; import type { PageHeaderItem } from '@/types/page-header.js'; import MkPostForm from '@/components/MkPostForm.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -83,7 +84,7 @@ import { $i, iAmModerator } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { deviceKind } from '@/utility/device-kind.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -118,6 +119,14 @@ const featuredPagination = computed(() => ({ }, })); +useInterval(() => { + if (channel.value == null) return; + miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, Date.now()); +}, 3000, { + immediate: true, + afterMounted: true, +}); + watch(() => props.channelId, async () => { channel.value = await misskeyApi('channels/show', { channelId: props.channelId, diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 68c5d6c270..dc043e2ce1 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <MkNotes :pagination="pagination" :detail="true"/> + <MkNotesTimeline :pagination="pagination" :detail="true"/> </div> </div> </PageWithHeader> @@ -34,7 +34,7 @@ import { computed, watch, provide, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 16a95c6753..46e494e6f6 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -115,7 +115,7 @@ const selectAll = () => { if (selectedEmojis.value.length > 0) { selectedEmojis.value = []; } else { - selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id); + selectedEmojis.value = emojisPaginationComponent.value?.paginator.items.value.map(item => item.id); } }; @@ -132,7 +132,7 @@ const add = async (ev: MouseEvent) => { }, { done: result => { if (result.created) { - emojisPaginationComponent.value?.prepend(result.created); + emojisPaginationComponent.value?.paginator.prepend(result.created); } }, closed: () => dispose(), @@ -145,12 +145,12 @@ const edit = (emoji) => { }, { done: result => { if (result.updated) { - emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({ + emojisPaginationComponent.value?.paginator.updateItem(result.updated.id, (oldEmoji) => ({ ...oldEmoji, ...result.updated, })); } else if (result.deleted) { - emojisPaginationComponent.value?.removeItem(emoji.id); + emojisPaginationComponent.value?.paginator.removeItem(emoji.id); } }, closed: () => dispose(), @@ -242,7 +242,7 @@ const setCategoryBulk = async () => { ids: selectedEmojis.value, category: result, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const setLicenseBulk = async () => { @@ -254,7 +254,7 @@ const setLicenseBulk = async () => { ids: selectedEmojis.value, license: result, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const addTagBulk = async () => { @@ -266,7 +266,7 @@ const addTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const removeTagBulk = async () => { @@ -278,7 +278,7 @@ const removeTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const setTagBulk = async () => { @@ -290,7 +290,7 @@ const setTagBulk = async () => { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const delBulk = async () => { @@ -302,7 +302,7 @@ const delBulk = async () => { await os.apiWithDialog('admin/emoji/delete-bulk', { ids: selectedEmojis.value, }); - emojisPaginationComponent.value?.reload(); + emojisPaginationComponent.value?.paginator.reload(); }; const headerActions = computed(() => [{ diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue index d7519896cc..95ac5082bb 100644 --- a/packages/frontend/src/pages/drive.file.notes.vue +++ b/packages/frontend/src/pages/drive.file.notes.vue @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </div> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkInfo from '@/components/MkInfo.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; const props = defineProps<{ fileId: string; @@ -23,7 +23,7 @@ const props = defineProps<{ const realFileId = computed(() => props.fileId); -const pagination = ref<Paging>({ +const pagination = ref<PagingCtx>({ endpoint: 'drive/files/attached-notes', limit: 10, params: { diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 9eb24aa70e..0479ed6f6c 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -103,7 +103,7 @@ const emit = defineEmits<{ (ev: 'closed'): void }>(); -const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const windowEl = useTemplateRef('windowEl'); const name = ref<string>(props.emoji ? props.emoji.name : ''); const category = ref<string>(props.emoji?.category ? props.emoji.category : ''); const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : ''); diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index a47e3efbc8..b8eb7eb8d5 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="notes">{{ i18n.ts.notes }}</option> <option value="polls">{{ i18n.ts.poll }}</option> </MkTab> - <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> - <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> + <MkNotesTimeline v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <MkNotesTimeline v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 9b4e3faaef..e98ae99a10 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { useTemplateRef, computed, ref } from 'vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; @@ -47,7 +47,7 @@ import { $i } from '@/i.js'; const paginationComponent = useTemplateRef('paginationComponent'); -const pagination = computed<Paging>(() => tab.value === 'list' ? { +const pagination = computed<PagingCtx>(() => tab.value === 'list' ? { endpoint: 'following/requests/list', limit: 10, } : { @@ -57,19 +57,19 @@ const pagination = computed<Paging>(() => tab.value === 'list' ? { function accept(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/accept', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } function reject(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/reject', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } function cancel(user: Misskey.entities.UserLite) { os.apiWithDialog('following/requests/cancel', { userId: user.id }).then(() => { - paginationComponent.value?.reload(); + paginationComponent.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 96a43f67e8..c25a5b36d8 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import type { ChartSrc } from '@/components/MkChart.vue'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; @@ -180,7 +180,7 @@ const usersPagination = { hostname: props.host, }, offsetMode: true, -} satisfies Paging; +} satisfies PagingCtx; if (iAmModerator) { watch(moderationNote, async () => { diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 406c08bcf2..4cc9021424 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -45,7 +45,7 @@ const currentInviteLimit = ref<null | number>(null); const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number; const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number; -const pagination: Paging = { +const pagination: PagingCtx = { endpoint: 'invite/list' as const, limit: 10, }; @@ -68,13 +68,13 @@ async function create() { text: ticket.code, }); - pagingComponent.value?.prepend(ticket); + pagingComponent.value?.paginator.prepend(ticket); update(); } function deleted(id: string) { if (pagingComponent.value) { - pagingComponent.value.items.delete(id); + pagingComponent.value.paginator.removeItem(id); } update(); } diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 9e427ecf35..4dafd87b80 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -73,15 +73,15 @@ async function create() { clipsCache.delete(); - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } function onClipCreated() { - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } function onClipDeleted() { - pagingComponent.value?.reload(); + pagingComponent.value?.paginator.reload(); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 0b76fb4725..06abe3d7fd 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; +import { computed, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; @@ -80,7 +80,7 @@ const props = defineProps<{ listId: string; }>(); -const paginationEl = ref<InstanceType<typeof MkPagination>>(); +const paginationEl = useTemplateRef('paginationEl'); const list = ref<Misskey.entities.UserList | null>(null); const isPublic = ref(false); const name = ref(''); @@ -109,7 +109,7 @@ function addUser() { listId: list.value.id, userId: user.id, }).then(() => { - paginationEl.value?.reload(); + paginationEl.value?.paginator.reload(); }); }); } @@ -125,7 +125,7 @@ async function removeUser(item, ev) { listId: list.value.id, userId: item.userId, }).then(() => { - paginationEl.value?.removeItem(item.id); + paginationEl.value?.paginator.removeItem(item.id); }); }, }], ev.currentTarget ?? ev.target); @@ -147,7 +147,7 @@ async function showMembershipMenu(item, ev) { userId: item.userId, withReplies, }).then(() => { - paginationEl.value!.updateItem(item.id, (old) => ({ + paginationEl.value!.paginator.updateItem(item.id, (old) => ({ ...old, withReplies, })); diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 0f1dbc4432..68a009f648 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -6,42 +6,40 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div> - <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> - <div v-if="note"> - <div v-if="showNext" class="_margin"> - <MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> - </div> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note"> + <div v-if="showNext" class="_margin"> + <MkNotesTimeline :pullToRefresh="false" class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> + </div> - <div class="_margin"> - <div v-if="!showNext" class="_buttons" :class="$style.loadNext"> - <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton> - <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton> - </div> - <div class="_margin _gaps_s"> - <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/> - </div> - <div v-if="clips && clips.length > 0" class="_margin"> - <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> - <div class="_gaps"> - <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> - </div> - </div> - <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> - <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton> - <MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton> + <div class="_margin"> + <div v-if="!showNext" class="_buttons" :class="$style.loadNext"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton> + </div> + <div class="_margin _gaps_s"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/> + </div> + <div v-if="clips && clips.length > 0" class="_margin"> + <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> + <div class="_gaps"> + <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> </div> </div> - - <div v-if="showPrev" class="_margin"> - <MkNotes class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/> + <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton> </div> </div> - <MkError v-else-if="error" @retry="fetchNote()"/> - <MkLoading v-else/> - </Transition> - </div> + + <div v-if="showPrev" class="_margin"> + <MkNotesTimeline :pullToRefresh="false" class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetchNote()"/> + <MkLoading v-else/> + </Transition> </div> </PageWithHeader> </template> @@ -50,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -80,26 +78,27 @@ const showPrev = ref<'user' | 'channel' | false>(false); const showNext = ref<'user' | 'channel' | false>(false); const error = ref(); -const prevUserPagination: Paging = { +const prevUserPagination: PagingCtx = { endpoint: 'users/notes', limit: 10, + baseId: props.noteId, + direction: 'older', params: computed(() => note.value ? ({ userId: note.value.userId, - untilId: note.value.id, }) : undefined), }; -const nextUserPagination: Paging = { - reversed: true, +const nextUserPagination: PagingCtx = { endpoint: 'users/notes', limit: 10, + baseId: props.noteId, + direction: 'newer', params: computed(() => note.value ? ({ userId: note.value.userId, - sinceId: note.value.id, }) : undefined), }; -const prevChannelPagination: Paging = { +const prevChannelPagination: PagingCtx = { endpoint: 'channels/timeline', limit: 10, params: computed(() => note.value ? ({ @@ -108,7 +107,7 @@ const prevChannelPagination: Paging = { }) : undefined), }; -const nextChannelPagination: Paging = { +const nextChannelPagination: PagingCtx = { reversed: true, endpoint: 'channels/timeline', limit: 10, diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 5cb71945dd..db911c1202 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <div v-if="tab === 'all'"> - <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> + <MkStreamingNotificationsTimeline :class="$style.notifications" :excludeTypes="excludeTypes"/> </div> <div v-else-if="tab === 'mentions'"> - <MkNotes :pagination="mentionsPagination"/> + <MkNotesTimeline :pagination="mentionsPagination"/> </div> <div v-else-if="tab === 'directNotes'"> - <MkNotes :pagination="directNotesPagination"/> + <MkNotesTimeline :pagination="directNotesPagination"/> </div> </div> </PageWithHeader> @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import { notificationTypes } from '@@/js/const.js'; -import XNotifications from '@/components/MkNotifications.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 9d01edb255..42639cde9e 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;"> - <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> + <MkStreamingNotesTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> <MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/> </div> </PageWithHeader> @@ -29,7 +29,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; const props = withDefaults(defineProps<{ roleId: string; diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 17cf272a36..35609912eb 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection v-if="notePagination"> <template #header>{{ i18n.ts.searchResult }}</template> - <MkNotes :key="`searchNotes:${key}`" :pagination="notePagination"/> + <MkNotesTimeline :key="`searchNotes:${key}`" :pagination="notePagination"/> </MkFoldableSection> </div> </template> @@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, shallowRef, toRef } from 'vue'; import type * as Misskey from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import { $i } from '@/i.js'; import { host as localHost } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; @@ -125,7 +125,7 @@ import { useRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkInput from '@/components/MkInput.vue'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -144,7 +144,7 @@ const props = withDefaults(defineProps<{ const router = useRouter(); const key = ref(0); -const notePagination = ref<Paging<'notes/search'>>(); +const notePagination = ref<PagingCtx<'notes/search'>>(); const searchQuery = ref(toRef(props, 'query').value); const hostInput = ref(toRef(props, 'host').value); diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 101de6a64f..fb4166fe36 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, toRef } from 'vue'; import type { Endpoints } from 'misskey-js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; import MkUserList from '@/components/MkUserList.vue'; import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{ const router = useRouter(); const key = ref(0); -const userPagination = ref<Paging<'users/search'>>(); +const userPagination = ref<PagingCtx<'users/search'>>(); const searchQuery = ref(toRef(props, 'query').value); const searchOrigin = ref(toRef(props, 'origin').value); diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 33c17e5d7f..ec45eb3487 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -59,7 +59,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; -const list = ref<InstanceType<typeof FormPagination>>(); +const list = useTemplateRef('list'); const pagination = { endpoint: 'i/apps' as const, @@ -72,7 +72,7 @@ const pagination = { function revoke(token) { misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => { - list.value?.reload(); + list.value?.paginator.reload(); }); } diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 4d718d21b4..c6732e7787 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -41,6 +41,26 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRadios> </SearchMarker> + <SearchMarker :keywords="['realtimemode']"> + <MkSwitch v-model="realtimeMode"> + <template #label><i class="ti ti-bolt"></i> <SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template> + </MkSwitch> + </SearchMarker> + + <MkDisableSection :disabled="realtimeMode"> + <SearchMarker :keywords="['polling', 'interval']"> + <MkPreferenceContainer k="pollingInterval"> + <MkRange v-model="pollingInterval" :min="1" :max="3" :step="1" easing :showTicks="true" :textConverter="(v) => v === 1 ? i18n.ts.low : v === 2 ? i18n.ts.middle : v === 3 ? i18n.ts.high : ''"> + <template #label><SearchLabel>{{ i18n.ts._settings.contentsUpdateFrequency }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description }}</SearchKeyword><br><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description2 }}</SearchKeyword></template> + <template #prefix><i class="ti ti-player-play"></i></template> + <template #suffix><i class="ti ti-player-track-next"></i></template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> + </MkDisableSection> + <div class="_gaps_s"> <SearchMarker :keywords="['titlebar', 'show']"> <MkPreferenceContainer k="showTitlebar"> @@ -148,22 +168,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['note', 'timeline', 'gap']"> - <MkPreferenceContainer k="showGapBetweenNotesInTimeline"> - <MkSwitch v-model="showGapBetweenNotesInTimeline"> - <template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> - - <SearchMarker :keywords="['disable', 'streaming', 'timeline']"> - <MkPreferenceContainer k="disableStreamingTimeline"> - <MkSwitch v-model="disableStreamingTimeline"> - <template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template> - </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> - <SearchMarker :keywords="['pinned', 'list']"> <MkFolder> <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> @@ -734,7 +738,7 @@ import MkRadios from '@/components/MkRadios.vue'; import MkRange from '@/components/MkRange.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; +import MkDisableSection from '@/components/MkDisableSection.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -757,8 +761,10 @@ const $i = ensureSignin(); const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); +const realtimeMode = computed(store.makeGetterSetter('realtimeMode')); const overridedDeviceKind = prefer.model('overridedDeviceKind'); +const pollingInterval = prefer.model('pollingInterval'); const showTitlebar = prefer.model('showTitlebar'); const keepCw = prefer.model('keepCw'); const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior'); @@ -777,7 +783,6 @@ const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel'); const numberOfPageCache = prefer.model('numberOfPageCache'); const enableInfiniteScroll = prefer.model('enableInfiniteScroll'); const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu'); -const disableStreamingTimeline = prefer.model('disableStreamingTimeline'); const useGroupedNotifications = prefer.model('useGroupedNotifications'); const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow'); const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia'); @@ -785,7 +790,6 @@ const confirmOnReact = prefer.model('confirmOnReact'); const defaultNoteVisibility = prefer.model('defaultNoteVisibility'); const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly'); const rememberNoteVisibility = prefer.model('rememberNoteVisibility'); -const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline'); const notificationPosition = prefer.model('notificationPosition'); const notificationStackAxis = prefer.model('notificationStackAxis'); const instanceTicker = prefer.model('instanceTicker'); @@ -843,13 +847,13 @@ watch(useSystemFont, () => { watch([ hemisphere, lang, + realtimeMode, + pollingInterval, enableInfiniteScroll, showNoteActionsOnlyHover, overridedDeviceKind, - disableStreamingTimeline, alwaysConfirmFollow, confirmWhenRevealingSensitiveMedia, - showGapBetweenNotesInTimeline, mediaListWithOneImageAppearance, reactionsDisplaySize, limitWidthOfReaction, diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index e1dffd4f2d..d1e5db5a5b 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <MkNotes ref="notes" class="" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" class="" :pagination="pagination"/> </div> <template v-if="$i" #footer> <div :class="$style.footer"> @@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; -import MkNotes from '@/components/MkNotes.vue'; +import { computed, ref, useTemplateRef } from 'vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; @@ -40,7 +40,8 @@ const pagination = { tag: props.tag, })), }; -const notes = ref<InstanceType<typeof MkNotes>>(); + +const tlComponent = useTemplateRef('tlComponent'); async function post() { store.set('postFormHashtags', props.tag); @@ -48,7 +49,7 @@ async function post() { await os.post(); store.set('postFormHashtags', ''); store.set('postFormWithHashtags', false); - notes.value?.pagingComponent?.reload(); + tlComponent.value?.reload(); } const headerActions = computed(() => [{ diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index efe2689579..453a48d1bc 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader ref="pageComponent" v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true"> +<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> {{ i18n.ts._timelineDescription[src] }} </MkInfo> <MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="_panel" fixed style="margin-bottom: var(--MI-margin);"/> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <MkTimeline + <MkStreamingNotesTimeline ref="tlComponent" :key="src + withRenotes + withReplies + onlyFiles + withSensitive" :class="$style.tl" @@ -22,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only :withSensitive="withSensitive" :onlyFiles="onlyFiles" :sound="true" - @queue="queueUpdated" /> </div> </PageWithHeader> @@ -33,7 +31,7 @@ import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { MenuItem } from '@/types/menu.js'; import type { BasicTimelineType } from '@/timelines.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; @@ -51,11 +49,9 @@ import { prefer } from '@/preferences.js'; provide('shouldOmitHeaderTitle', true); const tlComponent = useTemplateRef('tlComponent'); -const pageComponent = useTemplateRef('pageComponent'); type TimelinePageSrc = BasicTimelineType | `list:${string}`; -const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const src = computed<TimelinePageSrc>({ get: () => ($i ? store.r.tl.value.src : srcWhenNotSignin.value), @@ -110,18 +106,6 @@ const withSensitive = computed<boolean>({ set: (x) => saveTlFilter('withSensitive', x), }); -watch(src, () => { - queue.value = 0; -}); - -function queueUpdated(q: number): void { - queue.value = q; -} - -function top(): void { - if (pageComponent.value) pageComponent.value.scrollToTop(); -} - async function chooseList(ev: MouseEvent): Promise<void> { const lists = await userListsCache.fetch(); const items: MenuItem[] = [ diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index e05e35d533..f166495258 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div ref="rootEl"> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlEl" :key="listId" - src="list" - :list="listId" - :sound="true" - @queue="queueUpdated" - /> - </div> + <div :class="$style.tl"> + <MkStreamingNotesTimeline + ref="tlEl" :key="listId" + src="list" + :list="listId" + :sound="true" + /> </div> </div> </PageWithHeader> @@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { scrollInContainer } from '@@/js/scroll.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; @@ -39,9 +34,6 @@ const props = defineProps<{ }>(); const list = ref<Misskey.entities.UserList | null>(null); -const queue = ref(0); -const tlEl = useTemplateRef('tlEl'); -const rootEl = useTemplateRef('rootEl'); watch(() => props.listId, async () => { list.value = await misskeyApi('users/lists/show', { @@ -49,14 +41,6 @@ watch(() => props.listId, async () => { }); }, { immediate: true }); -function queueUpdated(q) { - queue.value = q; -} - -function top() { - scrollInContainer(rootEl.value, { top: 0 }); -} - function settings() { router.push(`/my/lists/${props.listId}`); } @@ -76,25 +60,6 @@ definePage(() => ({ </script> <style lang="scss" module> -.new { - position: sticky; - top: calc(var(--MI-stickyTop, 0px) + 16px); - z-index: 1000; - width: 100%; - margin: calc(-0.675em - 8px) 0; - - &:first-child { - margin-top: calc(-0.675em - 8px - var(--MI-margin)); - } -} - -.newButton { - display: block; - margin: var(--MI-margin) auto 0 auto; - padding: 8px 16px; - border-radius: 32px; -} - .tl { background: var(--MI_THEME-bg); border-radius: var(--MI-radius); diff --git a/packages/frontend/src/pages/user/files.vue b/packages/frontend/src/pages/user/files.vue index 91ebcad0b2..51ae809aac 100644 --- a/packages/frontend/src/pages/user/files.vue +++ b/packages/frontend/src/pages/user/files.vue @@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <div class="_spacer" style="--MI_SPACER-w: 1100px;"> - <div :class="$style.root"> - <MkPagination v-slot="{items}" :pagination="pagination"> - <div :class="$style.stream"> - <MkNoteMediaGrid v-for="note in items" :note="note" square/> - </div> - </MkPagination> - </div> +<div class="_spacer" style="--MI_SPACER-w: 1100px;"> + <div :class="$style.root"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div :class="$style.stream"> + <MkNoteMediaGrid v-for="note in items" :note="note" square/> + </div> + </MkPagination> </div> +</div> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 50bb1de24f..23f740ddd0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -4,158 +4,160 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }"> - <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> - <div class="main _gaps"> - <!-- TODO --> - <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> - <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> + <div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }"> + <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> + <div class="main _gaps"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> - <div class="profile _gaps"> - <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> - <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/> - <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> + <div class="profile _gaps"> + <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> + <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/> + <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> - <div :key="user.id" class="main _panel"> - <div class="banner-container" :style="style"> - <div ref="bannerEl" class="banner" :style="style"></div> - <div class="fade"></div> + <div :key="user.id" class="main _panel"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> + <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} + </button> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div class="actions"> + <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> + <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" indicator/> <div class="title"> - <MkUserName class="name" :user="user" :nowrap="true"/> + <MkUserName :user="user" :nowrap="false" class="name"/> <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> - <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> - <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} - </button> </div> </div> - <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> - <div class="actions"> - <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> - <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + <div v-if="user.followedMessage != null" class="followedMessage"> + <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> + <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> + <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div> + </MkFukidashi> </div> - </div> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="title"> - <MkUserName :user="user" :nowrap="false" class="name"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span> - <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> - <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <div v-if="user.roles.length > 0" class="roles"> + <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> + <MkA v-adaptive-bg :to="`/roles/${role.id}`"> + <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> + {{ role.name }} + </MkA> + </span> </div> - </div> - <div v-if="user.followedMessage != null" class="followedMessage"> - <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> - <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> - <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div> - </MkFukidashi> - </div> - <div v-if="user.roles.length > 0" class="roles"> - <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> - <MkA v-adaptive-bg :to="`/roles/${role.id}`"> - <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> - {{ role.name }} + <div v-if="iAmModerator" class="moderationNote"> + <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + <div v-else> + <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> + </div> + </div> + <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> + <div class="heading" v-text="i18n.ts.memo"/> + <textarea + ref="memoTextareaEl" + v-model="memoDraft" + rows="1" + @focus="isEditingMemo = true" + @blur="updateMemo" + @input="adjustMemoTextarea" + /> + </div> + <div class="description"> + <MkOmit> + <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </MkOmit> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/> + <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> + </dd> + </dl> + </div> + <div class="status"> + <MkA :to="userPage(user)"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> </MkA> - </span> - </div> - <div v-if="iAmModerator" class="moderationNote"> - <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> - <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> - <div v-else> - <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> </div> </div> - <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> - <div class="heading" v-text="i18n.ts.memo"/> - <textarea - ref="memoTextareaEl" - v-model="memoDraft" - rows="1" - @focus="isEditingMemo = true" - @blur="updateMemo" - @input="adjustMemoTextarea" - /> - </div> - <div class="description"> - <MkOmit> - <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/> - <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> - </MkOmit> - </div> - <div class="fields system"> - <dl v-if="user.location" class="field"> - <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> - <dd class="value">{{ user.location }}</dd> - </dl> - <dl v-if="user.birthday" class="field"> - <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd> - </dl> - <dl class="field"> - <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> - <dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd> - </dl> - </div> - <div v-if="user.fields.length > 0" class="fields"> - <dl v-for="(field, i) in user.fields" :key="i" class="field"> - <dt class="name"> - <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/> - </dt> - <dd class="value"> - <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/> - <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> - </dd> - </dl> + </div> + + <div class="contents _gaps"> + <div v-if="user.pinnedNotes.length > 0" class="_gaps"> + <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> </div> - <div class="status"> - <MkA :to="userPage(user)"> - <b>{{ number(user.notesCount) }}</b> - <span>{{ i18n.ts.notes }}</span> - </MkA> - <MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')"> - <b>{{ number(user.followingCount) }}</b> - <span>{{ i18n.ts.following }}</span> - </MkA> - <MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')"> - <b>{{ number(user.followersCount) }}</b> - <span>{{ i18n.ts.followers }}</span> - </MkA> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <MkLazy> + <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> + </MkLazy> + <MkLazy> + <XActivity :key="user.id" :user="user"/> + </MkLazy> + </template> + <div v-if="!disableNotes"> + <MkLazy> + <XTimeline :user="user"/> + </MkLazy> </div> </div> </div> - - <div class="contents _gaps"> - <div v-if="user.pinnedNotes.length > 0" class="_gaps"> - <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> - </div> - <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> - <template v-if="narrow"> - <MkLazy> - <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> - </MkLazy> - <MkLazy> - <XActivity :key="user.id" :user="user"/> - </MkLazy> - </template> - <div v-if="!disableNotes"> - <MkLazy> - <XTimeline :user="user"/> - </MkLazy> - </div> + <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> + <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> + <XActivity :key="user.id" :user="user"/> </div> </div> - <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> - <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> - <XActivity :key="user.id" :user="user"/> - </div> </div> -</div> +</component> </template> <script lang="ts" setup> @@ -185,6 +187,7 @@ import { useRouter } from '@/router.js'; import { getStaticImageUrl } from '@/utility/media-proxy.js'; import MkSparkle from '@/components/MkSparkle.vue'; import { prefer } from '@/preferences.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -207,7 +210,7 @@ const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed; - /** Test only; MkNotes currently causes problems in vitest */ + /** Test only; MkNotesTimeline currently causes problems in vitest */ disableNotes: boolean; }>(), { disableNotes: false, @@ -299,6 +302,10 @@ watch([props.user], () => { memoDraft.value = props.user.memo; }); +async function reload() { + // TODO +} + onMounted(() => { window.requestAnimationFrame(parallaxLoop); narrow.value = rootEl.value!.clientWidth < 1000; diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 49d015a530..d8eca07a42 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> <MkTab v-model="tab" :class="$style.tab"> <option value="featured">{{ i18n.ts.featured }}</option> - <option :value="null">{{ i18n.ts.notes }}</option> + <option value="notes">{{ i18n.ts.notes }}</option> <option value="all">{{ i18n.ts.all }}</option> <option value="files">{{ i18n.ts.withFiles }}</option> </MkTab> </template> - <MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/> + <MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :pullToRefresh="false" :class="$style.tl"/> </MkStickyContainer> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; @@ -28,7 +28,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string | null>('all'); +const tab = ref<string>('all'); const pagination = computed(() => tab.value === 'featured' ? { endpoint: 'users/featured-notes' as const, diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index d6e477d0ae..d4f36271ad 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -7,9 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions" :swipable="true"> <div v-if="user"> <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> - <div v-else-if="tab === 'notes'" class="_spacer" style="--MI_SPACER-w: 800px;"> - <XTimeline :user="user"/> - </div> + <XNotes v-else-if="tab === 'notes'" :user="user"/> <XFiles v-else-if="tab === 'files'" :user="user"/> <XActivity v-else-if="tab === 'activity'" :user="user"/> <XAchievements v-else-if="tab === 'achievements'" :user="user"/> @@ -37,7 +35,7 @@ import { $i } from '@/i.js'; import { serverContext, assertServerContext } from '@/server-context.js'; const XHome = defineAsyncComponent(() => import('./home.vue')); -const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); +const XNotes = defineAsyncComponent(() => import('./notes.vue')); const XFiles = defineAsyncComponent(() => import('./files.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); diff --git a/packages/frontend/src/pages/user/notes.vue b/packages/frontend/src/pages/user/notes.vue new file mode 100644 index 0000000000..c97177b6a5 --- /dev/null +++ b/packages/frontend/src/pages/user/notes.vue @@ -0,0 +1,67 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_spacer" style="--MI_SPACER-w: 800px;"> + <div :class="$style.root"> + <MkStickyContainer> + <template #header> + <MkTab v-model="tab" :class="$style.tab"> + <option value="featured">{{ i18n.ts.featured }}</option> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :class="$style.tl"/> + </MkStickyContainer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; +import MkTab from '@/components/MkTab.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + user: Misskey.entities.UserDetailed; +}>(); + +const tab = ref<string>('all'); + +const pagination = computed(() => tab.value === 'featured' ? { + endpoint: 'users/featured-notes' as const, + limit: 10, + params: { + userId: props.user.id, + }, +} : { + endpoint: 'users/notes' as const, + limit: 10, + params: { + userId: props.user.id, + withRenotes: tab.value === 'all', + withReplies: tab.value === 'all', + withChannelNotes: tab.value === 'all', + withFiles: tab.value === 'files', + }, +}); +</script> + +<style lang="scss" module> +.tab { + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.tl { + background: var(--MI_THEME-bg); + border-radius: var(--MI-radius); + overflow: clip; +} +</style> diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue index 680fe08c14..62a220d2f1 100644 --- a/packages/frontend/src/pages/welcome.timeline.note.vue +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -27,7 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> </div> <div v-if="note.reactionCount > 0" :class="$style.reactions"> - <MkReactionsViewer :note="note" :maxNumber="16"/> + <!-- TODO --> + <!--<MkReactionsViewer :note="note" :maxNumber="16"/>--> </div> </div> </div> diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index 414bb9c5aa..13ca07cdcc 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -93,7 +93,6 @@ export function migrateOldSettings() { prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel); prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll); prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu); - prefer.commit('showGapBetweenNotesInTimeline', store.s.showGapBetweenNotesInTimeline); prefer.commit('instanceTicker', store.s.instanceTicker); prefer.commit('emojiPickerScale', store.s.emojiPickerScale); prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth); @@ -115,7 +114,6 @@ export function migrateOldSettings() { prefer.commit('notificationStackAxis', store.s.notificationStackAxis); prefer.commit('enableCondensedLine', store.s.enableCondensedLine); prefer.commit('keepScreenOn', store.s.keepScreenOn); - prefer.commit('disableStreamingTimeline', store.s.disableStreamingTimeline); prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications); prefer.commit('dataSaver', store.s.dataSaver); prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 96f43bb2f6..af4423c6a4 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -214,9 +214,6 @@ export const PREF_DEF = { useReactionPickerForContextMenu: { default: false, }, - showGapBetweenNotesInTimeline: { - default: false, - }, instanceTicker: { default: 'remote' as 'none' | 'remote' | 'always', }, @@ -241,6 +238,12 @@ export const PREF_DEF = { numberOfPageCache: { default: 3, }, + pollingInterval: { + // 1 ... 低 + // 2 ... 中 + // 3 ... 高 + default: 2, + }, showNoteActionsOnlyHover: { default: false, }, @@ -277,9 +280,6 @@ export const PREF_DEF = { keepScreenOn: { default: false, }, - disableStreamingTimeline: { - default: false, - }, useGroupedNotifications: { default: true, }, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 5ff9c1c7fe..06c2f9149c 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -80,6 +80,10 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: false, }, + realtimeMode: { + where: 'device', + default: true, + }, recentlyUsedEmojis: { where: 'device', default: [] as string[], @@ -378,10 +382,6 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: false, }, - disableStreamingTimeline: { - where: 'device', - default: false, - }, useGroupedNotifications: { where: 'device', default: true, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 341f5cb621..30936fbb10 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -314,7 +314,6 @@ rt { max-width: 100%; &:disabled { - opacity: 0.5; cursor: default; } } diff --git a/packages/frontend/src/types/date-separated-list.ts b/packages/frontend/src/types/date-separated-list.ts deleted file mode 100644 index af685cff12..0000000000 --- a/packages/frontend/src/types/date-separated-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type MisskeyEntity = { - id: string; - createdAt: string; - _shouldInsertAd_?: boolean; - [x: string]: any; -}; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 5fe99e0d14..fcf9fb234d 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> - <XDrawerMenu/> + <XNavbar style="height: 100%;" :asDrawer="true" :showWidgetButton="false"/> </div> </Transition> @@ -112,7 +112,8 @@ import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import { store } from '@/store.js'; +import XNavbar from '@/ui/_common_/navbar.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); @@ -129,7 +130,9 @@ function onNotification(notification: Misskey.entities.Notification, isClient = if (window.document.visibilityState === 'visible') { if (!isClient && notification.type !== 'test') { // サーバーサイドのテスト通知の際は自動で既読をつけない(テストできないので) - useStream().send('readNotification'); + if (store.s.realtimeMode) { + useStream().send('readNotification'); + } } notifications.value.unshift(notification); @@ -146,11 +149,12 @@ function onNotification(notification: Misskey.entities.Notification, isClient = } if ($i) { - const connection = useStream().useChannel('main', null, 'UI'); - connection.on('notification', onNotification); + if (store.s.realtimeMode) { + const connection = useStream().useChannel('main'); + connection.on('notification', onNotification); + } globalEvents.on('clientNotification', notification => onNotification(notification, true)); - //#region Listen message from SW if ('serviceWorker' in navigator) { swInject(); } @@ -226,12 +230,6 @@ if ($i) { left: 0; z-index: 1001; height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); } .widgetsDrawerBg { diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue deleted file mode 100644 index 826e03751a..0000000000 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ /dev/null @@ -1,273 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="$style.root"> - <div :class="$style.top"> - <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> - <button class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> - </button> - </div> - <div :class="$style.middle"> - <MkA :class="$style.item" :activeClass="$style.active" to="/" exact> - <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> - </MkA> - <template v-for="item in prefer.r.menu.value"> - <div v-if="item === '-'" :class="$style.divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> - <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink"> - <span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span> - <i v-else class="_indicatorCircle"></i> - </span> - </component> - </template> - <div :class="$style.divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" :class="$style.item" :activeClass="$style.active" to="/admin"> - <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> - </MkA> - <button :class="$style.item" class="_button" @click="more"> - <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> - <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> - </button> - <MkA :class="$style.item" :activeClass="$style.active" to="/settings"> - <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> - </MkA> - </div> - <div :class="$style.bottom"> - <button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post"> - <i :class="$style.postIcon" class="ti ti-pencil ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span> - </button> - <button class="_button" :class="$style.account" @click="openAccountMenu"> - <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/> - </button> - </div> -</div> -</template> - -<script lang="ts" setup> -import { computed, defineAsyncComponent } from 'vue'; -import { openInstanceMenu } from './common.js'; -import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; -import { prefer } from '@/preferences.js'; -import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; -import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; -import { $i } from '@/i.js'; - -const otherMenuItemIndicated = computed(() => { - for (const def in navbarItemDef) { - if (prefer.r.menu.value.includes(def)) continue; - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - -function openAccountMenu(ev: MouseEvent) { - openAccountMenu_({ - withExtraOperation: true, - }, ev); -} - -function more() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, { - closed: () => dispose(), - }); -} -</script> - -<style lang="scss" module> -.root { - --nav-bg-transparent: color(from var(--MI_THEME-navBg) srgb r g b / 0.5); - - display: flex; - flex-direction: column; -} - -.top { - position: sticky; - top: 0; - z-index: 1; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); -} - -.banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-size: cover; - background-position: center center; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); -} - -.instance { - position: relative; - display: block; - text-align: center; - width: 100%; -} - -.instanceIcon { - display: inline-block; - width: 38px; - aspect-ratio: 1; - border-radius: 8px; -} - -.bottom { - position: sticky; - bottom: 0; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); -} - -.post { - position: relative; - display: block; - width: 100%; - height: 40px; - color: var(--MI_THEME-fgOnAccent); - font-weight: bold; - text-align: left; - - &::before { - content: ""; - display: block; - width: calc(100% - 38px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - } - - &:hover, &.active { - &::before { - background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); - } - } -} - -.postIcon { - position: relative; - margin-left: 30px; - margin-right: 8px; - width: 32px; -} - -.account { - position: relative; - display: flex; - align-items: center; - padding-left: 30px; - width: 100%; - text-align: left; - box-sizing: border-box; - margin-top: 16px; -} - -.avatar { - display: block; - flex-shrink: 0; - position: relative; - width: 32px; - aspect-ratio: 1; - margin-right: 8px; -} - -.acct { - display: block; - flex-shrink: 1; - padding-right: 8px; -} - -.middle { - flex: 1; -} - -.divider { - margin: 16px 16px; - border-top: solid 0.5px var(--MI_THEME-divider); -} - -.item { - position: relative; - display: block; - padding-left: 24px; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--MI_THEME-navFg); - - &:hover { - text-decoration: none; - color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17))); - } - - &.active { - color: var(--MI_THEME-navActive); - } - - &:hover, &.active { - &::before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--MI_THEME-accentedBg); - } - } -} - -.itemIcon { - position: relative; - width: 32px; - margin-right: 8px; -} - -.itemIndicator { - position: absolute; - top: 0; - left: 20px; - color: var(--MI_THEME-navIndicator); - font-size: 8px; - - &:has(.itemIndicateValueIcon) { - animation: none; - left: auto; - right: 20px; - } -} - -.itemText { - position: relative; - font-size: 0.9em; -} -</style> diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index ce8efa3324..7cfedc939f 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> + <button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> + <i class="ti ti-bolt ti-fw"></i> + </button> </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> @@ -50,6 +53,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="showWidgetButton" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> <i class="ti ti-apps ti-fw"></i> </button> + <button v-if="iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> + <i class="ti ti-bolt ti-fw"></i> + </button> <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }"> <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> @@ -76,16 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> <button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button> </div> - <div :class="$style.subButtonGapFill"></div> - <div :class="$style.subButtonGapFillDivider"></div> - <div :class="[$style.subButton, $style.toggleButton]"> - <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> - <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> - <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> - </g> - </svg> - <button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button> - </div> + <template v-if="!props.asDrawer"> + <div :class="$style.subButtonGapFill"></div> + <div :class="$style.subButtonGapFillDivider"></div> + <div :class="[$style.subButton, $style.toggleButton]"> + <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> + <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + <button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button> + </div> + </template> </div> </div> </template> @@ -108,15 +116,16 @@ const router = useRouter(); const props = defineProps<{ showWidgetButton?: boolean; + asDrawer?: boolean; }>(); const emit = defineEmits<{ (ev: 'widgetButtonClick'): void; }>(); -const forceIconOnly = ref(window.innerWidth <= 1279); +const forceIconOnly = ref(!props.asDrawer && window.innerWidth <= 1279); const iconOnly = computed(() => { - return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'); + return !props.asDrawer && (forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon')); }); const otherMenuItemIndicated = computed(() => { @@ -147,6 +156,20 @@ function toggleIconOnly() { } } +function toggleRealtimeMode(ev: MouseEvent) { + os.popupMenu([{ + type: 'label', + text: i18n.ts.realtimeMode, + }, { + text: store.s.realtimeMode ? i18n.ts.turnItOff : i18n.ts.turnItOn, + icon: store.s.realtimeMode ? 'ti ti-bolt-off' : 'ti ti-bolt', + action: () => { + store.set('realtimeMode', !store.s.realtimeMode); + window.location.reload(); + }, + }], ev.currentTarget ?? ev.target); +} + function openAccountMenu(ev: MouseEvent) { openAccountMenu_({ withExtraOperation: true, @@ -191,21 +214,108 @@ function menuEdit() { overscroll-behavior: contain; background: var(--MI_THEME-navBg); contain: strict; + + /* 画面が縦に長い、設置している項目数が少ないなどの環境においても確実にbottomを最下部に表示するため */ display: flex; flex-direction: column; - direction: rtl; // スクロールバーを左に表示したいため + + direction: rtl; /* スクロールバーを左に表示したいため */ } .top { + flex-shrink: 0; direction: ltr; + + /* 疑似progressive blur */ + &::before { + position: absolute; + z-index: -1; + inset: 0; + content: ""; + backdrop-filter: blur(8px); + mask-image: linear-gradient( + 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% + ); + } + + &::after { + position: absolute; + z-index: -1; + inset: 0; + bottom: 25%; + content: ""; + backdrop-filter: blur(16px); + mask-image: linear-gradient( + 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% + ); + } } .middle { + flex: 1; direction: ltr; } .bottom { + flex-shrink: 0; direction: ltr; + + /* 疑似progressive blur */ + &::before { + position: absolute; + z-index: -1; + inset: -30px 0 0 0; + content: ""; + backdrop-filter: blur(8px); + mask-image: linear-gradient( + to bottom, + 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% + ); + pointer-events: none; + } + + &::after { + position: absolute; + z-index: -1; + inset: 0; + top: 25%; + content: ""; + backdrop-filter: blur(16px); + mask-image: linear-gradient( + to bottom, + 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% + ); + } } .subButtons { @@ -290,29 +400,18 @@ function menuEdit() { } .top { + --top-height: 80px; + position: sticky; top: 0; z-index: 1; - padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); + display: flex; + height: var(--top-height); } .instance { position: relative; - display: block; - text-align: center; - width: 100%; - - &:focus-visible { - outline: none; - - > .instanceIcon { - outline: 2px solid var(--MI_THEME-focus); - outline-offset: 2px; - } - } + width: var(--top-height); } .instanceIcon { @@ -322,13 +421,20 @@ function menuEdit() { border-radius: 8px; } + .realtimeMode { + display: inline-block; + width: var(--top-height); + margin-left: auto; + + &.on { + color: var(--MI_THEME-accent); + } + } + .bottom { position: sticky; bottom: 0; padding-top: 20px; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .post { @@ -416,10 +522,6 @@ function menuEdit() { padding-right: 8px; } - .middle { - flex: 1; - } - .divider { margin: 16px 16px; border-top: solid 0.5px var(--MI_THEME-divider); @@ -520,9 +622,6 @@ function menuEdit() { top: 0; z-index: 1; padding: 20px 0; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .instance { @@ -551,9 +650,6 @@ function menuEdit() { position: sticky; bottom: 0; padding-top: 20px; - background: var(--nav-bg-transparent); - -webkit-backdrop-filter: var(--MI-blur, blur(8px)); - backdrop-filter: var(--MI-blur, blur(8px)); } .widget { @@ -564,6 +660,18 @@ function menuEdit() { text-align: center; } + .realtimeMode { + display: block; + position: relative; + width: 100%; + height: 52px; + text-align: center; + + &.on { + color: var(--MI_THEME-accent); + } + } + .post { display: block; position: relative; @@ -637,10 +745,6 @@ function menuEdit() { display: none; } - .middle { - flex: 1; - } - .divider { margin: 8px auto; width: calc(100% - 32px); @@ -650,7 +754,7 @@ function menuEdit() { .item { display: block; position: relative; - padding: 18px 0; + padding: 16px 0; width: 100%; text-align: center; diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index 5f7600881f..35508b7ce6 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -20,6 +20,7 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; const zIndex = os.claimZIndex('high'); @@ -37,11 +38,13 @@ function reload() { window.location.reload(); } -useStream().on('_disconnected_', onDisconnected); +if (store.s.realtimeMode) { + useStream().on('_disconnected_', onDisconnected); -onUnmounted(() => { - useStream().off('_disconnected_', onDisconnected); -}); + onUnmounted(() => { + useStream().off('_disconnected_', onDisconnected); + }); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 194b56c842..716f0ba995 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span> </template> - <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/> </XColumn> </template> @@ -21,13 +21,12 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { antennasCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -96,10 +95,6 @@ function editAntenna() { os.pageWindow('my/antennas/' + props.column.antennaId); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [ { icon: 'ti ti-pencil', diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index c2644da707..3439a2a56e 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="padding: 8px; text-align: center;"> <MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton> </div> - <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/> + <MkStreamingNotesTimeline ref="timeline" src="channel" :channel="column.channelId"/> </template> </XColumn> </template> @@ -26,14 +26,13 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { favoritedChannelsCache } from '@/cache.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -90,10 +89,6 @@ async function post() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [{ icon: 'ti ti-pencil', text: i18n.ts.selectChannel, diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index 772188d773..c8b174da09 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.direct }}</template> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; -import MkNotes from '@/components/MkNotes.vue'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { i18n } from '@/i18n.js'; defineProps<{ @@ -31,11 +31,11 @@ const pagination = { }, }; -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const tlComponent = useTemplateRef('tlComponent'); function reloadTimeline() { return new Promise<void>((res) => { - tlComponent.value?.pagingComponent?.reload().then(() => { + tlComponent.value?.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index a8f17feb23..5b7390b1b2 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span> </template> - <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/> </XColumn> </template> @@ -21,13 +21,12 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { userListsCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -102,10 +101,6 @@ function editList() { os.pageWindow('my/lists/' + props.column.listId); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [ { icon: 'ti ti-pencil', diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index ffd0307940..640e933f23 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -7,27 +7,27 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()"> <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.mentions }}</template> - <MkNotes ref="tlComponent" :pagination="pagination"/> + <MkNotesTimeline ref="tlComponent" :pagination="pagination"/> </XColumn> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; -import MkNotes from '@/components/MkNotes.vue'; -import { i18n } from '../../i18n.js'; +import { i18n } from '@/i18n.js'; +import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; defineProps<{ column: Column; isStacked: boolean; }>(); -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const tlComponent = useTemplateRef('tlComponent'); function reloadTimeline() { return new Promise<void>((res) => { - tlComponent.value?.pagingComponent?.reload().then(() => { + tlComponent.value?.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 8378dddfef..0e84ed3572 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="async () => { await notificationsComponent?.reload() }"> <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.notifications }}</template> - <XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/> + <MkStreamingNotificationsTimeline ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/> </XColumn> </template> @@ -16,7 +16,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue'; import XColumn from './column.vue'; import type { Column } from '@/deck.js'; import { updateColumn } from '@/deck.js'; -import XNotifications from '@/components/MkNotifications.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 468b3e49e0..ff00dfa6e0 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span> </template> - <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/> + <MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> </XColumn> </template> @@ -20,12 +20,11 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -68,10 +67,6 @@ async function setRole() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu: MenuItem[] = [{ icon: 'ti ti-pencil', text: i18n.ts.role, diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 6759135654..97208f1c6a 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> - <MkTimeline + <MkStreamingNotesTimeline v-else-if="column.tl" ref="timeline" :key="column.tl + withRenotes + withReplies + onlyFiles" @@ -26,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only :withReplies="withReplies" :withSensitive="withSensitive" :onlyFiles="onlyFiles" - @note="onNote" /> </XColumn> </template> @@ -38,12 +37,11 @@ import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; import type { SoundStore } from '@/preferences/def.js'; import { removeColumn, updateColumn } from '@/deck.js'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; @@ -117,10 +115,6 @@ async function setType() { }); } -function onNote() { - sound.playMisskeySfxFile(soundSetting.value); -} - const menu = computed<MenuItem[]>(() => { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts index 97aec4c1f0..2f33c25a0a 100644 --- a/packages/frontend/src/use/use-note-capture.ts +++ b/packages/frontend/src/use/use-note-capture.ts @@ -4,106 +4,166 @@ */ import { onUnmounted } from 'vue'; -import type { Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { EventEmitter } from 'eventemitter3'; +import type { Reactive, Ref } from 'vue'; import { useStream } from '@/stream.js'; import { $i } from '@/i.js'; +import { store } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; -export function useNoteCapture(props: { - rootEl: ShallowRef<HTMLElement | undefined>; - note: Ref<Misskey.entities.Note>; - pureNote: Ref<Misskey.entities.Note>; - isDeletedRef: Ref<boolean>; +export const noteEvents = new EventEmitter<{ + [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void; +}>(); + +const fetchEvent = new EventEmitter<{ + [id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>; +}>(); + +const pollingQueue = new Map<string, { + referenceCount: number; + lastAddedAt: number; +}>(); + +function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + if (pollingQueue.has(note.id)) { + const data = pollingQueue.get(note.id)!; + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount + 1, + lastAddedAt: Date.now(), + }); + } else { + pollingQueue.set(note.id, { + referenceCount: 1, + lastAddedAt: Date.now(), + }); + } +} + +function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + const data = pollingQueue.get(note.id); + if (data == null) return; + + if (data.referenceCount === 1) { + pollingQueue.delete(note.id); + } else { + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount - 1, + }); + } +} + +const CAPTURE_MAX = 30; +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; + +window.setInterval(() => { + const ids = [...pollingQueue.entries()] + .filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く + .map(([k, v]) => k) + .sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート + .slice(0, CAPTURE_MAX); + + if (ids.length === 0) return; + if (window.document.hidden) return; + + // まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない? + misskeyApi('notes/show-partial-bulk', { + noteIds: ids, + }).then((items) => { + for (const item of items) { + fetchEvent.emit(item.id, { + reactions: item.reactions, + reactionEmojis: item.reactionEmojis, + }); + } + }); +}, POLLING_INTERVAL); + +function pollingSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; + $note: ReactiveNoteData; }) { + const { note, $note } = props; + + function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void { + $note.reactions = data.reactions; + $note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0); + $note.reactionEmojis = data.reactionEmojis; + } + + pollingEnqueue(note); + fetchEvent.on(note.id, onFetched); + + onUnmounted(() => { + pollingDequeue(note); + fetchEvent.off(note.id, onFetched); + }); +} + +function realtimeSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; +}): void { const note = props.note; - const pureNote = props.pureNote; - const connection = $i ? useStream() : null; + const connection = useStream(); function onStreamNoteUpdated(noteData): void { const { type, id, body } = noteData; - if ((id !== note.value.id) && (id !== pureNote.value.id)) return; + if (id !== note.id) return; switch (type) { case 'reacted': { - const reaction = body.reaction; - - if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) { - note.value.reactionEmojis[body.emoji.name] = body.emoji.url; - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = currentCount + 1; - note.value.reactionCount += 1; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = reaction; - } + noteEvents.emit(`reacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'unreacted': { - const reaction = body.reaction; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = Math.max(0, currentCount - 1); - note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); - if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = null; - } + noteEvents.emit(`unreacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'pollVoted': { - const choice = body.choice; - - const choices = [...note.value.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...($i && (body.userId === $i.id) ? { - isVoted: true, - } : {}), - }; - - note.value.poll.choices = choices; + noteEvents.emit(`pollVoted:${id}`, { + userId: body.userId, + choice: body.choice, + }); break; } case 'deleted': { - props.isDeletedRef.value = true; + globalEvents.emit('noteDeleted', id); break; } } } function capture(withHandler = false): void { - if (connection) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); - if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); - if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); - } + connection.send('sr', { id: note.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } function decapture(withHandler = false): void { - if (connection) { - connection.send('un', { - id: note.value.id, - }); - if (pureNote.value.id !== note.value.id) { - connection.send('un', { - id: pureNote.value.id, - }); - } - if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); - } + connection.send('un', { id: note.id }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); } function onStreamConnected() { @@ -111,14 +171,113 @@ export function useNoteCapture(props: { } capture(true); - if (connection) { - connection.on('_connected_', onStreamConnected); - } + connection.on('_connected_', onStreamConnected); onUnmounted(() => { decapture(true); - if (connection) { - connection.off('_connected_', onStreamConnected); + connection.off('_connected_', onStreamConnected); + }); +} + +type ReactiveNoteData = Reactive<{ + reactions: Misskey.entities.Note['reactions']; + reactionCount: Misskey.entities.Note['reactionCount']; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; + pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices']; +}>; + +export function useNoteCapture(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; + parentNote: Misskey.entities.Note | null; + $note: ReactiveNoteData; +}) { + const { note, parentNote, $note } = props; + + noteEvents.on(`reacted:${note.id}`, onReacted); + noteEvents.on(`unreacted:${note.id}`, onUnreacted); + noteEvents.on(`pollVoted:${note.id}`, onPollVoted); + + let latestReactedKey: string | null = null; + let latestUnreactedKey: string | null = null; + let latestPollVotedKey: string | null = null; + + function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const newReactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newReactedKey === latestReactedKey) return; + latestReactedKey = newReactedKey; + + if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) { + $note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url; + } + + const currentCount = $note.reactions[ctx.reaction] || 0; + + $note.reactions[ctx.reaction] = currentCount + 1; + $note.reactionCount += 1; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = ctx.reaction; } + } + + function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newUnreactedKey === latestUnreactedKey) return; + latestUnreactedKey = newUnreactedKey; + + const currentCount = $note.reactions[ctx.reaction] || 0; + + $note.reactions[ctx.reaction] = Math.max(0, currentCount - 1); + $note.reactionCount = Math.max(0, $note.reactionCount - 1); + if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction]; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = null; + } + } + + function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void { + const newPollVotedKey = `${ctx.userId}:${ctx.choice}`; + if (newPollVotedKey === latestPollVotedKey) return; + latestPollVotedKey = newPollVotedKey; + + const choices = [...$note.pollChoices]; + choices[ctx.choice] = { + ...choices[ctx.choice], + votes: choices[ctx.choice].votes + 1, + ...($i && (ctx.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + $note.pollChoices = choices; + } + + onUnmounted(() => { + noteEvents.off(`reacted:${note.id}`, onReacted); + noteEvents.off(`unreacted:${note.id}`, onUnreacted); + noteEvents.off(`pollVoted:${note.id}`, onPollVoted); }); + + // 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない + // ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する + // TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない + if (parentNote == null) { + if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない + return; + } + } else { + if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない + return; + } + } + + if ($i && store.s.realtimeMode) { + realtimeSubscribe(props); + } else { + pollingSubscribe(props); + } } diff --git a/packages/frontend/src/use/use-pagination.ts b/packages/frontend/src/use/use-pagination.ts new file mode 100644 index 0000000000..f1042985bf --- /dev/null +++ b/packages/frontend/src/use/use-pagination.ts @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const MAX_ITEMS = 30; +const MAX_QUEUE_ITEMS = 100; +const FIRST_FETCH_LIMIT = 15; +const SECOND_FETCH_LIMIT = 30; + +export type MisskeyEntity = { + id: string; + createdAt: string; + _shouldInsertAd_?: boolean; + [x: string]: any; +}; + +export type PagingCtx<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; + + offsetMode?: boolean; + + baseId?: MisskeyEntity['id']; + direction?: 'newer' | 'older'; +}; + +export function usePagination<T extends MisskeyEntity>(props: { + ctx: PagingCtx; + useShallowRef?: boolean; +}) { + const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]); + let aheadQueue: T[] = []; + const queuedAheadItemsCount = ref(0); + const fetching = ref(true); + const fetchingOlder = ref(false); + const canFetchOlder = ref(false); + const error = ref(false); + + // パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) + watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true }); + + function getNewestId(): string | null | undefined { + // 様々な要因により並び順は保証されないのでソートが必要 + if (aheadQueue.length > 0) { + return aheadQueue.map(x => x.id).sort().at(-1); + } + return items.value.map(x => x.id).sort().at(-1); + } + + function getOldestId(): string | null | undefined { + // 様々な要因により並び順は保証されないのでソートが必要 + return items.value.map(x => x.id).sort().at(0); + } + + async function init(): Promise<void> { + items.value = []; + aheadQueue = []; + queuedAheadItemsCount.value = 0; + fetching.value = true; + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: props.ctx.limit ?? FIRST_FETCH_LIMIT, + allowPartial: true, + ...(props.ctx.baseId && props.ctx.direction === 'newer' ? { + sinceId: props.ctx.baseId, + } : props.ctx.baseId && props.ctx.direction === 'older' ? { + untilId: props.ctx.baseId, + } : {}), + }).then(res => { + // 逆順で返ってくるので + if (props.ctx.baseId && props.ctx.direction === 'newer') { + res.reverse(); + } + + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 3) item._shouldInsertAd_ = true; + } + + if (res.length === 0 || props.ctx.noPaging) { + pushItems(res); + canFetchOlder.value = false; + } else { + pushItems(res); + canFetchOlder.value = true; + } + + error.value = false; + fetching.value = false; + }, err => { + error.value = true; + fetching.value = false; + }); + } + + function reload(): Promise<void> { + return init(); + } + + async function fetchOlder(): Promise<void> { + if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return; + fetchingOlder.value = true; + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.ctx.offsetMode ? { + offset: items.value.length, + } : { + untilId: getOldestId(), + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 10) item._shouldInsertAd_ = true; + } + + if (res.length === 0) { + canFetchOlder.value = false; + fetchingOlder.value = false; + } else { + pushItems(res); + canFetchOlder.value = true; + fetchingOlder.value = false; + } + }, err => { + fetchingOlder.value = false; + }); + } + + async function fetchNewer(options: { + toQueue?: boolean; + } = {}): Promise<void> { + const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; + await misskeyApi<T[]>(props.ctx.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.ctx.offsetMode ? { + offset: items.value.length, + } : { + sinceId: getNewestId(), + }), + }).then(res => { + if (res.length === 0) return; // これやらないと余計なre-renderが走る + + if (options.toQueue) { + aheadQueue.unshift(...res.toReversed()); + if (aheadQueue.length > MAX_QUEUE_ITEMS) { + aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS); + } + queuedAheadItemsCount.value = aheadQueue.length; + } else { + unshiftItems(res.toReversed()); + } + }); + } + + function trim(trigger = true) { + if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true; + items.value = items.value.slice(0, MAX_ITEMS); + if (props.useShallowRef && trigger) triggerRef(items); + } + + function unshiftItems(newItems: T[]) { + if (newItems.length === 0) return; // これやらないと余計なre-renderが走る + items.value.unshift(...newItems.filter(x => !items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため + trim(false); + if (props.useShallowRef) triggerRef(items); + } + + function pushItems(oldItems: T[]) { + if (oldItems.length === 0) return; // これやらないと余計なre-renderが走る + items.value.push(...oldItems); + if (props.useShallowRef) triggerRef(items); + } + + function prepend(item: T) { + if (items.value.some(x => x.id === item.id)) return; + items.value.unshift(item); + trim(false); + if (props.useShallowRef) triggerRef(items); + } + + function enqueue(item: T) { + aheadQueue.unshift(item); + if (aheadQueue.length > MAX_QUEUE_ITEMS) { + aheadQueue.pop(); + } + queuedAheadItemsCount.value = aheadQueue.length; + } + + function releaseQueue() { + if (aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る + unshiftItems(aheadQueue); + aheadQueue = []; + queuedAheadItemsCount.value = 0; + } + + function removeItem(id: string) { + // TODO: queueからも消す + + const index = items.value.findIndex(x => x.id === id); + if (index !== -1) { + items.value.splice(index, 1); + if (props.useShallowRef) triggerRef(items); + } + } + + function updateItem(id: string, updator: (item: T) => T) { + // TODO: queueのも更新 + + const index = items.value.findIndex(x => x.id === id); + if (index !== -1) { + const item = items.value[index]!; + items.value[index] = updator(item); + if (props.useShallowRef) triggerRef(items); + } + } + + onMounted(() => { + init(); + }); + + return { + items: items as DeepReadonly<ShallowRef<T[]>>, + queuedAheadItemsCount, + fetching, + fetchingOlder, + canFetchOlder, + init, + reload, + fetchOlder, + fetchNewer, + unshiftItems, + prepend, + trim, + removeItem, + updateItem, + enqueue, + releaseQueue, + error, + }; +} diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index dd8bdf43d7..dc813cb78e 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -25,10 +25,10 @@ import { getAppearNote } from '@/utility/get-appear-note.js'; import { genEmbedCode } from '@/utility/get-embed-code.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; +import { globalEvents } from '@/events.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; - isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { function getClipName(clip: Misskey.entities.Clip) { @@ -68,7 +68,6 @@ export async function getNoteClipMenu(props: { } })); }); - if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') { os.alert({ @@ -178,7 +177,6 @@ export function getNoteMenu(props: { note: Misskey.entities.Note; translation: Ref<Misskey.entities.NotesTranslateResponse | null>; translating: Ref<boolean>; - isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { const appearNote = getAppearNote(props.note); @@ -194,6 +192,8 @@ export function getNoteMenu(props: { misskeyApi('notes/delete', { noteId: appearNote.id, + }).then(() => { + globalEvents.emit('noteDeleted', appearNote.id); }); if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { @@ -211,6 +211,8 @@ export function getNoteMenu(props: { misskeyApi('notes/delete', { noteId: appearNote.id, + }).then(() => { + globalEvents.emit('noteDeleted', appearNote.id); }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); @@ -251,7 +253,6 @@ export function getNoteMenu(props: { async function unclip(): Promise<void> { if (!props.currentClip) return; os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); - props.isDeleted.value = true; } async function promote(): Promise<void> { @@ -569,8 +570,9 @@ export function getRenoteMenu(props: { misskeyApi('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, - }).then(() => { + }).then((res) => { os.toast(i18n.ts.renoted); + globalEvents.emit('notePosted', res.createdNote); }); } }, @@ -617,8 +619,9 @@ export function getRenoteMenu(props: { localOnly, visibility, renoteId: appearNote.id, - }).then(() => { + }).then((res) => { os.toast(i18n.ts.renoted); + globalEvents.emit('notePosted', res.createdNote); }); } }, @@ -658,8 +661,9 @@ export function getRenoteMenu(props: { misskeyApi('notes/create', { renoteId: appearNote.id, channelId: channel.id, - }).then(() => { + }).then((res) => { os.toast(i18n.tsx.renotedToX({ name: channel.name })); + globalEvents.emit('notePosted', res.createdNote); }); } }, diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index e1bc9790b9..1071a80962 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -25,6 +25,37 @@ export type DateSeparetedTimelineItem<T> = { nextText: string; }; +// TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい +export function isSeparatorNeeded( + prev: string | null, + next: string | null, +) { + if (prev == null || next == null) return false; + const prevDate = new Date(prev); + const nextDate = new Date(next); + return ( + prevDate.getFullYear() !== nextDate.getFullYear() || + prevDate.getMonth() !== nextDate.getMonth() || + prevDate.getDate() !== nextDate.getDate() + ); +} + +// TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい +export function getSeparatorInfo( + prev: string | null, + next: string | null, +) { + if (prev == null || next == null) return null; + const prevDate = new Date(prev); + const nextDate = new Date(next); + return { + prevDate, + prevText: getDateText(prevDate), + nextDate, + nextText: getDateText(nextDate), + }; +} + export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { return computed<DateSeparetedTimelineItem<T>[]>(() => { const tl: DateSeparetedTimelineItem<T>[] = []; diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index c5e1324ef5..b21a82179e 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> <div> - <XNotifications :excludeTypes="widgetProps.excludeTypes"/> + <MkStreamingNotificationsTimeline :excludeTypes="widgetProps.excludeTypes"/> </div> </MkContainer> </template> @@ -21,7 +21,7 @@ import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import XNotifications from '@/components/MkNotifications.vue'; +import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 47dec05303..9cbeb9cf2e 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> <div v-else> - <MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> + <MkStreamingNotesTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> </div> </MkContainer> </template> @@ -38,7 +38,7 @@ import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import MkTimeline from '@/components/MkTimeline.vue'; +import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { i18n } from '@/i18n.js'; import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import type { MenuItem } from '@/types/menu.js'; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 79813d3f82..f085240f84 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1962,6 +1962,8 @@ declare namespace entities { NotesSearchByTagResponse, NotesShowRequest, NotesShowResponse, + NotesShowPartialBulkRequest, + NotesShowPartialBulkResponse, NotesStateRequest, NotesStateResponse, NotesThreadMutingCreateRequest, @@ -3059,6 +3061,12 @@ type NotesSearchRequest = operations['notes___search']['requestBody']['content'] type NotesSearchResponse = operations['notes___search']['responses']['200']['content']['application/json']; // @public (undocumented) +type NotesShowPartialBulkRequest = operations['notes___show-partial-bulk']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesShowPartialBulkResponse = operations['notes___show-partial-bulk']['responses']['200']['content']['application/json']; + +// @public (undocumented) type NotesShowRequest = operations['notes___show']['requestBody']['content']['application/json']; // @public (undocumented) diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index b607c93e1e..12b51a4ac0 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3761,6 +3761,17 @@ declare module '../api.js' { /** * No description provided. * + * **Credential required**: *No* + */ + request<E extends 'notes/show-partial-bulk', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * * **Credential required**: *Yes* / **Permission**: *read:account* */ request<E extends 'notes/state', P extends Endpoints[E]['req']>( diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 56224cdbaf..13adaa9efd 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -512,6 +512,8 @@ import type { NotesSearchByTagResponse, NotesShowRequest, NotesShowResponse, + NotesShowPartialBulkRequest, + NotesShowPartialBulkResponse, NotesStateRequest, NotesStateResponse, NotesThreadMutingCreateRequest, @@ -971,6 +973,7 @@ export type Endpoints = { 'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse }; 'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse }; 'notes/show': { req: NotesShowRequest; res: NotesShowResponse }; + 'notes/show-partial-bulk': { req: NotesShowPartialBulkRequest; res: NotesShowPartialBulkResponse }; 'notes/state': { req: NotesStateRequest; res: NotesStateResponse }; 'notes/thread-muting/create': { req: NotesThreadMutingCreateRequest; res: EmptyResponse }; 'notes/thread-muting/delete': { req: NotesThreadMutingDeleteRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index b5370e99fa..2030e8ae5d 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -515,6 +515,8 @@ export type NotesSearchByTagRequest = operations['notes___search-by-tag']['reque export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json']; export type NotesShowRequest = operations['notes___show']['requestBody']['content']['application/json']; export type NotesShowResponse = operations['notes___show']['responses']['200']['content']['application/json']; +export type NotesShowPartialBulkRequest = operations['notes___show-partial-bulk']['requestBody']['content']['application/json']; +export type NotesShowPartialBulkResponse = operations['notes___show-partial-bulk']['responses']['200']['content']['application/json']; export type NotesStateRequest = operations['notes___state']['requestBody']['content']['application/json']; export type NotesStateResponse = operations['notes___state']['responses']['200']['content']['application/json']; export type NotesThreadMutingCreateRequest = operations['notes___thread-muting___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c54cc571d2..6db66477f7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3247,6 +3247,15 @@ export type paths = { */ post: operations['notes___show']; }; + '/notes/show-partial-bulk': { + /** + * notes/show-partial-bulk + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['notes___show-partial-bulk']; + }; '/notes/state': { /** * notes/state @@ -25739,6 +25748,59 @@ export type operations = { }; }; /** + * notes/show-partial-bulk + * @description No description provided. + * + * **Credential required**: *No* + */ + 'notes___show-partial-bulk': { + requestBody: { + content: { + 'application/json': { + noteIds: string[]; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': Record<string, never>[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** * notes/state * @description No description provided. * |