feat: per user featured notes

This commit is contained in:
syuilo 2023-10-06 18:30:08 +09:00
parent adf9d9c969
commit a5b6e807bb
11 changed files with 116 additions and 11 deletions

View file

@ -21,10 +21,12 @@
### Changes ### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
- API: notes/global-timeline は現在常に `[]` を返します - API: notes/global-timeline は現在常に `[]` を返します
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
### General ### General
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました - Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました - Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
- Feat: ユーザーごとのハイライト
- Enhance: ソフトワードミュートとハードワードミュートは統合されました - Enhance: ソフトワードミュートとハードワードミュートは統合されました
- Enhance: モデレーションログ機能の強化 - Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新

View file

@ -5,11 +5,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { MiNote } from '@/models/_.js'; import type { 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';
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
@Injectable() @Injectable()
export class FeaturedService { export class FeaturedService {
@ -78,10 +79,15 @@ export class FeaturedService {
} }
@bindThis @bindThis
public updateInChannelNotesRanking(noteId: MiNote['id'], channelId: MiNote['channelId'], score = 1): Promise<void> { public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
} }
@bindThis
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
}
@bindThis @bindThis
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> { public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
@ -91,4 +97,9 @@ export class FeaturedService {
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> { public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit);
} }
@bindThis
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
}
} }

View file

@ -729,9 +729,10 @@ export class NoteCreateService implements OnApplicationShutdown {
// 30%の確率でハイライト用ランキング更新 // 30%の確率でハイライト用ランキング更新
if (Math.random() < 0.3) { if (Math.random() < 0.3) {
if (renote.channelId != null) { if (renote.channelId != null) {
this.featuredService.updateInChannelNotesRanking(renote.id, renote.channelId, 1); this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
} else if (renote.visibility === 'public' && renote.userHost == null) { } else if (renote.visibility === 'public' && renote.userHost == null) {
this.featuredService.updateGlobalNotesRanking(renote.id, 1); this.featuredService.updateGlobalNotesRanking(renote.id, 5);
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
} }
} }
} }

View file

@ -195,9 +195,10 @@ export class ReactionService {
// 30%の確率でハイライト用ランキング更新 // 30%の確率でハイライト用ランキング更新
if (Math.random() < 0.3 && note.userId !== user.id) { if (Math.random() < 0.3 && note.userId !== user.id) {
if (note.channelId != null) { if (note.channelId != null) {
this.featuredService.updateInChannelNotesRanking(note.id, note.channelId, 1); this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
} else if (note.visibility === 'public' && note.userHost == null) { } else if (note.visibility === 'public' && note.userHost == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1); this.featuredService.updateGlobalNotesRanking(note.id, 1);
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
} }
} }

View file

@ -325,6 +325,7 @@ import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
import * as ep___users_lists_list from './endpoints/users/lists/list.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js';
@ -674,6 +675,7 @@ const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep
const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default };
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default };
const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default };
@ -1027,6 +1029,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_following, $users_following,
$users_gallery_posts, $users_gallery_posts,
$users_getFrequentlyRepliedUsers, $users_getFrequentlyRepliedUsers,
$users_featuredNotes,
$users_lists_create, $users_lists_create,
$users_lists_delete, $users_lists_delete,
$users_lists_list, $users_lists_list,
@ -1371,6 +1374,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_following, $users_following,
$users_gallery_posts, $users_gallery_posts,
$users_getFrequentlyRepliedUsers, $users_getFrequentlyRepliedUsers,
$users_featuredNotes,
$users_lists_create, $users_lists_create,
$users_lists_delete, $users_lists_delete,
$users_lists_list, $users_lists_list,

View file

@ -325,6 +325,7 @@ import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
import * as ep___users_lists_list from './endpoints/users/lists/list.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js';
@ -672,6 +673,7 @@ const eps = [
['users/following', ep___users_following], ['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts], ['users/gallery/posts', ep___users_gallery_posts],
['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
['users/featured-notes', ep___users_featuredNotes],
['users/lists/create', ep___users_lists_create], ['users/lists/create', ep___users_lists_create],
['users/lists/delete', ep___users_lists_delete], ['users/lists/delete', ep___users_lists_delete],
['users/lists/list', ep___users_lists_list], ['users/lists/list', ep___users_lists_list],

View file

@ -32,7 +32,7 @@ export const paramDef = {
type: 'object', type: 'object',
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 }, untilId: { type: 'string', format: 'misskey:id' },
channelId: { type: 'string', nullable: true, format: 'misskey:id' }, channelId: { type: 'string', nullable: true, format: 'misskey:id' },
}, },
required: [], required: [],
@ -69,7 +69,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
noteIds.sort((a, b) => a > b ? -1 : 1); noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds.slice(ps.offset, ps.offset + ps.limit); if (ps.untilId) {
noteIds = noteIds.filter(id => id < ps.untilId!);
}
noteIds = noteIds.slice(0, ps.limit);
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })

View file

@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
allowGet: true,
cacheSec: 3600,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
untilId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private featuredService: FeaturedService,
) {
super(meta, paramDef, async (ps, me) => {
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
if (noteIds.length === 0) {
return [];
}
noteIds.sort((a, b) => a > b ? -1 : 1);
if (ps.untilId) {
noteIds = noteIds.filter(id => id < ps.untilId!);
}
noteIds = noteIds.slice(0, ps.limit);
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
// TODO: ミュート等考慮
return await this.noteEntityService.packMany(notes, me);
});
}
}

View file

@ -102,7 +102,6 @@ let searchKey = $ref('');
const featuredPagination = $computed(() => ({ const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const, endpoint: 'notes/featured' as const,
limit: 10, limit: 10,
offsetMode: true,
params: { params: {
channelId: props.channelId, channelId: props.channelId,
}, },

View file

@ -22,7 +22,6 @@ import { i18n } from '@/i18n.js';
const paginationForNotes = { const paginationForNotes = {
endpoint: 'notes/featured' as const, endpoint: 'notes/featured' as const,
limit: 10, limit: 10,
offsetMode: true,
}; };
const paginationForPolls = { const paginationForPolls = {

View file

@ -131,7 +131,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<XFiles :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</template> </template>
<MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/> <div v-if="!disableNotes">
<div style="margin-bottom: 8px;">{{ i18n.ts.featured }}</div>
<MkNotes :class="$style.tl" :noGap="true" :pagination="pagination"/>
</div>
</div> </div>
</div> </div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
@ -210,7 +213,7 @@ watch($$(moderationNote), async () => {
}); });
const pagination = { const pagination = {
endpoint: 'users/notes' as const, endpoint: 'users/featured-notes' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
userId: props.user.id, userId: props.user.id,