merge: Add "can trend" role policy (resolves #1050, #1051, and #1052) (!1010)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1010

Closes #1050, #1051, and #1052

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-05-15 08:40:19 +00:00
commit 33029e3b58
19 changed files with 205 additions and 61 deletions

12
locales/index.d.ts vendored
View file

@ -7599,6 +7599,10 @@ export interface Locale extends ILocale {
* Maximum number of scheduled notes * Maximum number of scheduled notes
*/ */
"scheduleNoteMax": string; "scheduleNoteMax": string;
/**
* Can appear in trending notes / users
*/
"canTrend": string;
}; };
"_condition": { "_condition": {
/** /**
@ -13045,6 +13049,14 @@ export interface Locale extends ILocale {
* Note: the bubble timeline is hidden by default, and must be enabled via roles. * Note: the bubble timeline is hidden by default, and must be enabled via roles.
*/ */
"bubbleTimelineMustBeEnabled": string; "bubbleTimelineMustBeEnabled": string;
/**
* Users popular on the global network
*/
"popularUsersGlobal": string;
/**
* Users popular on {name}
*/
"popularUsersLocal": ParameterizedString<"name">;
/** /**
* Translation timeout * Translation timeout
*/ */

View file

@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
export const GALLERY_POSTS_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( constructor(
@Inject(DI.redis) @Inject(DI.redis)
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
private readonly roleService: RoleService,
) { ) {
} }
@ -31,7 +34,14 @@ export class FeaturedService {
} }
@bindThis @bindThis
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> { private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise<void> {
if (userId) {
const policies = await this.roleService.getUserPolicies(userId);
if (!policies.canTrend) {
return;
}
}
const currentWindow = this.getCurrentWindow(windowRange); const currentWindow = this.getCurrentWindow(windowRange);
const redisTransaction = this.redisClient.multi(); const redisTransaction = this.redisClient.multi();
redisTransaction.zincrby( redisTransaction.zincrby(
@ -89,28 +99,28 @@ export class FeaturedService {
} }
@bindThis @bindThis
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> { public updateGlobalNotesRanking(note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
} }
@bindThis @bindThis
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> { public updateGalleryPostsRanking(galleryPost: Pick<MiGalleryPost, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId);
} }
@bindThis @bindThis
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> { public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
} }
@bindThis @bindThis
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> { public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick<MiNote, 'id'>, score = 1): Promise<void> {
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId);
} }
@bindThis @bindThis
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> { public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score); return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null);
} }
@bindThis @bindThis

View file

@ -592,6 +592,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!this.isRenote(note) || this.isQuote(note)) { if (!this.isRenote(note) || this.isQuote(note)) {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
} }
this.pushToTl(note, user); 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) { 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) { if (data.poll && data.poll.expiresAt) {
@ -814,8 +816,8 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private incRenoteCount(renote: MiNote) { private async incRenoteCount(renote: MiNote, user: MiUser) {
this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
renoteCount: () => '"renoteCount" + 1', renoteCount: () => '"renoteCount" + 1',
}) })
@ -823,15 +825,18 @@ export class NoteCreateService implements OnApplicationShutdown {
.execute(); .execute();
// 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { 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.channelId != null) {
if (renote.replyId == null) { if (renote.replyId == null) {
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
} }
} else { } else {
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
this.featuredService.updateGlobalNotesRanking(renote.id, 5); this.featuredService.updateGlobalNotesRanking(renote, 5);
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
}
} }
} }
} }

View file

@ -124,9 +124,11 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false); this.perUserNotesChart.update(user, note, false);
} }
if (note.renoteId && note.text || !note.renoteId) { if (!isRenote(note) || isQuote(note)) {
// Decrement notes count (user) // Decrement notes count (user)
this.decNotesCountOfUser(user); this.decNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
} }
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
@ -165,8 +167,11 @@ export class NoteDeleteService {
}); });
} }
if (note.uri) { const deletedUris = [note, ...cascadingNotes]
this.apLogService.deleteObjectLogs(note.uri) .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}'`)); .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
} }
} }

View file

@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown {
} }
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
// ハッシュタグ更新 // ハッシュタグ更新
this.pushToTl(note, user); this.pushToTl(note, user);

View file

@ -30,6 +30,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { CacheService } from '@/core/CacheService.js';
const FALLBACK = '\u2764'; const FALLBACK = '\u2764';
@ -102,6 +103,7 @@ export class ReactionService {
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private notificationService: NotificationService, private notificationService: NotificationService,
private perUserReactionsChart: PerUserReactionsChart, private perUserReactionsChart: PerUserReactionsChart,
private readonly cacheService: CacheService,
) { ) {
} }
@ -212,20 +214,28 @@ export class ReactionService {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if ( if (
Math.random() < 0.3 && Math.random() < 0.3 &&
note.userId !== user.id && note.userId !== user.id &&
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3 (Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
) { ) {
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.channelId != null) {
if (note.replyId == null) { if (note.replyId == null) {
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1); this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1);
} }
} else { } else {
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1); this.featuredService.updateGlobalNotesRanking(note, 1);
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1); this.featuredService.updatePerUserNotesRanking(note.userId, note, 1);
}
}
} }
} }
} }
@ -330,6 +340,8 @@ export class ReactionService {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
this.globalEventService.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id, userId: user.id,

View file

@ -69,6 +69,7 @@ export type RolePolicies = {
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
chatAvailability: 'available' | 'readonly' | 'unavailable'; chatAvailability: 'available' | 'readonly' | 'unavailable';
canTrend: boolean;
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@ -108,6 +109,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportMuting: true, canImportMuting: true,
canImportUserLists: true, canImportUserLists: true,
chatAvailability: 'available', chatAvailability: 'available',
canTrend: true,
}; };
@Injectable() @Injectable()
@ -149,6 +151,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
) { ) {
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
// TODO additional cache for final calculation?
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }
@ -358,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getUserAssigns(userId: MiUser['id']) { public async getUserAssigns(userOrId: MiUser | MiUser['id']) {
const now = Date.now(); const now = Date.now();
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
@ -367,12 +371,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getUserRoles(userId: MiUser['id']) { public async getUserRoles(userOrId: MiUser | MiUser['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); 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 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 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)); const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedRoles, ...matchedCondRoles]; return [...assignedRoles, ...matchedCondRoles];
} }
@ -381,8 +386,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
* *
*/ */
@bindThis @bindThis
public async getUserBadgeRoles(userId: MiUser['id']) { public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) {
const now = Date.now(); const now = Date.now();
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); 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 assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) { 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)); const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
} else { } else {
@ -401,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> { public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise<RolePolicies> {
const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; 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<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) { function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
if (roles.length === 0) return basePolicies[name]; 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)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
chatAvailability: calc('chatAvailability', aggregateChatAvailability), chatAvailability: calc('chatAvailability', aggregateChatAvailability),
canTrend: calc('canTrend', vs => vs.some(v => v === true)),
}; };
} }

View file

@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
optional: false, nullable: false, optional: false, nullable: false,
enum: ['available', 'readonly', 'unavailable'], enum: ['available', 'readonly', 'unavailable'],
}, },
canTrend: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新 // ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { 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); this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);

View file

@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新 // ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { 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); this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);

View file

@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = { export const meta = {
requireCredential: false, requireCredential: false,
@ -41,6 +42,7 @@ export const paramDef = {
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
trending: { type: 'boolean', default: false },
}, },
required: ['tag', 'sort'], required: ['tag', 'sort'],
} as const; } as const;
@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private readonly roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
@ -80,7 +83,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; 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' }); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
}); });

View file

@ -117,7 +117,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel')
.andWhere('user.isExplorable = TRUE');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);

View file

@ -4,11 +4,14 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; 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 { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import type { SelectQueryBuilder } from 'typeorm';
export const meta = { export const meta = {
tags: ['users'], tags: ['users'],
@ -38,7 +41,7 @@ export const paramDef = {
properties: { properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 }, 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' }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
hostname: { hostname: {
@ -59,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queryService: QueryService, private queryService: QueryService,
private readonly roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user') const query = this.usersRepository.createQueryBuilder('user')
@ -81,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
switch (ps.sort) { switch (ps.sort) {
case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
case '-follower': query.orderBy('user.followersCount', 'ASC'); 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', 'DESC'); break;
case '-createdAt': query.orderBy('user.id', 'ASC'); break; case '-createdAt': query.orderBy('user.id', 'ASC'); break;
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
@ -94,9 +100,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.limit(ps.limit); query.limit(ps.limit);
query.offset(ps.offset); 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' }); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
}); });
} }
private addLocalFollowers(query: SelectQueryBuilder<MiUser>) {
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"');
}
} }

View file

@ -176,6 +176,7 @@ export const ROLE_POLICIES = [
'canImportMuting', 'canImportMuting',
'canImportUserLists', 'canImportUserLists',
'chatAvailability', 'chatAvailability',
'canTrend',
] as const; ] as const;
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png'; export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png';

View file

@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange> </MkRange>
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
<template #suffix>
<span v-if="role.policies.canTrend.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canTrend.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canTrend)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canTrend.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canTrend.value" :disabled="role.policies.canTrend.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canTrend.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div> </div>
</FormSlot> </FormSlot>
</div> </div>

View file

@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template> <template #label>{{ i18n.ts.enable }}</template>
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
<template #suffix>{{ policies.canTrend ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canTrend">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
</div> </div>
</MkFolder> </MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>

View file

@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="tag == null"> <template v-if="tag == null">
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.tsx.popularUsersLocal({ name: instance.name ?? host }) }}</template>
<MkUserList :pagination="popularUsersLocalF"/>
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsersGlobal }}</template>
<MkUserList :pagination="popularUsersF"/> <MkUserList :pagination="popularUsersF"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
@ -65,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { watch, ref, useTemplateRef, computed } from 'vue'; import { watch, ref, useTemplateRef, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { host } from '@@/js/config';
import MkUserList from '@/components/MkUserList.vue'; import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue'; import MkTab from '@/components/MkTab.vue';
@ -73,7 +78,7 @@ import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
tag?: string; tag?: string | undefined;
}>(); }>();
const origin = ref('local'); const origin = ref('local');
@ -86,43 +91,48 @@ watch(() => props.tag, () => {
}); });
const tagUsers = computed(() => ({ const tagUsers = computed(() => ({
endpoint: 'hashtags/users' as const, endpoint: 'hashtags/users',
limit: 30, limit: 30,
params: { params: {
tag: props.tag, tag: props.tag,
origin: 'combined', origin: 'combined',
sort: '+follower', sort: '+follower',
}, },
})); } as const));
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; const pinnedUsers = { endpoint: 'pinned-users', limit: 10, noPaging: true } as const;
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive', state: 'alive',
origin: 'local', origin: 'local',
sort: '+follower', sort: '+follower',
} }; } } as const;
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local', origin: 'local',
sort: '+updatedAt', sort: '+updatedAt',
} }; } } as const;
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local', origin: 'local',
state: 'alive', state: 'alive',
sort: '+createdAt', sort: '+createdAt',
} }; } } as const;
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive', state: 'alive',
origin: 'remote', origin: 'remote',
sort: '+follower', sort: '+follower',
} }; } } as const;
const popularUsersLocalF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'remote',
sort: '+localFollower',
} } as const;
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined', origin: 'combined',
sort: '+updatedAt', sort: '+updatedAt',
} }; } } as const;
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined', origin: 'combined',
sort: '+createdAt', sort: '+createdAt',
} }; } } as const;
misskeyApi('hashtags/list', { misskeyApi('hashtags/list', {
sort: '+attachedLocalUsers', sort: '+attachedLocalUsers',

View file

@ -5520,6 +5520,7 @@ export type components = {
scheduleNoteMax: number; scheduleNoteMax: number;
/** @enum {string} */ /** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable'; chatAvailability: 'available' | 'readonly' | 'unavailable';
canTrend: boolean;
}; };
ReversiGameLite: { ReversiGameLite: {
/** Format: id */ /** Format: id */
@ -21749,6 +21750,8 @@ export type operations = {
* @enum {string} * @enum {string}
*/ */
origin?: 'combined' | 'local' | 'remote'; origin?: 'combined' | 'local' | 'remote';
/** @default false */
trending?: boolean;
}; };
}; };
}; };
@ -31522,7 +31525,7 @@ export type operations = {
/** @default 0 */ /** @default 0 */
offset?: number; offset?: number;
/** @enum {string} */ /** @enum {string} */
sort?: '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt'; sort?: '+follower' | '-follower' | '+localFollower' | '-localFollower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt';
/** /**
* @default all * @default all
* @enum {string} * @enum {string}

View file

@ -239,6 +239,7 @@ _role:
canImportNotes: "Can import notes" canImportNotes: "Can import notes"
canUpdateBioMedia: "Allow users to edit their avatar or banner" canUpdateBioMedia: "Allow users to edit their avatar or banner"
scheduleNoteMax: "Maximum number of scheduled notes" scheduleNoteMax: "Maximum number of scheduled notes"
canTrend: "Can appear in trending notes / users"
_condition: _condition:
isLocked: "Private account" isLocked: "Private account"
isExplorable: "Account is discoverable" isExplorable: "Account is discoverable"
@ -565,5 +566,8 @@ bubbleTimeline: "Bubble timeline"
bubbleTimelineDescription: "Choose which instances should be displayed in the bubble." bubbleTimelineDescription: "Choose which instances should be displayed in the bubble."
bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, and must be enabled via roles." bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, and must be enabled via roles."
popularUsersGlobal: "Users popular on the global network"
popularUsersLocal: "Users popular on {name}"
translationTimeoutLabel: "Translation timeout" translationTimeoutLabel: "Translation timeout"
translationTimeoutCaption: "Timeout in milliseconds for translation API requests." translationTimeoutCaption: "Timeout in milliseconds for translation API requests."