This commit is contained in:
かっこかり 2024-09-17 18:46:24 +09:00 committed by GitHub
commit 781570e3e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 192 additions and 36 deletions

View File

@ -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: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能

12
locales/index.d.ts vendored
View File

@ -5084,6 +5084,18 @@ export interface Locale extends ILocale {
*
*/
"clipNoteLimitExceeded": string;
/**
*
*/
"pageViewCount": string;
/**
*
*/
"preferPopularUserFactor": string;
/**
*
*/
"preferPopularUserFactorDescription": string;
"_delivery": {
/**
*

View File

@ -1267,6 +1267,9 @@ fromX: "{x}から"
genEmbedCode: "埋め込みコードを生成"
noteOfThisUser: "このユーザーのノート一覧"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
pageViewCount: "ページ閲覧数"
preferPopularUserFactor: "人気のユーザーの算出基準"
preferPopularUserFactorDescription: "ページ閲覧数はローカルユーザーにのみ適用されます(リモートユーザーはフォロワー数で表示されます)。「無効」に設定すると、ローカル・リモートどちらの「人気のユーザー」セクションも表示されなくなります。"
_delivery:
status: "配信状態"

View File

@ -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"`);
}
}

View File

@ -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<typeof schema> { // 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 }>();
}
}

View File

@ -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<S> = {
required: (keyof S)[];
};
type MiAndOrmRepository<T extends ObjectLiteral> = Repository<T> & MiRepository<T>;
export function getJsonSchema<S extends Schema>(schema: S): ToJsonSchema<Unflatten<ChartResult<S>>> {
const unflatten = (str: string, parent: Record<string, any>) => {
const keys = str.split('.');
@ -146,11 +148,10 @@ export default abstract class Chart<T extends Schema> {
group: string | null;
}[] = [];
// ↓にしたいけどfindOneとかで型エラーになる
//private repositoryForHour: Repository<RawRecord<T>> & MiRepository<RawRecord<T>>;
//private repositoryForDay: Repository<RawRecord<T>> & MiRepository<RawRecord<T>>;
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<RawRecord<T>>;
//private repositoryForDay: MiAndOrmRepository<RawRecord<T>>;
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<T extends Schema> {
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<T extends Schema> {
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<T extends Schema> {
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

View File

@ -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;

View File

@ -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';
}

View File

@ -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,

View File

@ -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<typeof meta, typeof paramDef> { // eslint-
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
urlPreviewUserAgent: instance.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
preferPopularUserFactor: instance.preferPopularUserFactor,
};
});
}

View File

@ -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<typeof meta, typeof paramDef> { // 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);

View File

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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' });
});

View File

@ -53,7 +53,7 @@ const popularUsers: Paging = {
params: {
state: 'alive',
origin: 'local',
sort: '+follower',
sort: '+pv',
},
};
</script>

View File

@ -50,11 +50,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.impressumDescription }}</template>
</MkInput>
<FormSection>
<template #label>{{ i18n.ts.explore }}</template>
<div class="_gaps_m">
<MkTextarea v-model="pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
</MkTextarea>
<MkRadios v-model="preferPopularUserFactor">
<template #label>{{ i18n.ts.preferPopularUserFactor }}</template>
<template #caption>{{ i18n.ts.preferPopularUserFactorDescription }}</template>
<option value="follower">{{ i18n.ts.followersCount }}</option>
<option value="pv">{{ i18n.ts.pageViewCount }}</option>
<option value="none">{{ i18n.ts.disabled }}</option>
</MkRadios>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.files }}</template>
@ -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<string | null>(null);
const shortName = ref<string | null>(null);
@ -249,6 +265,7 @@ const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
const urlPreviewRequireContentLength = ref<boolean>(true);
const urlPreviewUserAgent = ref<string | null>(null);
const urlPreviewSummaryProxyUrl = ref<string | null>(null);
const preferPopularUserFactor = ref<MisskeyEntities.MetaLite['preferPopularUserFactor']>('follower');
async function init(): Promise<void> {
const meta = await misskeyApi('admin/meta');
@ -278,6 +295,7 @@ async function init(): Promise<void> {
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);

View File

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<MkUserList :pagination="pinnedUsers"/>
</MkFoldableSection>
<MkFoldableSection class="_margin" persistKey="explore-popular-users">
<MkFoldableSection v-if="instance.preferPopularUserFactor !== 'none'" class="_margin" persistKey="explore-popular-users">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsers"/>
</MkFoldableSection>
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFoldableSection>
<template v-if="tag == null">
<MkFoldableSection class="_margin">
<MkFoldableSection v-if="instance.preferPopularUserFactor !== 'none'" class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsersF"/>
</MkFoldableSection>
@ -69,7 +69,9 @@ import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import type { Paging } from '@/components/MkPagination.vue';
const props = defineProps<{
tag?: string;
@ -94,34 +96,47 @@ const tagUsers = computed(() => ({
},
}));
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
function createUserQueryPaging(params: Misskey.Endpoints['users']['req']): Paging {
return {
endpoint: 'users',
limit: 10,
noPaging: true,
params,
};
}
const pinnedUsers = {
endpoint: 'pinned-users',
noPaging: true,
limit: 10,
} satisfies Paging;
const popularUsers = createUserQueryPaging({
state: 'alive',
origin: 'local',
sort: '+follower',
} };
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
sort: instance.preferPopularUserFactor === 'pv' ? '+pv' : '+follower',
});
const recentlyUpdatedUsers = createUserQueryPaging({
origin: 'local',
sort: '+updatedAt',
} };
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
});
const recentlyRegisteredUsers = createUserQueryPaging({
origin: 'local',
state: 'alive',
sort: '+createdAt',
} };
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
});
const popularUsersF = createUserQueryPaging({
state: 'alive',
origin: 'remote',
sort: '+follower',
} };
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
sort: '+follower', // pv
});
const recentlyUpdatedUsersF = createUserQueryPaging({
origin: 'combined',
sort: '+updatedAt',
} };
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
});
const recentlyRegisteredUsersF = createUserQueryPaging({
origin: 'combined',
sort: '+createdAt',
} };
});
misskeyApi('hashtags/list', {
sort: '+attachedLocalUsers',

View File

@ -4935,6 +4935,8 @@ export type components = {
translatorAvailable: boolean;
mediaProxy: string;
enableUrlPreview: boolean;
/** @enum {string} */
preferPopularUserFactor: 'follower' | 'pv' | 'none';
backgroundImageUrl: string | null;
impressumUrl: string | null;
logoImageUrl: string | null;
@ -5137,6 +5139,8 @@ export type operations = {
urlPreviewRequireContentLength: boolean;
urlPreviewUserAgent: string | null;
urlPreviewSummaryProxyUrl: string | null;
/** @enum {string} */
preferPopularUserFactor: 'follower' | 'pv' | 'none';
};
};
};
@ -9386,6 +9390,8 @@ export type operations = {
urlPreviewRequireContentLength?: boolean;
urlPreviewUserAgent?: string | null;
urlPreviewSummaryProxyUrl?: string | null;
/** @enum {string} */
preferPopularUserFactor?: 'follower' | 'pv' | 'none';
};
};
};
@ -25005,7 +25011,7 @@ export type operations = {
/** @default 0 */
offset?: number;
/** @enum {string} */
sort?: '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt';
sort?: '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt' | '+pv' | '-pv';
/**
* @default all
* @enum {string}