diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index c3b60837cf..9e14c771c0 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -361,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserAssigns(userId: MiUser['id']) { + public async getUserAssigns(user: MiUser | MiUser['id']) { const now = Date.now(); + const userId = typeof(user) === 'object' ? user.id : user; let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); @@ -370,12 +371,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserRoles(userId: MiUser['id']) { + public async getUserRoles(userId: MiUser | MiUser['id']) { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const followStats = await this.cacheService.getFollowStats(userId); const assigns = await this.getUserAssigns(userId); const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; + const user = typeof(userId) === 'object' ? userId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedRoles, ...matchedCondRoles]; } @@ -384,8 +385,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { * 指定ユーザーのバッジロール一覧取得 */ @bindThis - public async getUserBadgeRoles(userId: MiUser['id']) { + public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) { const now = Date.now(); + const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId; let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); @@ -395,7 +397,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; + const user = typeof(userId) === 'object' ? userId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -404,7 +406,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserPolicies(userId: MiUser['id'] | null): Promise { + public async getUserPolicies(userId: MiUser | MiUser['id'] | null): Promise { const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; if (userId == null) return basePolicies; diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index eb2289960a..68c795de73 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js"; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { requireCredential: false, @@ -41,6 +42,7 @@ export const paramDef = { sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, + trending: { type: 'boolean', default: false }, }, required: ['tag', 'sort'], } as const; @@ -52,6 +54,7 @@ export default class extends Endpoint { // eslint- private usersRepository: UsersRepository, private userEntityService: UserEntityService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); @@ -80,7 +83,18 @@ export default class extends Endpoint { // eslint- case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; } - const users = await query.limit(ps.limit).getMany(); + let users = await query.limit(ps.limit).getMany(); + + // This is not ideal, for a couple of reasons: + // 1. It may return less than "limit" results. + // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early. + // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB. + if (ps.trending) { + const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const)); + users = usersWithRoles + .filter(([,p]) => p.canTrend) + .map(([u]) => u); + } return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 3ac4af4f1a..3b0ca553e5 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -10,6 +10,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import type { SelectQueryBuilder } from 'typeorm'; export const meta = { @@ -49,6 +50,7 @@ export const paramDef = { default: null, description: 'The local host is represented with `null`.', }, + trending: { type: 'boolean', default: false }, }, required: [], } as const; @@ -61,6 +63,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private queryService: QueryService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const query = this.usersRepository.createQueryBuilder('user') @@ -98,7 +101,18 @@ export default class extends Endpoint { // eslint- query.limit(ps.limit); query.offset(ps.offset); - const users = await query.getMany(); + let users = await query.getMany(); + + // This is not ideal, for a couple of reasons: + // 1. It may return less than "limit" results. + // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early. + // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB. + if (ps.trending) { + const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const)); + users = usersWithRoles + .filter(([,p]) => p.canTrend) + .map(([u]) => u); + } return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index cb1f5a3095..2e16cefe50 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -97,6 +97,7 @@ const tagUsers = computed(() => ({ tag: props.tag, origin: 'combined', sort: '+follower', + trending: true, }, })); @@ -105,33 +106,40 @@ const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { state: 'alive', origin: 'local', sort: '+follower', + trending: true, } }; const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { origin: 'local', sort: '+updatedAt', + trending: true, } }; const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { origin: 'local', state: 'alive', sort: '+createdAt', + trending: true, } }; const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { state: 'alive', origin: 'remote', sort: '+follower', + trending: true, } }; const popularUsersLocalF = { endpoint: 'users', limit: 10, noPaging: true, params: { state: 'alive', origin: 'remote', sort: '+localFollower', + trending: true, } }; const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { origin: 'combined', sort: '+updatedAt', + trending: true, } }; const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { origin: 'combined', sort: '+createdAt', + trending: true, } }; misskeyApi('hashtags/list', { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index e1dc765ee7..94509c945e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -21750,6 +21750,8 @@ export type operations = { * @enum {string} */ origin?: 'combined' | 'local' | 'remote'; + /** @default false */ + trending?: boolean; }; }; }; @@ -31539,6 +31541,8 @@ export type operations = { * @default null */ hostname?: string | null; + /** @default false */ + trending?: boolean; }; }; };