From e12943c15b5f42327945733ff5f43225280f7f23 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 3 Oct 2023 15:32:29 +0900 Subject: [PATCH] wip --- CHANGELOG.md | 1 + .../backend/src/core/NoteCreateService.ts | 12 +-- packages/backend/src/core/UserListService.ts | 18 ++++ .../core/entities/UserListEntityService.ts | 18 +++- .../backend/src/server/api/EndpointsModule.ts | 16 +++- packages/backend/src/server/api/endpoints.ts | 8 +- .../endpoints/users/lists/get-memberships.ts | 79 ++++++++++++++++ .../users/lists/update-membership.ts | 79 ++++++++++++++++ .../server/api/stream/channels/user-list.ts | 23 ++++- packages/backend/test/e2e/timelines.ts | 47 ++++++++++ packages/frontend/src/pages/my-lists/list.vue | 94 +++++++++++-------- 11 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts create mode 100644 packages/backend/src/server/api/endpoints/users/lists/update-membership.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb7234866..0a0c5d63d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ### General - ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました +- ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました - ソフトワードミュートとハードワードミュートは統合されました ### Server diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 74dd053e46..015bb26e73 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -840,11 +840,11 @@ export class NoteCreateService implements OnApplicationShutdown { select: ['followerId', 'withReplies'], }); - const userLists = await this.userListJoiningsRepository.find({ + const userListMemberships = await this.userListJoiningsRepository.find({ where: { userId: user.id, }, - select: ['userListId'], + select: ['userListId', 'withReplies'], }); // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする @@ -875,21 +875,21 @@ export class NoteCreateService implements OnApplicationShutdown { // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); //} - for (const userList of userLists) { + for (const userListMembership of userListMemberships) { // 自分自身以外への返信 if (note.replyId && note.replyUserId !== note.userId) { - if (!userList.withReplies) continue; + if (!userListMembership.withReplies) continue; } redisPipeline.xadd( - `userListTimeline:${userList.userListId}`, + `userListTimeline:${userListMembership.userListId}`, 'MAXLEN', '~', '200', '*', 'note', note.id); if (note.fileIds.length > 0) { redisPipeline.xadd( - `userListTimelineWithFiles:${userList.userListId}`, + `userListTimelineWithFiles:${userListMembership.userListId}`, 'MAXLEN', '~', '100', '*', 'note', note.id); diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 93dc5edbba..ed76d37f15 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -122,6 +122,24 @@ export class UserListService implements OnApplicationShutdown { this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); } + @bindThis + public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) { + const joining = await this.userListJoiningsRepository.findOneBy({ + userId: target.id, + userListId: list.id, + }); + + if (joining == null) { + throw new Error('User is not a member of the list'); + } + + await this.userListJoiningsRepository.update({ + id: joining.id, + }, { + withReplies: options.withReplies, + }); + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onMessage); diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index a7f2885194..adb7586ef3 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -5,11 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListJoining, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; import type { MiUserList } from '@/models/UserList.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { @@ -19,6 +20,8 @@ export class UserListEntityService { @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, ) { } @@ -40,5 +43,18 @@ export class UserListEntityService { isPublic: userList.isPublic, }; } + + @bindThis + public async packMembershipsMany( + memberships: MiUserListJoining[], + ) { + return Promise.all(memberships.map(async x => ({ + id: x.id, + createdAt: x.createdAt.toISOString(), + userId: x.userId, + user: await this.userEntityService.pack(x.userId), + withReplies: x.withReplies, + }))); + } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 822b4a9448..4b549c3439 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -335,7 +335,9 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; +import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -683,7 +685,9 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default }; +const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; +const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; +const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; @@ -1035,7 +1039,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $users_notes, $users_pages, $users_flashs, @@ -1378,7 +1384,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $users_notes, $users_pages, $users_flashs, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a46f136fb2..d33cd6d887 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -334,8 +334,10 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; +import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; +import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -681,7 +683,9 @@ const eps = [ ['users/lists/favorite', ep___users_lists_favorite], ['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_create_from_public], + ['users/lists/create-from-public', ep___users_lists_createFromPublic], + ['users/lists/update-membership', ep___users_lists_updateMembership], + ['users/lists/get-memberships', ep___users_lists_getMemberships], ['users/notes', ep___users_notes], ['users/pages', ep___users_pages], ['users/flashs', ep___users_flashs], diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts new file mode 100644 index 0000000000..3e6dff520c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UserListsRepository, UserListFavoritesRepository, UserListJoiningsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: false, + + kind: 'read:account', + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + forPublic: { type: 'boolean', default: false }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['listId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userListEntityService: UserListEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { + id: ps.listId, + userId: me.id, + } : { + id: ps.listId, + isPublic: true, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + const query = this.queryService.makePaginationQuery(this.userListJoiningsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) + .andWhere('membership.userListId = :userListId', { userListId: userList.id }) + .innerJoinAndSelect('membership.user', 'user'); + + const memberships = await query + .limit(ps.limit) + .getMany(); + + return this.userListEntityService.packMembershipsMany(memberships); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts new file mode 100644 index 0000000000..b69465b940 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UserListsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { UserListService } from '@/core/UserListService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '588e7f72-c744-4a61-b180-d354e912bda2', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + withReplies: { type: 'boolean' }, + }, + required: ['listId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + private userListService: UserListService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + await this.userListService.updateMembership(user, userList, { + withReplies: ps.withReplies, + }); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8bbba0b6db..aa9c18e9cd 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListJoining, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -18,7 +18,7 @@ class UserListChannel extends Channel { public static shouldShare = false; public static requireCredential = false; private listId: string; - public listUsers: MiUser['id'][] = []; + public membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; constructor( @@ -58,19 +58,25 @@ class UserListChannel extends Channel { @bindThis private async updateListUsers() { - const users = await this.userListJoiningsRepository.find({ + const memberships = await this.userListJoiningsRepository.find({ where: { userListId: this.listId, }, select: ['userId'], }); - this.listUsers = users.map(x => x.userId); + const membershipsMap: Record | undefined> = {}; + for (const membership of memberships) { + membershipsMap[membership.userId] = { + withReplies: membership.withReplies, + }; + } + this.membershipsMap = membershipsMap; } @bindThis private async onNote(note: Packed<'Note'>) { - if (!this.listUsers.includes(note.userId)) return; + if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user, { @@ -95,6 +101,13 @@ class UserListChannel extends Channel { } } + // 関係ない返信は除外 + if (note.reply && !this.membershipsMap[note.userId]?.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 2e7973251e..f4c7ffc82d 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -600,6 +600,53 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null); }); + test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + + test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index df13998f6b..b600f99fbc 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -29,16 +29,22 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.addUser }} -
- - - - -
- - {{ i18n.ts.loadMore }} - - + + + +
@@ -59,9 +65,11 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; -import { userListsCache } from '@/cache'; +import { userListsCache } from '@/cache.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; + const { enableInfiniteScroll, } = defaultStore.reactiveState; @@ -70,40 +78,25 @@ const props = defineProps<{ listId: string; }>(); -const FETCH_USERS_LIMIT = 20; - +const paginationEl = ref>(); let list = $ref(null); -let users = $ref([]); -let queueUserIds = $ref([]); -let fetching = $ref(true); const isPublic = ref(false); const name = ref(''); +const membershipsPagination = { + endpoint: 'users/lists/get-memberships' as const, + limit: 30, + params: computed(() => ({ + listId: props.listId, + })), +}; function fetchList() { - fetching = true; os.api('users/lists/show', { listId: props.listId, }).then(_list => { list = _list; name.value = list.name; isPublic.value = list.isPublic; - queueUserIds = list.userIds; - - return fetchMoreUsers(); - }); -} - -function fetchMoreUsers() { - if (!list) return; - if (fetching && users.length !== 0) return; // fetchingがtrueならやめるが、usersが空なら続行 - fetching = true; - os.api('users/show', { - userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT), - }).then(_users => { - users = users.concat(_users); - queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT); - }).finally(() => { - fetching = false; }); } @@ -114,12 +107,12 @@ function addUser() { listId: list.id, userId: user.id, }).then(() => { - users.push(user); + paginationEl.value.reload(); }); }); } -async function removeUser(user, ev) { +async function removeUser(item, ev) { os.popupMenu([{ text: i18n.ts.remove, icon: 'ti ti-x', @@ -128,9 +121,28 @@ async function removeUser(user, ev) { if (!list) return; os.api('users/lists/pull', { listId: list.id, - userId: user.id, + userId: item.userId, }).then(() => { - users = users.filter(x => x.id !== user.id); + paginationEl.value.removeItem(item.id); + }); + }, + }], ev.currentTarget ?? ev.target); +} + +async function showMembershipMenu(item, ev) { + os.popupMenu([{ + text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, + icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages', + action: async () => { + os.api('users/lists/update-membership', { + listId: list.id, + userId: item.userId, + withReplies: !item.withReplies, + }).then(() => { + paginationEl.value.updateItem(item.id, (old) => ({ + ...old, + withReplies: !item.withReplies, + })); }); }, }], ev.currentTarget ?? ev.target); @@ -202,6 +214,12 @@ definePageMetadata(computed(() => list ? { align-self: center; } +.menu { + width: 32px; + height: 32px; + align-self: center; +} + .more { margin-left: auto; margin-right: auto;