wip
This commit is contained in:
parent
7022b16bce
commit
e12943c15b
11 changed files with 339 additions and 56 deletions
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
|
- ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
|
||||||
|
- ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
|
||||||
- ソフトワードミュートとハードワードミュートは統合されました
|
- ソフトワードミュートとハードワードミュートは統合されました
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
|
@ -840,11 +840,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
select: ['followerId', 'withReplies'],
|
select: ['followerId', 'withReplies'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const userLists = await this.userListJoiningsRepository.find({
|
const userListMemberships = await this.userListJoiningsRepository.find({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
select: ['userListId'],
|
select: ['userListId', 'withReplies'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||||
|
@ -875,21 +875,21 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
// 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 (note.replyId && note.replyUserId !== note.userId) {
|
||||||
if (!userList.withReplies) continue;
|
if (!userListMembership.withReplies) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
redisPipeline.xadd(
|
redisPipeline.xadd(
|
||||||
`userListTimeline:${userList.userListId}`,
|
`userListTimeline:${userListMembership.userListId}`,
|
||||||
'MAXLEN', '~', '200',
|
'MAXLEN', '~', '200',
|
||||||
'*',
|
'*',
|
||||||
'note', note.id);
|
'note', note.id);
|
||||||
|
|
||||||
if (note.fileIds.length > 0) {
|
if (note.fileIds.length > 0) {
|
||||||
redisPipeline.xadd(
|
redisPipeline.xadd(
|
||||||
`userListTimelineWithFiles:${userList.userListId}`,
|
`userListTimelineWithFiles:${userListMembership.userListId}`,
|
||||||
'MAXLEN', '~', '100',
|
'MAXLEN', '~', '100',
|
||||||
'*',
|
'*',
|
||||||
'note', note.id);
|
'note', note.id);
|
||||||
|
|
|
@ -122,6 +122,24 @@ export class UserListService implements OnApplicationShutdown {
|
||||||
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
|
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
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.redisForSub.off('message', this.onMessage);
|
||||||
|
|
|
@ -5,11 +5,12 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
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 { Packed } from '@/misc/json-schema.js';
|
||||||
import type { } from '@/models/Blocking.js';
|
import type { } from '@/models/Blocking.js';
|
||||||
import type { MiUserList } from '@/models/UserList.js';
|
import type { MiUserList } from '@/models/UserList.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { UserEntityService } from './UserEntityService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserListEntityService {
|
export class UserListEntityService {
|
||||||
|
@ -19,6 +20,8 @@ export class UserListEntityService {
|
||||||
|
|
||||||
@Inject(DI.userListJoiningsRepository)
|
@Inject(DI.userListJoiningsRepository)
|
||||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,5 +43,18 @@ export class UserListEntityService {
|
||||||
isPublic: userList.isPublic,
|
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,
|
||||||
|
})));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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_update from './endpoints/users/lists/update.js';
|
||||||
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.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_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_notes from './endpoints/users/notes.js';
|
||||||
import * as ep___users_pages from './endpoints/users/pages.js';
|
import * as ep___users_pages from './endpoints/users/pages.js';
|
||||||
import * as ep___users_flashs from './endpoints/users/flashs.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_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_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_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_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_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
|
||||||
const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.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_update,
|
||||||
$users_lists_favorite,
|
$users_lists_favorite,
|
||||||
$users_lists_unfavorite,
|
$users_lists_unfavorite,
|
||||||
$users_lists_create_from_public,
|
$users_lists_createFromPublic,
|
||||||
|
$users_lists_updateMembership,
|
||||||
|
$users_lists_getMemberships,
|
||||||
$users_notes,
|
$users_notes,
|
||||||
$users_pages,
|
$users_pages,
|
||||||
$users_flashs,
|
$users_flashs,
|
||||||
|
@ -1378,7 +1384,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$users_lists_update,
|
$users_lists_update,
|
||||||
$users_lists_favorite,
|
$users_lists_favorite,
|
||||||
$users_lists_unfavorite,
|
$users_lists_unfavorite,
|
||||||
$users_lists_create_from_public,
|
$users_lists_createFromPublic,
|
||||||
|
$users_lists_updateMembership,
|
||||||
|
$users_lists_getMemberships,
|
||||||
$users_notes,
|
$users_notes,
|
||||||
$users_pages,
|
$users_pages,
|
||||||
$users_flashs,
|
$users_flashs,
|
||||||
|
|
|
@ -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_show from './endpoints/users/lists/show.js';
|
||||||
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.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_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_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_notes from './endpoints/users/notes.js';
|
||||||
import * as ep___users_pages from './endpoints/users/pages.js';
|
import * as ep___users_pages from './endpoints/users/pages.js';
|
||||||
import * as ep___users_flashs from './endpoints/users/flashs.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/favorite', ep___users_lists_favorite],
|
||||||
['users/lists/unfavorite', ep___users_lists_unfavorite],
|
['users/lists/unfavorite', ep___users_lists_unfavorite],
|
||||||
['users/lists/update', ep___users_lists_update],
|
['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/notes', ep___users_notes],
|
||||||
['users/pages', ep___users_pages],
|
['users/pages', ep___users_pages],
|
||||||
['users/flashs', ep___users_flashs],
|
['users/flashs', ep___users_flashs],
|
||||||
|
|
|
@ -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<typeof meta, typeof paramDef> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<typeof meta, typeof paramDef> { // 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 type { MiUser } from '@/models/User.js';
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
|
@ -18,7 +18,7 @@ class UserListChannel extends Channel {
|
||||||
public static shouldShare = false;
|
public static shouldShare = false;
|
||||||
public static requireCredential = false;
|
public static requireCredential = false;
|
||||||
private listId: string;
|
private listId: string;
|
||||||
public listUsers: MiUser['id'][] = [];
|
public membershipsMap: Record<string, Pick<MiUserListJoining, 'withReplies'> | undefined> = {};
|
||||||
private listUsersClock: NodeJS.Timeout;
|
private listUsersClock: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -58,19 +58,25 @@ class UserListChannel extends Channel {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async updateListUsers() {
|
private async updateListUsers() {
|
||||||
const users = await this.userListJoiningsRepository.find({
|
const memberships = await this.userListJoiningsRepository.find({
|
||||||
where: {
|
where: {
|
||||||
userListId: this.listId,
|
userListId: this.listId,
|
||||||
},
|
},
|
||||||
select: ['userId'],
|
select: ['userId'],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.listUsers = users.map(x => x.userId);
|
const membershipsMap: Record<string, Pick<MiUserListJoining, 'withReplies'> | undefined> = {};
|
||||||
|
for (const membership of memberships) {
|
||||||
|
membershipsMap[membership.userId] = {
|
||||||
|
withReplies: membership.withReplies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.membershipsMap = membershipsMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async onNote(note: Packed<'Note'>) {
|
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)) {
|
if (['followers', 'specified'].includes(note.visibility)) {
|
||||||
note = await this.noteEntityService.pack(note.id, this.user, {
|
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がミュートしているユーザーが関わるものだったら無視する
|
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||||
|
|
|
@ -600,6 +600,53 @@ describe('Timelines', () => {
|
||||||
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null);
|
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 () => {
|
test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => {
|
||||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||||
|
|
||||||
|
|
|
@ -29,16 +29,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
|
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
|
||||||
<div v-for="user in users" :key="user.id" :class="$style.userItem">
|
|
||||||
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
|
<MkPagination ref="paginationEl" :pagination="membershipsPagination">
|
||||||
<MkUserCardMini :user="user"/>
|
<template #default="{ items }">
|
||||||
</MkA>
|
<div class="_gaps_s">
|
||||||
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
|
<div v-for="item in items" :key="item.id">
|
||||||
</div>
|
<div :class="$style.userItem">
|
||||||
<MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers">
|
<MkA :class="$style.userItemBody" :to="`${userPage(item.user)}`">
|
||||||
{{ i18n.ts.loadMore }}
|
<MkUserCardMini :user="item.user"/>
|
||||||
</MkButton>
|
</MkA>
|
||||||
<MkLoading v-if="fetching" class="loading"/>
|
<button class="_button" :class="$style.menu" @click="showMembershipMenu(item, $event)"><i class="ti ti-dots"></i></button>
|
||||||
|
<button class="_button" :class="$style.remove" @click="removeUser(item, $event)"><i class="ti ti-x"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,9 +65,11 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import { userListsCache } from '@/cache';
|
import { userListsCache } from '@/cache.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
|
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
enableInfiniteScroll,
|
enableInfiniteScroll,
|
||||||
} = defaultStore.reactiveState;
|
} = defaultStore.reactiveState;
|
||||||
|
@ -70,40 +78,25 @@ const props = defineProps<{
|
||||||
listId: string;
|
listId: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const FETCH_USERS_LIMIT = 20;
|
const paginationEl = ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
let list = $ref<Misskey.entities.UserList | null>(null);
|
let list = $ref<Misskey.entities.UserList | null>(null);
|
||||||
let users = $ref<Misskey.entities.UserLite[]>([]);
|
|
||||||
let queueUserIds = $ref<string[]>([]);
|
|
||||||
let fetching = $ref(true);
|
|
||||||
const isPublic = ref(false);
|
const isPublic = ref(false);
|
||||||
const name = ref('');
|
const name = ref('');
|
||||||
|
const membershipsPagination = {
|
||||||
|
endpoint: 'users/lists/get-memberships' as const,
|
||||||
|
limit: 30,
|
||||||
|
params: computed(() => ({
|
||||||
|
listId: props.listId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
function fetchList() {
|
function fetchList() {
|
||||||
fetching = true;
|
|
||||||
os.api('users/lists/show', {
|
os.api('users/lists/show', {
|
||||||
listId: props.listId,
|
listId: props.listId,
|
||||||
}).then(_list => {
|
}).then(_list => {
|
||||||
list = _list;
|
list = _list;
|
||||||
name.value = list.name;
|
name.value = list.name;
|
||||||
isPublic.value = list.isPublic;
|
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,
|
listId: list.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
users.push(user);
|
paginationEl.value.reload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeUser(user, ev) {
|
async function removeUser(item, ev) {
|
||||||
os.popupMenu([{
|
os.popupMenu([{
|
||||||
text: i18n.ts.remove,
|
text: i18n.ts.remove,
|
||||||
icon: 'ti ti-x',
|
icon: 'ti ti-x',
|
||||||
|
@ -128,9 +121,28 @@ async function removeUser(user, ev) {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
os.api('users/lists/pull', {
|
os.api('users/lists/pull', {
|
||||||
listId: list.id,
|
listId: list.id,
|
||||||
userId: user.id,
|
userId: item.userId,
|
||||||
}).then(() => {
|
}).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);
|
}], ev.currentTarget ?? ev.target);
|
||||||
|
@ -202,6 +214,12 @@ definePageMetadata(computed(() => list ? {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.more {
|
.more {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
Loading…
Reference in a new issue