Compare commits

...

3 Commits

Author SHA1 Message Date
syuilo f0a2c3ce76 Update NoteCreateService.ts 2023-10-02 02:34:04 +09:00
syuilo c019e9cad5 wip 2023-10-02 02:27:28 +09:00
syuilo 167aaabf20 wip 2023-10-02 02:21:18 +09:00
11 changed files with 158 additions and 170 deletions

View File

@ -12,6 +12,16 @@
-->
## 2023.10.0
### NOTE
- muted_noteテーブルは使われなくなったため手動で削除を行ってください。
### Changes
- API: users/notes で fileType 指定はできなくなりました
### Server
- タイムライン取得時のパフォーマンスを改善
## 2023.9.3
### General
- Enhance: ノートの翻訳機能の利用可否をロールで設定可能に

View File

@ -478,16 +478,12 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user)
this.incNotesCountOfUser(user);
if (data.reply) {
if (data.visibility === 'public' || data.visibility === 'home') {
this.pushToTl(note, user);
} else if (data.visibility === 'followers') {
this.pushToTl(note, user);
} else if (data.visibility === 'specified') {
// TODO
} else {
if (data.visibility === 'public' || data.visibility === 'home') {
this.pushToTl(note, user);
} else if (data.visibility === 'followers') {
this.pushToTl(note, user);
} else if (data.visibility === 'specified') {
// TODO
}
}
this.antennaService.addNoteToAntennas(note, user);
@ -802,78 +798,104 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
// TODO: 休眠ユーザーを弾く
// TODO: チャンネルフォロー
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
},
select: ['followerId'],
});
let userLists = await this.userListJoiningsRepository.find({
where: {
userId: user.id,
},
select: ['userListId'],
});
const redisPipeline = this.redisClient.pipeline();
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
if (note.replyId && note.replyUserId !== note.userId) {
if (note.visibility === 'public' || note.visibility === 'home') {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
} else {
// TODO: 休眠ユーザーを弾く
// TODO: チャンネルフォロー
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
},
select: ['followerId'],
});
let userLists = await this.userListJoiningsRepository.find({
where: {
userId: user.id,
},
select: ['userListId'],
});
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
redisPipeline.xadd(
`homeTimeline:${following.followerId}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${following.followerId}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
}
if (note.visibility === 'followers') {
// TODO: 重そうだから何とかしたい Set 使う?
userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListId));
}
for (const userList of userLists) {
redisPipeline.xadd(
`userListTimeline:${userList.userListId}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userList.userListId}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
}
redisPipeline.xadd(
`homeTimeline:${following.followerId}`,
`homeTimeline:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${following.followerId}`,
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
}
if (note.visibility === 'followers') {
// TODO: 重そうだから何とかしたい Set 使う?
userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListId));
}
for (const userList of userLists) {
redisPipeline.xadd(
`userListTimeline:${userList.userListId}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
if (note.visibility === 'public' || note.visibility === 'home') {
redisPipeline.xadd(
`userListTimelineWithFiles:${userList.userListId}`,
`userTimeline:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
}
}
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '300',
'*',
'note', note.id);
}
redisPipeline.exec();
}

View File

@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -554,7 +553,6 @@ const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass:
const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default };
const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default };
const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
@ -907,7 +905,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking,
$i_importFollowing,
$i_importMuting,
@ -1254,7 +1251,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking,
$i_importFollowing,
$i_importMuting,

View File

@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -552,7 +551,6 @@ const eps = [
['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts],
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting],

View File

@ -68,9 +68,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let timeline: MiNote[] = [];
@ -103,15 +105,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
timeline = await query.getMany();
// ミュート等考慮
timeline = timeline.filter(note => {
// TODO: インスタンスミュートの考慮
// TODO: ブロックの考慮
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {

View File

@ -94,9 +94,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let timeline: MiNote[] = [];
@ -129,11 +131,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
timeline = await query.getMany();
// ミュート等考慮
timeline = timeline.filter(note => {
// TODO: インスタンスミュートの考慮
// TODO: ブロックの考慮
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {

View File

@ -5,12 +5,14 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import * as Redis from 'ioredis';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -50,9 +52,6 @@ export const paramDef = {
untilDate: { type: 'integer' },
includeMyRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false },
},
required: ['userId'],
@ -61,87 +60,63 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private getterService: GetterService,
private cacheService: CacheService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
// Lookup user
const user = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
let timeline: MiNote[] = [];
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.userId = :userId', { userId: user.id })
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisClient.xrevrange(
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : ps.withReplies ? `userTimelineWithReplies:${ps.userId}` : `userTimeline:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const isFollowing = me ? (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId) : false;
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('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('channel.isSensitive = false');
}));
timeline = await query.getMany();
this.queryService.generateVisibilityQuery(query, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me, user);
this.queryService.generateBlockedUserQuery(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.fileType != null) {
query.andWhere('note.fileIds != \'{}\'');
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType!) {
const i = ps.fileType!.indexOf(type);
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
timeline = timeline.filter(note => {
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (ps.withRenotes === false) return false;
}
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
}
}
if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
if (note.visibility === 'followers' && !isFollowing) return false;
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
return true;
});
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: user.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(timeline, me);
});

View File

@ -128,14 +128,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<XPhotos :key="user.id" :user="user"/>
<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>
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XPhotos :key="user.id" :user="user"/>
<XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
</div>
</div>
@ -182,7 +182,7 @@ function calcAge(birthdate: string): number {
return yearDiff;
}
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const props = withDefaults(defineProps<{

View File

@ -6,20 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkContainer :max-height="300" :foldable="true">
<template #icon><i class="ti ti-photo"></i></template>
<template #header>{{ i18n.ts.images }}</template>
<template #header>{{ i18n.ts.files }}</template>
<div :class="$style.root">
<MkLoading v-if="fetching"/>
<div v-if="!fetching && images.length > 0" :class="$style.stream">
<div v-if="!fetching && files.length > 0" :class="$style.stream">
<MkA
v-for="image in images"
:key="image.note.id + image.file.id"
v-for="file in files"
:key="file.note.id + file.file.id"
:class="$style.img"
:to="notePage(image.note)"
:to="notePage(file.note)"
>
<ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/>
<!-- TODO: 画像以外のファイルに対応 -->
<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
</MkA>
</div>
<p v-if="!fetching && images.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
<p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
</div>
</MkContainer>
</template>
@ -40,7 +41,7 @@ const props = defineProps<{
}>();
let fetching = $ref(true);
let images = $ref<{
let files = $ref<{
note: Misskey.entities.Note;
file: Misskey.entities.DriveFile;
}[]>([]);
@ -52,24 +53,15 @@ function thumbnail(image: Misskey.entities.DriveFile): string {
}
onMounted(() => {
const image = [
'image/jpeg',
'image/webp',
'image/avif',
'image/png',
'image/gif',
'image/apng',
'image/vnd.mozilla.apng',
];
os.api('users/notes', {
userId: props.user.id,
fileType: image,
withFiles: true,
excludeNsfw: defaultStore.state.nsfw !== 'ignore',
limit: 10,
limit: 15,
}).then(notes => {
for (const note of notes) {
for (const file of note.files) {
images.push({
files.push({
note,
file,
});

View File

@ -1381,10 +1381,6 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'i/get-word-muted-notes-count': {
req: TODO;
res: TODO;
};
'i/import-following': {
req: TODO;
res: TODO;

View File

@ -371,7 +371,6 @@ export type Endpoints = {
'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; };
'i/gallery/likes': { req: TODO; res: TODO; };
'i/gallery/posts': { req: TODO; res: TODO; };
'i/get-word-muted-notes-count': { req: TODO; res: TODO; };
'i/import-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; };