diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-11-23 10:41:33 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-11-23 10:41:33 +0000 |
| commit | 6c13dc04f2d1a7b192d42d5b2a02fddbeb3617c7 (patch) | |
| tree | 307fcabbac6985abb8d59fa5c16ce021d1c1c7c4 /packages | |
| parent | fix some lints for frontend (diff) | |
| parent | merge: Move `cypress` to `optionalDependencies` (!697) (diff) | |
| download | sharkey-6c13dc04f2d1a7b192d42d5b2a02fddbeb3617c7.tar.gz sharkey-6c13dc04f2d1a7b192d42d5b2a02fddbeb3617c7.tar.bz2 sharkey-6c13dc04f2d1a7b192d42d5b2a02fddbeb3617c7.zip | |
Merge branch 'develop' into feature/2024.10
Diffstat (limited to 'packages')
22 files changed, 731 insertions, 327 deletions
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index d1ae13d706..87b2b620d7 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -32,7 +32,7 @@ import { AbuseReportService } from '@/core/AbuseReportService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { fromTuple } from '@/misc/from-tuple.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -166,7 +166,7 @@ export class ApInboxService { } else if (isAnnounce(activity)) { return await this.announce(actor, activity, resolver); } else if (isLike(activity)) { - return await this.like(actor, activity); + return await this.like(actor, activity, resolver); } else if (isUndo(activity)) { return await this.undo(actor, activity, resolver); } else if (isBlock(activity)) { @@ -198,10 +198,13 @@ export class ApInboxService { } @bindThis - private async like(actor: MiRemoteUser, activity: ILike): Promise<string> { + private async like(actor: MiRemoteUser, activity: ILike, resolver?: Resolver): Promise<string> { const targetUri = getApId(activity.object); - const note = await this.apNoteService.fetchNote(targetUri); + const object = fromTuple(activity.object); + if (!object) return 'skip: activity has no object property'; + + const note = await this.apNoteService.resolveNote(object, { resolver }); if (!note) return `skip: target note not found ${targetUri}`; await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); @@ -272,8 +275,12 @@ export class ApInboxService { } if (activity.target === actor.featured) { - const object = fromTuple(activity.object); - const note = await this.apNoteService.resolveNote(object, { resolver }); + const activityObject = fromTuple(activity.object); + if (isApObject(activityObject) && !isPost(activityObject)) { + return `unsupported featured object type: ${getApType(activityObject)}`; + } + + const note = await this.apNoteService.resolveNote(activityObject, { resolver }); if (note == null) return 'note not found'; await this.notePiningService.addPinned(actor, note.id); return; @@ -386,7 +393,7 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> { + private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise<string | void> { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); @@ -421,14 +428,14 @@ export class ApInboxService { }); if (isPost(object)) { - await this.createNote(resolver, actor, object, false, activity); + await this.createNote(resolver, actor, object, false); } else { return `Unknown type: ${getApType(object)}`; } } @bindThis - private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> { + private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false): Promise<string> { const uri = getApId(note); if (typeof note === 'object') { @@ -643,6 +650,10 @@ export class ApInboxService { if (activity.target === actor.featured) { const activityObject = fromTuple(activity.object); + if (isApObject(activityObject) && !isPost(activityObject)) { + return `unsupported featured object type: ${getApType(activityObject)}`; + } + const note = await this.apNoteService.resolveNote(activityObject, { resolver }); if (note == null) return 'note not found'; await this.notePiningService.removePinned(actor, note.id); @@ -787,7 +798,7 @@ export class ApInboxService { } @bindThis - private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> { + private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string | void> { if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } @@ -806,9 +817,19 @@ export class ApInboxService { await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; } else if (getApType(object) === 'Question') { + // If we get an Update(Question) for a note that doesn't exist, then create it instead + if (!await this.apNoteService.hasNote(object)) { + return await this.create(actor, activity, resolver); + } + await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err)); return 'ok: Question updated'; } else if (isPost(object)) { + // If we get an Update(Note) for a note that doesn't exist, then create it instead + if (!await this.apNoteService.hasNote(object)) { + return await this.create(actor, activity, resolver); + } + await this.apNoteService.updateNote(object, actor, resolver).catch(err => console.error(err)); return 'ok: Note updated'; } else { diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 51dd357cc5..7d65944488 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -143,6 +143,15 @@ export class ApNoteService { } /** + * Returns true if the provided object / ID exists in the local database. + */ + @bindThis + public async hasNote(object: string | IObject | [string | IObject]): Promise<boolean> { + const uri = getApId(object); + return await this.notesRepository.existsBy({ uri }); + } + + /** * Noteを作成します。 */ @bindThis diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index c5c0bb8c94..8b1218df47 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -343,6 +343,7 @@ export interface IMove extends IActivity { target: IObject | string; } +export const isApObject = (object: string | IObject): object is IObject => typeof(object) === 'object'; export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 3ad4577706..d6504a798a 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { isPackedPureRenote } from '@/misc/is-renote.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CacheService } from '../CacheService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -180,10 +181,9 @@ export class NoteEntityService implements OnModuleInit { } else { // フォロワーかどうか // TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする - const appearNote = packedNote.renote ?? packedNote; const isFollowing = await this.followingsRepository.exists({ where: { - followeeId: appearNote.userId, + followeeId: packedNote.userId, followerId: meId, }, }); @@ -193,6 +193,14 @@ export class NoteEntityService implements OnModuleInit { } } + // If this is a pure renote (boost), then we should *also* check the boosted note's visibility. + // Otherwise we can have empty notes on the timeline, which is not good. + // Notes are packed in depth-first order, so we can safely grab the "isHidden" property to avoid duplicated checks. + // This is pulled out to ensure that we check both the renote *and* the boosted note. + if (packedNote.renote?.isHidden && isPackedPureRenote(packedNote)) { + hide = true; + } + if (!hide && meId && packedNote.userId !== meId) { const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId); diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 43193d12f5..d6872de46a 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -71,6 +71,14 @@ type PackedQuote = fileIds: NonNullable<Packed<'Note'>['fileIds']> }); +type PackedPureRenote = PackedRenote & { + text: NonNullable<Packed<'Note'>['text']>; + cw: NonNullable<Packed<'Note'>['cw']>; + replyId: NonNullable<Packed<'Note'>['replyId']>; + poll: NonNullable<Packed<'Note'>['poll']>; + fileIds: NonNullable<Packed<'Note'>['fileIds']>; +} + export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { return note.renoteId != null; } @@ -82,3 +90,7 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote { note.poll != null || (note.fileIds != null && note.fileIds.length > 0); } + +export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote { + return isRenotePacked(note) && !isQuotePacked(note); +} diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 28a74bbb4a..dd183cd991 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { StatusError } from '@/misc/status-error.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -134,7 +135,7 @@ export class QueueProcessorService implements OnApplicationShutdown { // 何故かeがundefinedで来ることがある if (!e) return '?'; - if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') { + if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError' || e instanceof StatusError) { return `${e.name}: ${e.message}`; } @@ -148,12 +149,15 @@ export class QueueProcessorService implements OnApplicationShutdown { function renderJob(job?: Bull.Job) { if (!job) return '?'; - return { - name: job.name || undefined, + const info: Record<string, string> = { info: getJobInfo(job), - failedReason: job.failedReason || undefined, data: job.data, }; + + if (job.name) info.name = job.name; + if (job.failedReason) info.failedReason = job.failedReason; + + return info; } //#region system diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index f5a356db55..9026331dff 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -7,6 +7,7 @@ import { URL } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; +import { AbortError } from 'node-fetch'; import type Logger from '@/logger.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; @@ -238,6 +239,19 @@ export class InboxProcessorService implements OnApplicationShutdown { return e.message; } } + + if (e instanceof StatusError) { + if (e.isRetryable) { + return `temporary error ${e.statusCode}`; + } else { + return `skip: permanent error ${e.statusCode}`; + } + } + + if (e instanceof AbortError) { + return 'request aborted'; + } + throw e; } return 'ok'; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index fb5954fee0..82ee0f47d7 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -183,7 +183,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { }, ...(endpoint.meta.limit ? { '429': { - description: 'To many requests', + description: 'Too many requests', content: { 'application/json': { schema: { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 587793e310..0e9d0ff2bb 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -76,6 +76,9 @@ "vue": "3.5.12", "vuedraggable": "next" }, + "optionalDependencies": { + "cypress": "13.15.2" + }, "devDependencies": { "@misskey-dev/summaly": "5.1.0", "@storybook/addon-actions": "8.4.4", @@ -116,7 +119,6 @@ "@vue/runtime-core": "3.5.12", "acorn": "8.14.0", "cross-env": "7.0.3", - "cypress": "13.15.2", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.31.0", "fast-glob": "3.3.2", diff --git a/packages/frontend/src/components/SkFollowingRecentNotes.vue b/packages/frontend/src/components/SkFollowingRecentNotes.vue new file mode 100644 index 0000000000..6daa8feba5 --- /dev/null +++ b/packages/frontend/src/components/SkFollowingRecentNotes.vue @@ -0,0 +1,144 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkPullToRefresh :refresher="() => reload()"> + <MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost" :alt="i18n.ts.noNotes" aria-hidden="true"/> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items: notes }"> + <MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> + <SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/> + </MkDateSeparatedList> + </template> + </MkPagination> +</MkPullToRefresh> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { computed, shallowRef } from 'vue'; +import { infoImageUrl } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue'; +import { $i } from '@/account.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { FollowingFeedTab } from '@/scripts/following-feed-utils.js'; +import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; + +const props = defineProps<{ + userList: FollowingFeedTab; + withNonPublic: boolean; + withQuotes: boolean; + withReplies: boolean; + withBots: boolean; + onlyFiles: boolean; + selectedUserId?: string | null; +}>(); + +const emit = defineEmits<{ + (event: 'loaded', initialUserId?: string): void; + (event: 'userSelected', userId: string): void; +}>(); + +defineExpose({ reload }); + +async function reload() { + await latestNotesPaging.value?.reload(); +} + +function selectUser(userId: string) { + emit('userSelected', userId); +} + +async function onListReady(): Promise<void> { + // This looks complicated, but it's really just a trick to get the first user ID from the pagination. + const initialUserId = latestNotesPaging.value?.items.size + ? latestNotesPaging.value.items.values().next().value?.userId + : undefined; + + emit('loaded', initialUserId); +} + +const latestNotesPagination: Paging<'notes/following'> = { + endpoint: 'notes/following' as const, + limit: 20, + params: computed(() => ({ + list: props.userList, + filesOnly: props.onlyFiles, + includeNonPublic: props.withNonPublic, + includeReplies: props.withReplies, + includeQuotes: props.withQuotes, + includeBots: props.withBots, + })), +}; + +const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>(); + +function isSoftMuted(note: Misskey.entities.Note): boolean { + return isMuted(note, $i?.mutedWords); +} + +function isHardMuted(note: Misskey.entities.Note): boolean { + return isMuted(note, $i?.hardMutedWords); +} + +// Match the typing used by Misskey +type Mutes = (string | string[])[] | null | undefined; + +// Adapted from MkNote.ts +function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean { + return checkMute(note, mutes) + || checkMute(note.reply, mutes) + || checkMute(note.renote, mutes); +} + +// Adapted from check-word-mute.ts +function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean { + if (!note) { + return false; + } + + if (!mutes || mutes.length < 1) { + return false; + } + + return checkWordMute(note, $i, mutes); +} +</script> + +<style module lang="scss"> +.panel { + background: var(--panel); +} + +@keyframes border { + from { + border-left: 0 solid var(--accent); + } + to { + border-left: 6px solid var(--accent); + } +} + +.selected { + animation: border 0.2s ease-out 0s 1 forwards; + + &:first-child { + border-top-left-radius: 5px; + } + + &:last-child { + border-bottom-left-radius: 5px; + } +} +</style> diff --git a/packages/frontend/src/components/SkRemoteFollowersWarning.vue b/packages/frontend/src/components/SkRemoteFollowersWarning.vue new file mode 100644 index 0000000000..ceebbd59dd --- /dev/null +++ b/packages/frontend/src/components/SkRemoteFollowersWarning.vue @@ -0,0 +1,32 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkInfo v-if="showRemoteWarning" warn closable @close="close"> + {{ i18n.ts.remoteFollowersWarning }} +</MkInfo> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { i18n } from '@/i18n.js'; +import MkInfo from '@/components/MkInfo.vue'; +import { followersTab, FollowingFeedModel } from '@/scripts/following-feed-utils.js'; + +const props = defineProps<{ + model: FollowingFeedModel, +}>(); + +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const { model: { userList, remoteWarningDismissed } } = props; + +const showRemoteWarning = computed( + () => userList.value === followersTab && !remoteWarningDismissed.value, +); + +function close() { + remoteWarningDismissed.value = true; +} +</script> diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index f355facb51..908affcdaf 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -101,7 +101,7 @@ onMounted(async () => { margin-bottom: 12px; } -@container (min-width: 451px) { +@container (min-width: 750px) { .userInfo { margin-bottom: 24px; } diff --git a/packages/frontend/src/components/global/SkLazy.vue b/packages/frontend/src/components/global/SkLazy.vue new file mode 100644 index 0000000000..40add97db7 --- /dev/null +++ b/packages/frontend/src/components/global/SkLazy.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- Based on MkLazy.vue --> + +<template> +<div ref="rootEl" :class="$style.root"> + <slot v-if="showing"></slot> + <div v-else :class="$style.placeholder"></div> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; + +const rootEl = shallowRef<HTMLDivElement>(); +const showing = ref(false); + +defineExpose({ rootEl, showing }); + +const observer = new IntersectionObserver(entries => + showing.value = entries.some((entry) => entry.isIntersecting), +); + +onMounted(() => { + nextTick(() => { + if (rootEl.value) { + observer.observe(rootEl.value); + } + }); +}); + +onActivated(() => { + nextTick(() => { + if (rootEl.value) { + observer.observe(rootEl.value); + } + }); +}); + +onBeforeUnmount(() => { + observer.disconnect(); +}); +</script> + +<style lang="scss" module> +.root { + display: block; +} + +.placeholder { + display: block; + min-height: 150px; +} +</style> diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index b57759ade0..7fb13c8fcb 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -7,61 +7,41 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.header"> <MkPageHeader v-model:tab="userList" :tabs="headerTabs" :actions="headerActions" :displayBackButton="true" @update:tab="onChangeTab"/> - <MkInfo v-if="showRemoteWarning" :class="$style.remoteWarning" warn closable @close="remoteWarningDismissed = true">{{ i18n.ts.remoteFollowersWarning }}</MkInfo> + <SkRemoteFollowersWarning :class="$style.remoteWarning" :model="model"/> </div> <div ref="noteScroll" :class="$style.notes"> <MkHorizontalSwipe v-model:tab="userList" :tabs="headerTabs"> - <MkPullToRefresh :refresher="() => reloadLatestNotes()"> - <MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost" :alt="i18n.ts.noNotes" aria-hidden="true"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> - - <template #default="{ items: notes }"> - <MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> - <SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="selectedUserId == note.userId && $style.selected" @select="userSelected"/> - </MkDateSeparatedList> - </template> - </MkPagination> - </MkPullToRefresh> + <SkFollowingRecentNotes ref="followingRecentNotes" :selectedUserId="selectedUserId" :userList="userList" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles" @userSelected="userSelected" @loaded="listReady"/> </MkHorizontalSwipe> </div> - <div v-if="isWideViewport" ref="userScroll" :class="$style.user"> + <SkLazy ref="userScroll" :class="$style.user"> <MkHorizontalSwipe v-if="selectedUserId" v-model:tab="userList" :tabs="headerTabs"> <SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> </MkHorizontalSwipe> - </div> + </SkLazy> </div> </template> <script lang="ts" setup> -import { computed, Ref, ref, shallowRef } from 'vue'; -import * as Misskey from 'misskey-js'; +import { computed, ComputedRef, Ref, ref, shallowRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import { infoImageUrl } from '@/instance.js'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import { PageHeaderItem } from '@/types/page-header.js'; -import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue'; import { useRouter } from '@/router/supplier.js'; import MkPageHeader from '@/components/global/MkPageHeader.vue'; -import { $i } from '@/account.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import { useScrollPositionManager } from '@/nirax.js'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import { createModel, createOptions, followersTab, followingTab, mutualsTab } from '@/scripts/following-feed-utils.js'; +import { createModel, createHeaderItem, followingFeedTabs, followingTabIcon, followingTabName, followingTab } from '@/scripts/following-feed-utils.js'; +import SkLazy from '@/components/global/SkLazy.vue'; +import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue'; +import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue'; +const model = createModel(); const { userList, withNonPublic, @@ -69,141 +49,62 @@ const { withBots, withReplies, onlyFiles, - remoteWarningDismissed, -} = createModel(); +} = model; const router = useRouter(); const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>(); -const userScroll = shallowRef<HTMLElement>(); +const followingRecentNotes = shallowRef<InstanceType<typeof SkFollowingRecentNotes>>(); +const userScroll = shallowRef<InstanceType<typeof SkLazy>>(); const noteScroll = shallowRef<HTMLElement>(); -const showRemoteWarning = computed(() => userList.value === 'followers' && !remoteWarningDismissed.value); - -// We have to disable the per-user feed on small displays, and it must be done through JS instead of CSS. -// Otherwise, the second column will waste resources in the background. -const wideViewportQuery = window.matchMedia('(min-width: 750px)'); -const isWideViewport: Ref<boolean> = ref(wideViewportQuery.matches); -wideViewportQuery.addEventListener('change', () => isWideViewport.value = wideViewportQuery.matches); - const selectedUserId: Ref<string | null> = ref(null); -function userSelected(user: Misskey.entities.UserLite): void { - if (isWideViewport.value) { - selectedUserId.value = user.id; - } else { - router.push(`/following-feed/${user.id}`); +function listReady(initialUserId?: string): void { + if (initialUserId && !selectedUserId.value) { + selectedUserId.value = initialUserId; } } -async function reloadLatestNotes() { - await latestNotesPaging.value?.reload(); -} +function userSelected(userId: string): void { + selectedUserId.value = userId; -async function reloadUserNotes() { - await userRecentNotes.value?.reload(); + if (!userScroll.value?.showing) { + router.push(`/following-feed/${userId}`); + } } async function reload() { await Promise.all([ - reloadLatestNotes(), - reloadUserNotes(), + followingRecentNotes.value?.reload(), + userRecentNotes.value?.reload(), ]); } -async function onListReady(): Promise<void> { - if (!selectedUserId.value && latestNotesPaging.value?.items.size) { - // This looks messy, but actually just gets the first user ID. - const selectedNote = latestNotesPaging.value.items.values().next().value; - - // We know this to be non-null because of the size check above. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - selectedUserId.value = selectedNote!.userId; - } -} - async function onChangeTab(): Promise<void> { selectedUserId.value = null; } -function isSoftMuted(note: Misskey.entities.Note): boolean { - return isMuted(note, $i?.mutedWords); -} - -function isHardMuted(note: Misskey.entities.Note): boolean { - return isMuted(note, $i?.hardMutedWords); -} - -// Match the typing used by Misskey -type Mutes = (string | string[])[] | null | undefined; - -// Adapted from MkNote.ts -function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean { - return checkMute(note, mutes) - || checkMute(note.reply, mutes) - || checkMute(note.renote, mutes); -} - -// Adapted from check-word-mute.ts -function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean { - if (!note) { - return false; - } - - if (!mutes || mutes.length < 1) { - return false; - } - - return checkWordMute(note, $i, mutes); -} - -const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>(); - -const latestNotesPagination: Paging<'notes/following'> = { - endpoint: 'notes/following' as const, - limit: 20, - params: computed(() => ({ - list: userList.value, - filesOnly: onlyFiles.value, - includeNonPublic: withNonPublic.value, - includeReplies: withReplies.value, - includeQuotes: withQuotes.value, - includeBots: withBots.value, - })), -}; - const headerActions: PageHeaderItem[] = [ { icon: 'ti ti-refresh', text: i18n.ts.reload, handler: () => reload(), }, - createOptions(), + createHeaderItem(), ]; -const headerTabs = computed(() => [ - { - key: followingTab, - icon: 'ph-user-check ph-bold ph-lg', - title: i18n.ts.following, - } satisfies Tab, - { - key: mutualsTab, - icon: 'ph-user-switch ph-bold ph-lg', - title: i18n.ts.mutuals, - } satisfies Tab, - { - key: followersTab, - icon: 'ph-user ph-bold ph-lg', - title: i18n.ts.followers, - } satisfies Tab, -]); +const headerTabs: ComputedRef<Tab[]> = computed(() => followingFeedTabs.map(t => ({ + key: t, + icon: followingTabIcon(t), + title: followingTabName(t), +}))); -useScrollPositionManager(() => getScrollContainer(userScroll.value ?? null), router); +useScrollPositionManager(() => getScrollContainer(userScroll.value?.rootEl ?? null), router); useScrollPositionManager(() => getScrollContainer(noteScroll.value ?? null), router); definePageMetadata(() => ({ title: i18n.ts.following, - icon: 'ph-user-check ph-bold ph-lg', + icon: followingTabIcon(followingTab), })); </script> @@ -257,22 +158,13 @@ definePageMetadata(() => ({ margin-bottom: 12px; } -@keyframes border { - from {border-left: 0px solid var(--accent);} - to {border-left: 6px solid var(--accent);} -} - -.selected { - animation: border 0.2s ease-out 0s 1 forwards; - &:first-child { - border-top-left-radius: 5px; - } - &:last-child { - border-bottom-left-radius: 5px; +@container (max-width: 749px) { + .user { + display: none; } } -@media (min-width: 750px) { +@container (min-width: 750px) { .root { grid-template-columns: min-content 4fr 6fr min-content; grid-template-rows: min-content 1fr; @@ -290,8 +182,4 @@ definePageMetadata(() => ({ margin-bottom: 24px; } } - -.panel { - background: var(--MI_THEME-panel); -} </style> diff --git a/packages/frontend/src/pages/user/recent-notes.vue b/packages/frontend/src/pages/user/recent-notes.vue index c16a1c5fed..d636068408 100644 --- a/packages/frontend/src/pages/user/recent-notes.vue +++ b/packages/frontend/src/pages/user/recent-notes.vue @@ -4,16 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer ref="userScroll"> +<MkStickyContainer> <template #header> <MkPageHeader :actions="headerActions" :displayBackButton="true"/> </template> - <SkUserRecentNotes ref="userRecentNotes" :userId="userId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> + <SkUserRecentNotes ref="userRecentNotes" :class="$style.notes" :userId="userId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> </MkStickyContainer> </template> <script setup lang="ts"> - import { computed, shallowRef } from 'vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -21,7 +20,7 @@ import { PageHeaderItem } from '@/types/page-header.js'; import MkPageHeader from '@/components/global/MkPageHeader.vue'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import { acct } from '@/filters/user.js'; -import { createModel, createOptions } from '@/scripts/following-feed-utils.js'; +import { createModel, createHeaderItem } from '@/scripts/following-feed-utils.js'; import MkStickyContainer from '@/components/global/MkStickyContainer.vue'; defineProps<{ @@ -45,7 +44,7 @@ const headerActions: PageHeaderItem[] = [ text: i18n.ts.reload, handler: () => userRecentNotes.value?.reload(), }, - createOptions(), + createHeaderItem(), ]; // Based on user/index.vue @@ -64,3 +63,17 @@ definePageMetadata(() => ({ } : {}, })); </script> + +<style lang="scss" module> +@container (min-width: 451px) { + .notes { + padding: 12px; + } +} + +@container (min-width: 750px) { + .notes { + padding: 24px; + } +} +</style> diff --git a/packages/frontend/src/scripts/following-feed-utils.ts b/packages/frontend/src/scripts/following-feed-utils.ts index 064d6b72e3..39f17949d6 100644 --- a/packages/frontend/src/scripts/following-feed-utils.ts +++ b/packages/frontend/src/scripts/following-feed-utils.ts @@ -3,19 +3,75 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed } from 'vue'; +import { computed, Ref, WritableComputedRef } from 'vue'; import { defaultStore } from '@/store.js'; import { deepMerge } from '@/scripts/merge.js'; import { PageHeaderItem } from '@/types/page-header.js'; import { i18n } from '@/i18n.js'; import { popupMenu } from '@/os.js'; +import { MenuItem } from '@/types/menu.js'; export const followingTab = 'following' as const; export const mutualsTab = 'mutuals' as const; export const followersTab = 'followers' as const; -export type FollowingFeedTab = typeof followingTab | typeof mutualsTab | typeof followersTab; +export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const; +export type FollowingFeedTab = typeof followingFeedTabs[number]; -export function createOptions(): PageHeaderItem { +export function followingTabName(tab: FollowingFeedTab): string; +export function followingTabName(tab: FollowingFeedTab | null | undefined): null; +export function followingTabName(tab: FollowingFeedTab | null | undefined): string | null { + if (tab === followingTab) return i18n.ts.following; + if (tab === followersTab) return i18n.ts.followers; + if (tab === mutualsTab) return i18n.ts.mutuals; + return null; +} + +export function followingTabIcon(tab: FollowingFeedTab | null | undefined): string { + if (tab === followersTab) return 'ph-user ph-bold ph-lg'; + if (tab === mutualsTab) return 'ph-user-switch ph-bold ph-lg'; + return 'ph-user-check ph-bold ph-lg'; +} + +export type FollowingFeedModel = { + [Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>; +} + +export interface FollowingFeedState { + withNonPublic: boolean, + withQuotes: boolean, + withBots: boolean, + withReplies: boolean, + onlyFiles: boolean, + userList: FollowingFeedTab, + remoteWarningDismissed: boolean, +} + +export const defaultFollowingFeedState: FollowingFeedState = { + withNonPublic: false, + withQuotes: false, + withBots: true, + withReplies: false, + onlyFiles: false, + userList: followingTab, + remoteWarningDismissed: false, +}; + +interface StorageInterface<T extends Partial<FollowingFeedState> = Partial<FollowingFeedState>> { + readonly state: Partial<T>; + readonly reactiveState: Ref<Partial<T>>; + save(updated: T): void; +} + +export function createHeaderItem(storage?: Ref<StorageInterface>): PageHeaderItem { + const menu = createOptionsMenu(storage); + return { + icon: 'ti ti-dots', + text: i18n.ts.options, + handler: ev => popupMenu(menu, ev.currentTarget ?? ev.target), + }; +} + +export function createOptionsMenu(storage?: Ref<StorageInterface>): MenuItem[] { const { userList, withNonPublic, @@ -23,80 +79,83 @@ export function createOptions(): PageHeaderItem { withBots, withReplies, onlyFiles, - } = createModel(); + } = createModel(storage); - return { - icon: 'ti ti-dots', - text: i18n.ts.options, - handler: ev => - popupMenu([ - { - type: 'switch', - text: i18n.ts.showNonPublicNotes, - ref: withNonPublic, - disabled: userList.value === 'followers', - }, - { - type: 'switch', - text: i18n.ts.showQuotes, - ref: withQuotes, - }, - { - type: 'switch', - text: i18n.ts.showBots, - ref: withBots, - }, - { - type: 'switch', - text: i18n.ts.showReplies, - ref: withReplies, - disabled: onlyFiles, - }, - { - type: 'divider', - }, - { - type: 'switch', - text: i18n.ts.fileAttachedOnly, - ref: onlyFiles, - disabled: withReplies, - }, - ], ev.currentTarget ?? ev.target), - }; + return [ + { + type: 'switch', + text: i18n.ts.showNonPublicNotes, + ref: withNonPublic, + disabled: computed(() => userList.value === followersTab), + }, + { + type: 'switch', + text: i18n.ts.showQuotes, + ref: withQuotes, + }, + { + type: 'switch', + text: i18n.ts.showBots, + ref: withBots, + }, + { + type: 'switch', + text: i18n.ts.showReplies, + ref: withReplies, + disabled: onlyFiles, + }, + { + type: 'divider', + }, + { + type: 'switch', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + disabled: withReplies, + }, + ]; } -export function createModel() { - const userList = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.userList, +export function createModel(storage?: Ref<StorageInterface>): FollowingFeedModel { + // eslint-disable-next-line no-param-reassign + storage ??= createDefaultStorage(); + + // Based on timeline.saveTlFilter() + const saveFollowingFilter = <K extends keyof FollowingFeedState>(key: K, value: FollowingFeedState[K]) => { + const state = deepMerge(storage.value.state, defaultFollowingFeedState); + const out = deepMerge({ [key]: value }, state); + storage.value.save(out); + }; + + const userList: WritableComputedRef<FollowingFeedTab> = computed({ + get: () => storage.value.reactiveState.value.userList ?? defaultFollowingFeedState.userList, set: value => saveFollowingFilter('userList', value), }); - - const withNonPublic = computed({ + const withNonPublic: WritableComputedRef<boolean> = computed({ get: () => { if (userList.value === 'followers') return false; - return defaultStore.reactiveState.followingFeed.value.withNonPublic; + return storage.value.reactiveState.value.withNonPublic ?? defaultFollowingFeedState.withNonPublic; }, set: value => saveFollowingFilter('withNonPublic', value), }); - const withQuotes = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.withQuotes, + const withQuotes: WritableComputedRef<boolean> = computed({ + get: () => storage.value.reactiveState.value.withQuotes ?? defaultFollowingFeedState.withQuotes, set: value => saveFollowingFilter('withQuotes', value), }); - const withBots = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.withBots, + const withBots: WritableComputedRef<boolean> = computed({ + get: () => storage.value.reactiveState.value.withBots ?? defaultFollowingFeedState.withBots, set: value => saveFollowingFilter('withBots', value), }); - const withReplies = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.withReplies, + const withReplies: WritableComputedRef<boolean> = computed({ + get: () => storage.value.reactiveState.value.withReplies ?? defaultFollowingFeedState.withReplies, set: value => saveFollowingFilter('withReplies', value), }); - const onlyFiles = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.onlyFiles, + const onlyFiles: WritableComputedRef<boolean> = computed({ + get: () => storage.value.reactiveState.value.onlyFiles ?? defaultFollowingFeedState.onlyFiles, set: value => saveFollowingFilter('onlyFiles', value), }); - - const remoteWarningDismissed = computed({ - get: () => defaultStore.reactiveState.followingFeed.value.remoteWarningDismissed, + const remoteWarningDismissed: WritableComputedRef<boolean> = computed({ + get: () => storage.value.reactiveState.value.remoteWarningDismissed ?? defaultFollowingFeedState.remoteWarningDismissed, set: value => saveFollowingFilter('remoteWarningDismissed', value), }); @@ -111,8 +170,12 @@ export function createModel() { }; } -// Based on timeline.saveTlFilter() -function saveFollowingFilter<Key extends keyof typeof defaultStore.state.followingFeed>(key: Key, value: (typeof defaultStore.state.followingFeed)[Key]) { - const out = deepMerge({ [key]: value }, defaultStore.state.followingFeed); - return defaultStore.set('followingFeed', out); +function createDefaultStorage() { + return computed(() => ({ + state: defaultStore.state.followingFeed, + reactiveState: defaultStore.reactiveState.followingFeed, + save(updated: typeof defaultStore.state.followingFeed) { + return defaultStore.set('followingFeed', updated); + }, + })); } diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts index 9794a300da..89fdda0cbb 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/scripts/merge.ts @@ -18,7 +18,7 @@ function isPureObject(value: unknown): value is Record<string | number | symbol, * valueにないキーをdefからもらう(再帰的)\ * nullはそのまま、undefinedはdefの値 **/ -export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, def: X): X { +export function deepMerge<X extends object>(value: DeepPartial<X>, def: X): X { if (isPureObject(value) && isPureObject(def)) { const result = deepClone(value as Cloneable) as X; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 11c76db2e3..c34e0bbf48 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -10,9 +10,9 @@ import lightTheme from '@@/themes/l-cherry.json5'; import darkTheme from '@@/themes/d-ice.json5'; import { searchEngineMap } from './scripts/search-engine-map.js'; import type { SoundType } from '@/scripts/sound.js'; -import type { FollowingFeedTab } from '@/scripts/following-feed-utils.js'; import { DEFAULT_DEVICE_KIND, type DeviceKind } from '@/scripts/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; +import { defaultFollowingFeedState } from '@/scripts/following-feed-utils.js'; import { Storage } from '@/pizzax.js'; import type { Ast } from '@syuilo/aiscript'; @@ -250,15 +250,7 @@ export const defaultStore = markRaw(new Storage('base', { }, followingFeed: { where: 'account', - default: { - withNonPublic: false, - withQuotes: false, - withBots: true, - withReplies: false, - onlyFiles: false, - userList: 'following' as FollowingFeedTab, - remoteWarningDismissed: false, - }, + default: defaultFollowingFeedState, }, overridedDeviceKind: { diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 0d82558a00..c0ea833546 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -18,16 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" @wheel.self="onWheel" > - <component - :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" - v-for="id in ids" - :ref="id" - :key="id" - :class="$style.column" - :column="columns.find(c => c.id === id)!" - :isStacked="ids.length > 1" - @headerWheel="onWheel" - /> + <Suspense> + <component + :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" + v-for="id in ids" + :ref="id" + :key="id" + :class="$style.column" + :column="columns.find(c => c.id === id)!" + :isStacked="ids.length > 1" + @headerWheel="onWheel" + /> + <template #fallback> + <MkLoading/> + </template> + </Suspense> </section> <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> <div>{{ i18n.ts._deck.introduction }}</div> @@ -118,6 +123,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; +import XFollowingColumn from '@/ui/deck/following-column.vue'; import { mainRouter } from '@/router/main.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -133,6 +139,7 @@ const columnComponents = { mentions: XMentionsColumn, direct: XDirectColumn, roleTimeline: XRoleTimelineColumn, + following: XFollowingColumn, }; mainRouter.navHook = (path, flag): boolean => { diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 3186982349..91859b46d7 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -4,7 +4,7 @@ */ import { throttle } from 'throttle-debounce'; -import { markRaw } from 'vue'; +import { computed, markRaw, Ref } from 'vue'; import { notificationTypes } from 'misskey-js'; import type { BasicTimelineType } from '@/timelines.js'; import { Storage } from '@/pizzax.js'; @@ -29,6 +29,7 @@ export const columnTypes = [ 'mentions', 'direct', 'roleTimeline', + 'following', ] as const; export type ColumnType = typeof columnTypes[number]; @@ -113,8 +114,8 @@ export const loadDeck = async () => { }; // TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する -export const saveDeck = throttle(1000, () => { - misskeyApi('i/registry/set', { +export const saveDeck = throttle(1000, async () => { + await misskeyApi('i/registry/set', { scope: ['client', 'deck', 'profiles'], key: deckStore.state.profile, value: { @@ -314,7 +315,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat saveDeck(); } -export function updateColumn(id: Column['id'], column: Partial<Column>) { +export async function updateColumn<TColumn>(id: Column['id'], column: Partial<TColumn>) { const columns = deepClone(deckStore.state.columns); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); const currentColumn = deepClone(deckStore.state.columns[columnIndex]); @@ -323,6 +324,18 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) { currentColumn[k] = v; } columns[columnIndex] = currentColumn; - deckStore.set('columns', columns); - saveDeck(); + await Promise.all([ + deckStore.set('columns', columns), + saveDeck(), + ]); +} + +export function getColumn<TColumn extends Column>(id: Column['id']): TColumn { + return deckStore.state.columns.find(c => c.id === id) as TColumn; +} + +export function getReactiveColumn<TColumn extends Column>(id: Column['id']): Ref<TColumn> { + return computed(() => { + return deckStore.reactiveState.columns.value.find(c => c.id === id) as TColumn; + }); } diff --git a/packages/frontend/src/ui/deck/following-column.vue b/packages/frontend/src/ui/deck/following-column.vue new file mode 100644 index 0000000000..6b8c9db917 --- /dev/null +++ b/packages/frontend/src/ui/deck/following-column.vue @@ -0,0 +1,124 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- based on list-column.vue --> + +<template> +<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="reload"> + <template #header> + <i :class="columnIcon" aria-hidden="true"/><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <SkRemoteFollowersWarning :class="$style.followersWarning" :model="model"/> + <SkFollowingRecentNotes ref="latestNotes" :userList="userList" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withReplies="withReplies" :withBots="withBots" :onlyFiles="onlyFiles" @userSelected="userSelected"/> +</XColumn> +</template> + +<script lang="ts"> +import { computed, shallowRef } from 'vue'; +import type { Column } from '@/ui/deck/deck-store.js'; +import type { FollowingFeedState } from '@/scripts/following-feed-utils.js'; +export type FollowingColumn = Column & Partial<FollowingFeedState>; +</script> + +<script setup lang="ts"> +import { getColumn, getReactiveColumn, updateColumn } from '@/ui/deck/deck-store.js'; +import XColumn from '@/ui/deck/column.vue'; +import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue'; +import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue'; +import { createModel, createOptionsMenu, FollowingFeedTab, followingTab, followingTabName, followingTabIcon, followingFeedTabs } from '@/scripts/following-feed-utils.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { useRouter } from '@/router/supplier.js'; + +const props = defineProps<{ + column: FollowingColumn; + isStacked: boolean; +}>(); + +const columnIcon = computed(() => followingTabIcon(props.column.userList)); + +async function selectList(): Promise<void> { + const { canceled, result: newList } = await os.select<FollowingFeedTab>({ + title: i18n.ts.selectFollowRelationship, + items: followingFeedTabs.map(t => ({ + value: t, + text: followingTabName(t), + })), + default: props.column.userList ?? followingTab, + }); + + if (canceled) return; + + await updateColumn(props.column.id, { + name: getNewColumnName(newList), + userList: newList, + }); +} + +function getNewColumnName(newList: FollowingFeedTab) { + // If the user has renamed the column, then we need to keep that name. + // If no list is specified, then the column is newly created and the user *can't* have renamed it. + if (props.column.userList && props.column.name === followingTabName(props.column.userList)) { + return props.column.name; + } + + // Otherwise, we should match the name to the selected list. + return followingTabName(newList); +} + +if (!props.column.userList) { + await selectList(); +} + +// Redirects the Following Feed logic into column-specific storage. +// This allows multiple columns to exist with different settings. +const columnStorage = computed(() => ({ + state: getColumn<FollowingColumn>(props.column.id), + reactiveState: getReactiveColumn<FollowingColumn>(props.column.id), + save(updated: FollowingColumn) { + updateColumn(props.column.id, updated); + }, +})); + +const model = createModel(columnStorage); +const { + userList, + withNonPublic, + withQuotes, + withReplies, + withBots, + onlyFiles, +} = model; + +const menu: MenuItem[] = [ + { + icon: columnIcon.value, + text: i18n.ts.selectFollowRelationship, + action: selectList, + }, + ...createOptionsMenu(columnStorage), +]; + +const latestNotes = shallowRef<InstanceType<typeof SkFollowingRecentNotes>>(); + +async function reload() { + await latestNotes.value?.reload(); +} + +const router = useRouter(); + +function userSelected(userId: string) { + router.push(`/following-feed/${userId}`); +} +</script> + +<style lang="scss" module> +.followersWarning { + margin-bottom: 8px; + border-radius: 0; +} +</style> diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index fd6132e467..130e10e334 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -11135,7 +11135,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -11654,7 +11654,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -11721,7 +11721,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -12115,7 +12115,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -12175,7 +12175,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -12298,7 +12298,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -13900,7 +13900,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -14735,7 +14735,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -15082,7 +15082,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -15209,7 +15209,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -15704,7 +15704,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16187,7 +16187,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16247,7 +16247,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16310,7 +16310,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16369,7 +16369,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16429,7 +16429,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -16936,7 +16936,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -17211,7 +17211,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -17772,7 +17772,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -17919,7 +17919,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -17986,7 +17986,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18099,7 +18099,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18158,7 +18158,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18403,7 +18403,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18462,7 +18462,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18513,7 +18513,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18564,7 +18564,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18625,7 +18625,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18676,7 +18676,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18727,7 +18727,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18778,7 +18778,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18829,7 +18829,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18880,7 +18880,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -18931,7 +18931,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19168,7 +19168,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19228,7 +19228,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19288,7 +19288,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19347,7 +19347,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19406,7 +19406,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19465,7 +19465,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19533,7 +19533,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19601,7 +19601,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -19929,7 +19929,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -20652,7 +20652,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -20896,7 +20896,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -20956,7 +20956,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -21326,7 +21326,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -21805,7 +21805,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -21973,7 +21973,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -22472,7 +22472,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -22530,7 +22530,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -22588,7 +22588,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -23447,7 +23447,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -23940,7 +23940,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -24188,7 +24188,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -24362,7 +24362,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -24475,7 +24475,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -24613,7 +24613,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -24747,7 +24747,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -25079,7 +25079,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -25146,7 +25146,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -25476,7 +25476,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -26026,7 +26026,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -27305,7 +27305,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -28603,7 +28603,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; @@ -28771,7 +28771,7 @@ export type operations = { 'application/json': components['schemas']['Error']; }; }; - /** @description To many requests */ + /** @description Too many requests */ 429: { content: { 'application/json': components['schemas']['Error']; |