feat: per user featured notes
This commit is contained in:
		
							parent
							
								
									adf9d9c969
								
							
						
					
					
						commit
						a5b6e807bb
					
				
					 11 changed files with 116 additions and 11 deletions
				
			
		| 
						 | 
				
			
			@ -21,10 +21,12 @@
 | 
			
		|||
### Changes
 | 
			
		||||
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
 | 
			
		||||
- API: notes/global-timeline は現在常に `[]` を返します
 | 
			
		||||
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
 | 
			
		||||
 | 
			
		||||
### General
 | 
			
		||||
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
 | 
			
		||||
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
 | 
			
		||||
- Feat: ユーザーごとのハイライト
 | 
			
		||||
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
 | 
			
		||||
- Enhance: モデレーションログ機能の強化
 | 
			
		||||
- Enhance: ローカリゼーションの更新
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,11 +5,12 @@
 | 
			
		|||
 | 
			
		||||
import { Inject, Injectable } from '@nestjs/common';
 | 
			
		||||
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 { bindThis } from '@/decorators.js';
 | 
			
		||||
 | 
			
		||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
 | 
			
		||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class FeaturedService {
 | 
			
		||||
| 
						 | 
				
			
			@ -78,10 +79,15 @@ export class FeaturedService {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	@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);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@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
 | 
			
		||||
	public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
 | 
			
		||||
		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'][]> {
 | 
			
		||||
		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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -729,9 +729,10 @@ export class NoteCreateService implements OnApplicationShutdown {
 | 
			
		|||
		// 30%の確率でハイライト用ランキング更新
 | 
			
		||||
		if (Math.random() < 0.3) {
 | 
			
		||||
			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) {
 | 
			
		||||
				this.featuredService.updateGlobalNotesRanking(renote.id, 1);
 | 
			
		||||
				this.featuredService.updateGlobalNotesRanking(renote.id, 5);
 | 
			
		||||
				this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -195,9 +195,10 @@ export class ReactionService {
 | 
			
		|||
		// 30%の確率でハイライト用ランキング更新
 | 
			
		||||
		if (Math.random() < 0.3 && note.userId !== user.id) {
 | 
			
		||||
			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) {
 | 
			
		||||
				this.featuredService.updateGlobalNotesRanking(note.id, 1);
 | 
			
		||||
				this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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_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_featuredNotes from './endpoints/users/featured-notes.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_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_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_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_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 };
 | 
			
		||||
| 
						 | 
				
			
			@ -1027,6 +1029,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 | 
			
		|||
		$users_following,
 | 
			
		||||
		$users_gallery_posts,
 | 
			
		||||
		$users_getFrequentlyRepliedUsers,
 | 
			
		||||
		$users_featuredNotes,
 | 
			
		||||
		$users_lists_create,
 | 
			
		||||
		$users_lists_delete,
 | 
			
		||||
		$users_lists_list,
 | 
			
		||||
| 
						 | 
				
			
			@ -1371,6 +1374,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 | 
			
		|||
		$users_following,
 | 
			
		||||
		$users_gallery_posts,
 | 
			
		||||
		$users_getFrequentlyRepliedUsers,
 | 
			
		||||
		$users_featuredNotes,
 | 
			
		||||
		$users_lists_create,
 | 
			
		||||
		$users_lists_delete,
 | 
			
		||||
		$users_lists_list,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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_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_featuredNotes from './endpoints/users/featured-notes.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_list from './endpoints/users/lists/list.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -672,6 +673,7 @@ const eps = [
 | 
			
		|||
	['users/following', ep___users_following],
 | 
			
		||||
	['users/gallery/posts', ep___users_gallery_posts],
 | 
			
		||||
	['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
 | 
			
		||||
	['users/featured-notes', ep___users_featuredNotes],
 | 
			
		||||
	['users/lists/create', ep___users_lists_create],
 | 
			
		||||
	['users/lists/delete', ep___users_lists_delete],
 | 
			
		||||
	['users/lists/list', ep___users_lists_list],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,7 +32,7 @@ export const paramDef = {
 | 
			
		|||
	type: 'object',
 | 
			
		||||
	properties: {
 | 
			
		||||
		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' },
 | 
			
		||||
	},
 | 
			
		||||
	required: [],
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +69,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			}
 | 
			
		||||
 | 
			
		||||
			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')
 | 
			
		||||
				.where('note.id IN (:...noteIds)', { noteIds: noteIds })
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -102,7 +102,6 @@ let searchKey = $ref('');
 | 
			
		|||
const featuredPagination = $computed(() => ({
 | 
			
		||||
	endpoint: 'notes/featured' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	offsetMode: true,
 | 
			
		||||
	params: {
 | 
			
		||||
		channelId: props.channelId,
 | 
			
		||||
	},
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,6 @@ import { i18n } from '@/i18n.js';
 | 
			
		|||
const paginationForNotes = {
 | 
			
		||||
	endpoint: 'notes/featured' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	offsetMode: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const paginationForPolls = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -131,7 +131,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
					<XFiles :key="user.id" :user="user"/>
 | 
			
		||||
					<XActivity :key="user.id" :user="user"/>
 | 
			
		||||
				</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 v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
 | 
			
		||||
| 
						 | 
				
			
			@ -210,7 +213,7 @@ watch($$(moderationNote), async () => {
 | 
			
		|||
});
 | 
			
		||||
 | 
			
		||||
const pagination = {
 | 
			
		||||
	endpoint: 'users/notes' as const,
 | 
			
		||||
	endpoint: 'users/featured-notes' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: computed(() => ({
 | 
			
		||||
		userId: props.user.id,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue