diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c727cea78..74f9a3945f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ ## Unreleased ### General -- +- Enhance: 人気ユーザーの算出基準を変更できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/653, https://github.com/MisskeyIO/misskey/pull/664, https://github.com/MisskeyIO/misskey/pull/728, https://github.com/MisskeyIO/misskey/pull/734, https://github.com/MisskeyIO/misskey/pull/737) ### Client - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 diff --git a/locales/index.d.ts b/locales/index.d.ts index b06e0f245b..f0faa3b9d4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5084,6 +5084,18 @@ export interface Locale extends ILocale { * これ以上このクリップにノートを追加できません。 */ "clipNoteLimitExceeded": string; + /** + * ページ閲覧数 + */ + "pageViewCount": string; + /** + * 人気のユーザーの算出基準 + */ + "preferPopularUserFactor": string; + /** + * ページ閲覧数はローカルユーザーにのみ適用されます(リモートユーザーはフォロワー数で表示されます)。「無効」に設定すると、ローカル・リモートどちらの「人気のユーザー」セクションも表示されなくなります。 + */ + "preferPopularUserFactorDescription": string; "_delivery": { /** * 配信状態 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 292569cc5a..a08bb3858f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1269,6 +1269,9 @@ fromX: "{x}から" genEmbedCode: "埋め込みコードを生成" noteOfThisUser: "このユーザーのノート一覧" clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" +pageViewCount: "ページ閲覧数" +preferPopularUserFactor: "人気のユーザーの算出基準" +preferPopularUserFactorDescription: "ページ閲覧数はローカルユーザーにのみ適用されます(リモートユーザーはフォロワー数で表示されます)。「無効」に設定すると、ローカル・リモートどちらの「人気のユーザー」セクションも表示されなくなります。" _delivery: status: "配信状態" diff --git a/packages/backend/migration/1720879227657-preferPopularUserFactor.js b/packages/backend/migration/1720879227657-preferPopularUserFactor.js new file mode 100644 index 0000000000..b70e5b5aa4 --- /dev/null +++ b/packages/backend/migration/1720879227657-preferPopularUserFactor.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class PreferPopularUserFactor1720879227657 { + name = 'PreferPopularUserFactor1720879227657' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "meta_preferPopularUserFactor_enum" AS ENUM('follower', 'pv', 'none')`) + await queryRunner.query(`ALTER TABLE "meta" ADD "preferPopularUserFactor" "meta_preferPopularUserFactor_enum" NOT NULL DEFAULT 'follower'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preferPopularUserFactor"`); + await queryRunner.query(`DROP TYPE "meta_preferPopularUserFactor_enum"`); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts index 31708fefa8..3ad90b0904 100644 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ b/packages/backend/src/core/chart/charts/per-user-pv.ts @@ -7,6 +7,7 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { MiUser } from '@/models/User.js'; import { AppLockService } from '@/core/AppLockService.js'; +import { addTime, dateUTC, subtractTime } from '@/misc/prelude/time.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import Chart from '../core.js'; @@ -52,4 +53,32 @@ export default class PerUserPvChart extends Chart { // eslint-dis 'pv.visitor': 1, }, user.id); } + + @bindThis + public async getUsersRanking(span: 'hour' | 'day', order: 'ASC' | 'DESC', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{ userId: string; count: number; }[]> { + const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate(); + const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never; + + const lt = dateUTC([y, m, d, h, _m, _s, _ms]); + + const gt = + span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') : + span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') : + new Error('not happen') as never; + + const repository = + span === 'hour' ? this.repositoryForHour : + span === 'day' ? this.repositoryForDay : + new Error('not happen') as never; + + // ログ取得 + return await repository.createQueryBuilder() + .select('"group" as "userId", sum("___upv_user" + "___upv_visitor") as "count"') + .where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) }) + .groupBy('"userId"') + .orderBy('"count"', order) + .offset(offset) + .limit(limit) + .getRawMany<{ userId: string, count: number }>(); + } } diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index af5485a46e..deae0bcfbb 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -15,7 +15,7 @@ import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { MiRepository, miRepository } from '@/models/_.js'; -import type { DataSource, Repository } from 'typeorm'; +import type { DataSource, ObjectLiteral, Repository } from 'typeorm'; const COLUMN_PREFIX = '___' as const; const UNIQUE_TEMP_COLUMN_PREFIX = 'unique_temp___' as const; @@ -94,6 +94,8 @@ type ToJsonSchema = { required: (keyof S)[]; }; +type MiAndOrmRepository = Repository & MiRepository; + export function getJsonSchema(schema: S): ToJsonSchema>> { const unflatten = (str: string, parent: Record) => { const keys = str.split('.'); @@ -146,11 +148,10 @@ export default abstract class Chart { group: string | null; }[] = []; // ↓にしたいけどfindOneとかで型エラーになる - //private repositoryForHour: Repository> & MiRepository>; - //private repositoryForDay: Repository> & MiRepository>; - private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; - private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; - + //private repositoryForHour: MiAndOrmRepository>; + //private repositoryForDay: MiAndOrmRepository>; + protected repositoryForHour: MiAndOrmRepository<{ id: number; group?: string | null; date: number; }>; + protected repositoryForDay: MiAndOrmRepository<{ id: number; group?: string | null; date: number; }>; /** * 1日に一回程度実行されれば良いような計算処理を入れる(主にCASCADE削除などアプリケーション側で感知できない変動によるズレの修正用) */ @@ -186,11 +187,11 @@ export default abstract class Chart { return columns; } - private static dateToTimestamp(x: Date): number { + protected static dateToTimestamp(x: Date): number { return Math.floor(x.getTime() / 1000); } - private static parseDate(date: Date): [number, number, number, number, number, number, number] { + protected static parseDate(date: Date): [number, number, number, number, number, number, number] { const y = date.getUTCFullYear(); const m = date.getUTCMonth(); const d = date.getUTCDate(); @@ -202,7 +203,7 @@ export default abstract class Chart { return [y, m, d, h, _m, _s, _ms]; } - private static getCurrentDate() { + protected static getCurrentDate() { return Chart.parseDate(new Date()); } @@ -276,8 +277,8 @@ export default abstract class Chart { this.logger = logger; const { hour, day } = Chart.schemaToEntity(name, schema, grouped); - this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); - this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); + this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour).extend(miRepository); + this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day).extend(miRepository); } @bindThis diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index f4b1e302d0..a8fea76916 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -130,6 +130,7 @@ export class MetaEntityService { enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', maxFileSize: this.config.maxFileSize, + preferPopularUserFactor: instance.preferPopularUserFactor, }; return packed; diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 70d41801b5..40c03bfb40 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -625,4 +625,10 @@ export class MiMeta { nullable: true, }) public urlPreviewUserAgent: string | null; + + @Column('enum', { + enum: ['follower', 'pv', 'none'], + default: 'follower', + }) + public preferPopularUserFactor: 'follower' | 'pv' | 'none'; } diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 99feeaa7d7..7bcbf82fe2 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -211,6 +211,11 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + preferPopularUserFactor: { + type: 'string', + optional: false, nullable: false, + enum: ['follower', 'pv', 'none'], + }, backgroundImageUrl: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2e7f73da73..feb6e40b06 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -491,6 +491,11 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + preferPopularUserFactor: { + type: 'string', + optional: false, nullable: false, + enum: ['follower', 'pv', 'none'], + }, }, }, } as const; @@ -625,6 +630,7 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + preferPopularUserFactor: instance.preferPopularUserFactor, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 5efdc9d8c4..859a8c5916 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -167,6 +167,10 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + preferPopularUserFactor: { + type: 'string', + enum: ['follower', 'pv', 'none'], + }, }, required: [], } as const; @@ -632,6 +636,10 @@ export default class extends Endpoint { // eslint- set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } + if (ps.preferPopularUserFactor !== undefined) { + set.preferPopularUserFactor = ps.preferPopularUserFactor; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index e845853017..3ba4f9e3f4 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; import { QueryService } from '@/core/QueryService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -31,7 +32,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', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+pv', '-pv'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { @@ -49,6 +50,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private perUserPvChart: PerUserPvChart, private userEntityService: UserEntityService, private queryService: QueryService, @@ -71,6 +73,15 @@ export default class extends Endpoint { // eslint- query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); } + let pvRankedUsers: { userId: string; count: number; }[] | undefined = undefined; + if (ps.sort?.endsWith('pv')) { + // 直近12時間のPVランキングを取得 + pvRankedUsers = await this.perUserPvChart.getUsersRanking( + 'hour', ps.sort.startsWith('+') ? 'DESC' : 'ASC', + 12, null, ps.limit, ps.offset, + ); + } + switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; @@ -78,6 +89,8 @@ export default class extends Endpoint { // eslint- 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', 'ASC'); break; + case '+pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break; + case '-pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break; default: query.orderBy('user.id', 'ASC'); break; } @@ -88,6 +101,19 @@ export default class extends Endpoint { // eslint- query.offset(ps.offset); const users = await query.getMany(); + if (ps.sort === '+pv') { + users.sort((a, b) => { + const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0; + const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0; + return bPv - aPv; + }); + } else if (ps.sort === '-pv') { + users.sort((a, b) => { + const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0; + const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0; + return aPv - bPv; + }); + } return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 1524ea0ec9..3e451519ee 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -53,7 +53,7 @@ const popularUsers: Paging = { params: { state: 'alive', origin: 'local', - sort: '+follower', + sort: '+pv', }, }; diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 6f45c212ec..0acc956984 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -50,10 +50,24 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - + + + +
+ + + + + + + + + + + + +
+
@@ -222,6 +236,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import { entities as MisskeyEntities } from 'misskey-js'; const name = ref(null); const shortName = ref(null); @@ -249,6 +265,7 @@ const urlPreviewMaximumContentLength = ref(1024 * 1024 * 10); const urlPreviewRequireContentLength = ref(true); const urlPreviewUserAgent = ref(null); const urlPreviewSummaryProxyUrl = ref(null); +const preferPopularUserFactor = ref('follower'); async function init(): Promise { const meta = await misskeyApi('admin/meta'); @@ -278,6 +295,7 @@ async function init(): Promise { urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength; urlPreviewUserAgent.value = meta.urlPreviewUserAgent; urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl; + preferPopularUserFactor.value = meta.preferPopularUserFactor; } async function save() { @@ -308,6 +326,7 @@ async function save() { urlPreviewRequireContentLength: urlPreviewRequireContentLength.value, urlPreviewUserAgent: urlPreviewUserAgent.value, urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value, + preferPopularUserFactor: preferPopularUserFactor.value, }); fetchInstance(true); diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index e9608ae94e..6c8ecd7ff8 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only