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…
Reference in a new issue