diff --git a/locales/index.d.ts b/locales/index.d.ts index df6efe0d6a..3316a679e0 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -7599,6 +7599,10 @@ export interface Locale extends ILocale { * Maximum number of scheduled notes */ "scheduleNoteMax": string; + /** + * Can appear in trending notes / users + */ + "canTrend": string; }; "_condition": { /** @@ -13045,6 +13049,14 @@ export interface Locale extends ILocale { * Note: the bubble timeline is hidden by default, and must be enabled via roles. */ "bubbleTimelineMustBeEnabled": string; + /** + * Users popular on the global network + */ + "popularUsersGlobal": string; + /** + * Users popular on {name} + */ + "popularUsersLocal": ParameterizedString<"name">; /** * Translation timeout */ diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index b3335e38da..cabbb46504 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -8,6 +8,7 @@ import * as Redis from 'ioredis'; import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと @@ -21,6 +22,8 @@ export class FeaturedService { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする + + private readonly roleService: RoleService, ) { } @@ -31,7 +34,14 @@ export class FeaturedService { } @bindThis - private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise { + private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise { + if (userId) { + const policies = await this.roleService.getUserPolicies(userId); + if (!policies.canTrend) { + return; + } + } + const currentWindow = this.getCurrentWindow(windowRange); const redisTransaction = this.redisClient.multi(); redisTransaction.zincrby( @@ -89,28 +99,28 @@ export class FeaturedService { } @bindThis - public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); + public updateGlobalNotesRanking(note: Pick, score = 1): Promise { + return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId); } @bindThis - public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise { - return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); + public updateGalleryPostsRanking(galleryPost: Pick, score = 1): Promise { + return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId); } @bindThis - public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); + public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick, score = 1): Promise { + return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId); } @bindThis - public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score); + public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick, score = 1): Promise { + return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId); } @bindThis public updateHashtagsRanking(hashtag: string, score = 1): Promise { - return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score); + return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null); } @bindThis diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index e961d4236c..097d657ba3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -592,6 +592,8 @@ export class NoteCreateService implements OnApplicationShutdown { if (!this.isRenote(note) || this.isQuote(note)) { // Increment notes count (user) this.incNotesCountOfUser(user); + } else { + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); } this.pushToTl(note, user); @@ -631,7 +633,7 @@ export class NoteCreateService implements OnApplicationShutdown { } if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) { - this.incRenoteCount(data.renote); + this.incRenoteCount(data.renote, user); } if (data.poll && data.poll.expiresAt) { @@ -814,8 +816,8 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private incRenoteCount(renote: MiNote) { - this.notesRepository.createQueryBuilder().update() + private async incRenoteCount(renote: MiNote, user: MiUser) { + await this.notesRepository.createQueryBuilder().update() .set({ renoteCount: () => '"renoteCount" + 1', }) @@ -823,15 +825,18 @@ export class NoteCreateService implements OnApplicationShutdown { .execute(); // 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新 - if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { - if (renote.channelId != null) { - if (renote.replyId == null) { - this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); - } - } else { - if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { - this.featuredService.updateGlobalNotesRanking(renote.id, 5); - this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); + if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { + const policies = await this.roleService.getUserPolicies(user); + if (policies.canTrend) { + if (renote.channelId != null) { + if (renote.replyId == null) { + this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5); + } + } else { + if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { + this.featuredService.updateGlobalNotesRanking(renote, 5); + this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5); + } } } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 9b6c4754d1..9ce8cb6731 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -124,9 +124,11 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (note.renoteId && note.text || !note.renoteId) { + if (!isRenote(note) || isQuote(note)) { // Decrement notes count (user) this.decNotesCountOfUser(user); + } else { + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); } if (this.meta.enableStatsForFederatedInstances) { @@ -165,8 +167,11 @@ export class NoteDeleteService { }); } - if (note.uri) { - this.apLogService.deleteObjectLogs(note.uri) + const deletedUris = [note, ...cascadingNotes] + .map(n => n.uri) + .filter((u): u is string => u != null); + if (deletedUris.length > 0) { + this.apLogService.deleteObjectLogs(deletedUris) .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`)); } } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index e9637c56c7..58233b90ee 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown { } } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + // ハッシュタグ更新 this.pushToTl(note, user); diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 0179b0680f..a93605d2db 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -30,6 +30,7 @@ import { trackPromise } from '@/misc/promise-tracker.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; +import { CacheService } from '@/core/CacheService.js'; const FALLBACK = '\u2764'; @@ -102,6 +103,7 @@ export class ReactionService { private apDeliverManagerService: ApDeliverManagerService, private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, + private readonly cacheService: CacheService, ) { } @@ -212,20 +214,28 @@ export class ReactionService { .execute(); } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + // 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新 if ( Math.random() < 0.3 && note.userId !== user.id && (Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3 ) { - if (note.channelId != null) { - if (note.replyId == null) { - this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1); - } - } else { - if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { - this.featuredService.updateGlobalNotesRanking(note.id, 1); - this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1); + const author = await this.cacheService.findUserById(note.userId); + if (author.isExplorable) { + const policies = await this.roleService.getUserPolicies(author); + if (policies.canTrend) { + if (note.channelId != null) { + if (note.replyId == null) { + this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1); + } + } else { + if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { + this.featuredService.updateGlobalNotesRanking(note, 1); + this.featuredService.updatePerUserNotesRanking(note.userId, note, 1); + } + } } } } @@ -330,6 +340,8 @@ export class ReactionService { .execute(); } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, userId: user.id, diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 039932b76d..d3c458eec7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -69,6 +69,7 @@ export type RolePolicies = { canImportMuting: boolean; canImportUserLists: boolean; chatAvailability: 'available' | 'readonly' | 'unavailable'; + canTrend: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -108,6 +109,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportMuting: true, canImportUserLists: true, chatAvailability: 'available', + canTrend: true, }; @Injectable() @@ -149,6 +151,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { ) { this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + // TODO additional cache for final calculation? this.redisForSub.on('message', this.onMessage); } @@ -358,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserAssigns(userId: MiUser['id']) { + public async getUserAssigns(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)); @@ -367,12 +371,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserRoles(userId: MiUser['id']) { + public async getUserRoles(userOrId: MiUser | MiUser['id']) { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); + const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId; const followStats = await this.cacheService.getFollowStats(userId); - const assigns = await this.getUserAssigns(userId); + const assigns = await this.getUserAssigns(userOrId); 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(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedRoles, ...matchedCondRoles]; } @@ -381,8 +386,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)); @@ -392,7 +398,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(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -401,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserPolicies(userId: MiUser['id'] | null): Promise { + public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise { const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; - if (userId == null) return basePolicies; + if (userOrId == null) return basePolicies; - const roles = await this.getUserRoles(userId); + const roles = await this.getUserRoles(userOrId); function calc(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) { if (roles.length === 0) return basePolicies[name]; @@ -465,6 +471,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), chatAvailability: calc('chatAvailability', aggregateChatAvailability), + canTrend: calc('canTrend', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 307c114c96..363be921ed 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = { optional: false, nullable: false, enum: ['available', 'readonly', 'unavailable'], }, + canTrend: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index e73110648c..ae8ad6c044 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -98,7 +98,7 @@ export default class extends Endpoint { // eslint- // ランキング更新 if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, 1); + await this.featuredService.updateGalleryPostsRanking(post, 1); } this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index b0fad1eff2..be0a5a5584 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -81,7 +81,7 @@ export default class extends Endpoint { // eslint- // ランキング更新 if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, -1); + await this.featuredService.updateGalleryPostsRanking(post, -1); } this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1); 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/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 02d572e89b..8ab9f72139 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -117,7 +117,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('note.channel', 'channel') + .andWhere('user.isExplorable = TRUE'); this.queryService.generateBlockedHostQueryForNote(query); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index ee5c44cedd..defd38fe96 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -4,11 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import { MiFollowing } from '@/models/_.js'; +import type { MiUser, UsersRepository } from '@/models/_.js'; 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 = { tags: ['users'], @@ -38,7 +41,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, + sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { @@ -59,6 +62,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') @@ -81,6 +85,8 @@ export default class extends Endpoint { // eslint- switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break; + case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'ASC'); break; case '+createdAt': query.orderBy('user.id', 'DESC'); break; case '-createdAt': query.orderBy('user.id', 'ASC'); break; case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; @@ -94,9 +100,29 @@ export default class extends Endpoint { // eslint- query.limit(ps.limit); query.offset(ps.offset); - const users = await query.getMany(); + const allUsers = 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. + const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const)); + const users = usersWithRoles + .filter(([,p]) => p.canTrend) + .map(([u]) => u); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); } + + private addLocalFollowers(query: SelectQueryBuilder) { + query.innerJoin(qb => { + return qb + .from(MiFollowing, 'f') + .addSelect('f."followeeId"') + .addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers') + .addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers') + .groupBy('"followeeId"'); + }, 'f', 'user.id = f."followeeId"'); + } } diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 8354e8b800..2865656157 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -176,6 +176,7 @@ export const ROLE_POLICIES = [ 'canImportMuting', 'canImportUserLists', 'chatAvailability', + 'canTrend', ] as const; export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png'; diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index bca619c2e1..68e6a99991 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ + + + + + + + + +
+
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 52b8240733..d29f1266ae 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + {{ i18n.ts._role.new }} diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 6375944edf..77fe21c33f 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only