feat: per user featured notes
This commit is contained in:
parent
adf9d9c969
commit
a5b6e807bb
|
@ -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: ローカリゼーションの更新
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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(() => ({
|
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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue