Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	package.json
#	packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
#	packages/frontend/package.json
This commit is contained in:
mattyatea 2023-10-23 19:29:34 +09:00
commit cc5037cb85
28 changed files with 886 additions and 740 deletions

View file

@ -18,15 +18,17 @@
- Feat: アイコンデコレーション機能 - Feat: アイコンデコレーション機能
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように - Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
## Client ### Client
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
https://misskey-hub.net/docs/advanced/publish-on-your-website.html https://misskey-hub.net/docs/advanced/publish-on-your-website.html
- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 - Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正
### Server ### Server
- Enhance: RedisへのTLのキャッシュをオフにできるように
- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正
- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 - Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正
- Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正
## 2023.10.2 ## 2023.10.2

1
locales/index.d.ts vendored
View file

@ -1224,6 +1224,7 @@ export interface Locale {
"manifestJsonOverride": string; "manifestJsonOverride": string;
"shortName": string; "shortName": string;
"shortNameDescription": string; "shortNameDescription": string;
"fanoutTimelineDescription": string;
}; };
"_accountMigration": { "_accountMigration": {
"moveFrom": string; "moveFrom": string;

View file

@ -1222,6 +1222,7 @@ _serverSettings:
manifestJsonOverride: "manifest.jsonのオーバーライド" manifestJsonOverride: "manifest.jsonのオーバーライド"
shortName: "略称" shortName: "略称"
shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。" shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。"
fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。"
_accountMigration: _accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行" moveFrom: "別のアカウントからこのアカウントに移行"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.11.0-beta.2-prismisskey.1", "version": "2023.11.0-beta.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,11 +18,11 @@
"build-assets": "node ./scripts/build-assets.mjs", "build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook", "build-storybook": "pnpm --filter frontend build-storybook",
"build-and-start": "cross-env NODE_ENV=development pnpm build && cross-env NODE_ENV=development pnpm start",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"init": "pnpm migrate", "init": "pnpm migrate",
"migrate": "cd packages/backend && pnpm migrate", "migrate": "cd packages/backend && pnpm migrate",
"revert": "cd packages/backend && pnpm revert",
"check:connect": "cd packages/backend && pnpm check:connect", "check:connect": "cd packages/backend && pnpm check:connect",
"migrateandstart": "pnpm migrate && pnpm start", "migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev", "watch": "pnpm dev",
@ -56,7 +56,7 @@
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "6.8.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.3.2", "cypress": "13.3.2",
"eslint": "8.51.0", "eslint": "8.52.0",
"start-server-and-test": "2.0.1" "start-server-and-test": "2.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EnableFtt1698041201306 {
name = 'EnableFtt1698041201306'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableFanoutTimeline" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFanoutTimeline"`);
}
}

View file

@ -10,6 +10,7 @@
"start": "node ./built/index.js", "start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js", "check:connect": "node ./check_connect.js",
"build": "swc src -d built -D", "build": "swc src -d built -D",
"watch:swc": "swc src -d built -D -w", "watch:swc": "swc src -d built -D -w",
@ -76,9 +77,9 @@
"@nestjs/testing": "10.2.7", "@nestjs/testing": "10.2.7",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.3.2", "@simplewebauthn/server": "8.3.2",
"@sinonjs/fake-timers": "11.2.1", "@sinonjs/fake-timers": "11.2.2",
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.93", "@swc/core": "1.3.94",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
@ -124,7 +125,7 @@
"nanoid": "5.0.2", "nanoid": "5.0.2",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.6", "nodemailer": "6.9.7",
"nsfwjs": "2.4.2", "nsfwjs": "2.4.2",
"oauth": "0.10.0", "oauth": "0.10.0",
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
@ -142,7 +143,7 @@
"qrcode": "1.5.3", "qrcode": "1.5.3",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.20.3", "re2": "1.20.4",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rename": "1.0.4", "rename": "1.0.4",
@ -155,7 +156,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.12", "systeminformation": "5.21.13",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
@ -216,7 +217,7 @@
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "6.8.0",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.51.0", "eslint": "8.52.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"execa": "8.0.1", "execa": "8.0.1",
"jest": "29.7.0", "jest": "29.7.0",

View file

@ -77,7 +77,13 @@ export interface MainEventTypes {
unreadAntenna: MiAntenna; unreadAntenna: MiAntenna;
readAllAnnouncements: undefined; readAllAnnouncements: undefined;
myTokenRegenerated: undefined; myTokenRegenerated: undefined;
signin: MiSignin; signin: {
id: MiSignin['id'];
createdAt: string;
ip: string;
headers: Record<string, any>;
success: boolean;
};
registryUpdated: { registryUpdated: {
scope?: string[]; scope?: string[];
key: string; key: string;

View file

@ -56,6 +56,7 @@ import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -216,6 +217,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private utilityService: UtilityService, private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
) { } ) { }
@bindThis @bindThis
@ -292,6 +294,18 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
// Check blocking
if (data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)) {
if (data.renote.userHost === null) {
if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
if (blocked) {
throw new Error('blocked');
}
}
}
}
// 返信対象がpublicではないならhomeにする // 返信対象がpublicではないならhomeにする
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home'; data.visibility = 'home';
@ -825,6 +839,7 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis @bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (!meta.enableFanoutTimeline) return;
const r = this.redisForTimelines.pipeline(); const r = this.redisForTimelines.pipeline();

View file

@ -40,7 +40,7 @@ export class QueryService {
) { ) {
} }
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> { public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
if (sinceId && untilId) { if (sinceId && untilId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });

View file

@ -7,10 +7,12 @@ import { Injectable } from '@nestjs/common';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiSignin } from '@/models/Signin.js'; import type { MiSignin } from '@/models/Signin.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@Injectable() @Injectable()
export class SigninEntityService { export class SigninEntityService {
constructor( constructor(
private idService: IdService,
) { ) {
} }
@ -18,7 +20,13 @@ export class SigninEntityService {
public async pack( public async pack(
src: MiSignin, src: MiSignin,
) { ) {
return src; return {
id: src.id,
createdAt: this.idService.parse(src.id).date.toISOString(),
ip: src.ip,
headers: src.headers,
success: src.success,
};
} }
} }

View file

@ -504,6 +504,11 @@ export class MiMeta {
}) })
public preservedUsernames: string[]; public preservedUsernames: string[];
@Column('boolean', {
default: true,
})
public enableFanoutTimeline: boolean;
@Column('integer', { @Column('integer', {
default: 300, default: 300,
}) })

View file

@ -106,11 +106,11 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
silencedHosts: { silencedHosts: {
type: "array", type: 'array',
optional: true, optional: true,
nullable: false, nullable: false,
items: { items: {
type: "string", type: 'string',
optional: false, optional: false,
nullable: false, nullable: false,
}, },
@ -291,6 +291,10 @@ export const meta = {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
}, },
enableFanoutTimeline: {
type: 'boolean',
optional: false, nullable: false,
},
perLocalUserUserTimelineCacheMax: { perLocalUserUserTimelineCacheMax: {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
@ -423,6 +427,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableIdenticonGeneration: instance.enableIdenticonGeneration, enableIdenticonGeneration: instance.enableIdenticonGeneration,
policies: { ...DEFAULT_POLICIES, ...instance.policies }, policies: { ...DEFAULT_POLICIES, ...instance.policies },
manifestJsonOverride: instance.manifestJsonOverride, manifestJsonOverride: instance.manifestJsonOverride,
enableFanoutTimeline: instance.enableFanoutTimeline,
perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,

View file

@ -121,6 +121,7 @@ export const paramDef = {
serverRules: { type: 'array', items: { type: 'string' } }, serverRules: { type: 'array', items: { type: 'string' } },
preservedUsernames: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } },
manifestJsonOverride: { type: 'string' }, manifestJsonOverride: { type: 'string' },
enableFanoutTimeline: { type: 'boolean' },
perLocalUserUserTimelineCacheMax: { type: 'integer' }, perLocalUserUserTimelineCacheMax: { type: 'integer' },
perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' },
perUserHomeTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' },
@ -491,6 +492,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.manifestJsonOverride = ps.manifestJsonOverride; set.manifestJsonOverride = ps.manifestJsonOverride;
} }
if (ps.enableFanoutTimeline !== undefined) {
set.enableFanoutTimeline = ps.enableFanoutTimeline;
}
if (ps.perLocalUserUserTimelineCacheMax !== undefined) { if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
} }

View file

@ -17,6 +17,8 @@ import { CacheService } from '@/core/CacheService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -75,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private funoutTimelineService: FunoutTimelineService, private funoutTimelineService: FunoutTimelineService,
private queryService: QueryService, private queryService: QueryService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private metaService: MetaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -85,163 +88,200 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.stlDisabled); throw new ApiError(meta.errors.stlDisabled);
} }
const [ const serverSettings = await this.metaService.fetch();
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 noteIds: string[]; if (serverSettings.enableFanoutTimeline) {
let shouldFallbackToDb = false; 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),
]);
if (ps.withFiles) { let noteIds: string[];
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ let shouldFallbackToDb = false;
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
} else if (ps.withReplies) {
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
} else {
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
shouldFallbackToDb = htlNoteIds.length === 0;
}
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
if (!shouldFallbackToDb) {
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');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
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) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
// TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} else { // fallback to db
const followees = await this.userFollowingService.getFollowees(me.id);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere(new Brackets(qb => {
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
} else {
qb.where('note.userId = :meId', { meId: me.id });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.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)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.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)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
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)');
}));
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
} else if (ps.withReplies) {
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
} else {
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
shouldFallbackToDb = htlNoteIds.length === 0;
} }
//#endregion
const timeline = await query.limit(ps.limit).getMany(); noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
process.nextTick(() => { shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me); if (!shouldFallbackToDb) {
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');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
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) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
// TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} else { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
}
} else {
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
} }
}); });
} }
private async getFromDb(ps: {
untilId: string | null,
sinceId: string | null,
limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean,
withReplies: boolean,
}, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
} else {
qb.where('note.userId = :meId', { meId: me.id });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.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)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.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)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
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)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
}
} }

View file

@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService, private cacheService: CacheService,
private funoutTimelineService: FunoutTimelineService, private funoutTimelineService: FunoutTimelineService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -79,112 +82,140 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.ltlDisabled); throw new ApiError(meta.errors.ltlDisabled);
} }
const [ const serverSettings = await this.metaService.fetch();
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let noteIds: string[]; if (serverSettings.enableFanoutTimeline) {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
if (ps.withFiles) { let noteIds: string[];
noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
} else {
const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
}
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) {
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');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
// TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
} else { // fallback to db
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
} else {
const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
} }
if (!ps.withReplies) { noteIds = noteIds.slice(0, ps.limit);
query.andWhere(new Brackets(qb => {
qb if (noteIds.length > 0) {
.where('note.replyId IS NULL') // 返信ではない const query = this.notesRepository.createQueryBuilder('note')
.orWhere(new Brackets(qb => { .where('note.id IN (:...noteIds)', { noteIds: noteIds })
qb // 返信だけど投稿者自身への返信 .innerJoinAndSelect('note.user', 'user')
.where('note.replyId IS NOT NULL') .leftJoinAndSelect('note.reply', 'reply')
.andWhere('note.replyUserId = note.userId'); .leftJoinAndSelect('note.renote', 'renote')
})); .leftJoinAndSelect('reply.user', 'replyUser')
})); .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
// TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
} else { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
} }
} else {
const timeline = await query.limit(ps.limit).getMany(); return await this.getFromDb({
untilId,
process.nextTick(() => { sinceId,
if (me) { limit: ps.limit,
this.activeUsersChart.read(me); withFiles: ps.withFiles,
} withReplies: ps.withReplies,
}); }, me);
return await this.noteEntityService.packMany(timeline, me);
} }
}); });
} }
private async getFromDb(ps: {
sinceId: string | null,
untilId: string | null,
limit: number,
withFiles: boolean,
withReplies: boolean,
}, me: MiLocalUser | null) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId)
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(timeline, me);
}
} }

View file

@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -63,144 +65,171 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private funoutTimelineService: FunoutTimelineService, private funoutTimelineService: FunoutTimelineService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private queryService: QueryService, private queryService: QueryService,
private metaService: MetaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const [ const serverSettings = await this.metaService.fetch();
followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); if (serverSettings.enableFanoutTimeline) {
noteIds = noteIds.slice(0, ps.limit); const [
followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
if (noteIds.length > 0) { let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
const query = this.notesRepository.createQueryBuilder('note') noteIds = noteIds.slice(0, ps.limit);
.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');
let timeline = await query.getMany(); if (noteIds.length > 0) {
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');
timeline = timeline.filter(note => { let timeline = await query.getMany();
if (note.userId === me.id) {
return true; timeline = timeline.filter(note => {
} if (note.userId === me.id) {
if (isUserRelated(note, userIdsWhoBlockingMe)) return false; return true;
if (isUserRelated(note, userIdsWhoMeMuting)) return false; }
if (note.renoteId) { if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; if (note.renoteId) {
if (ps.withRenotes === false) return false; if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
} }
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
return true; return true;
}); });
// TODO: フィルタした結果件数が足りなかった場合の対応 // TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1); timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
}); });
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} else { // fallback to db } else { // fallback to db
const followees = await this.userFollowingService.getFollowees(me.id); return await this.getFromDb({
untilId,
//#region Construct query sinceId,
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) limit: ps.limit,
.andWhere('note.channelId IS NULL') includeMyRenotes: ps.includeMyRenotes,
.innerJoinAndSelect('note.user', 'user') includeRenotedMyNotes: ps.includeRenotedMyNotes,
.leftJoinAndSelect('note.reply', 'reply') includeLocalRenotes: ps.includeLocalRenotes,
.leftJoinAndSelect('note.renote', 'renote') withFiles: ps.withFiles,
.leftJoinAndSelect('reply.user', 'replyUser') }, me);
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else {
query.andWhere('note.userId = :meId', { meId: me.id });
} }
} else {
query.andWhere(new Brackets(qb => { return await this.getFromDb({
qb untilId,
.where('note.replyId IS NULL') // 返信ではない sinceId,
.orWhere(new Brackets(qb => { limit: ps.limit,
qb // 返信だけど投稿者自身への返信 includeMyRenotes: ps.includeMyRenotes,
.where('note.replyId IS NOT NULL') includeRenotedMyNotes: ps.includeRenotedMyNotes,
.andWhere('note.replyUserId = note.userId'); includeLocalRenotes: ps.includeLocalRenotes,
})); withFiles: ps.withFiles,
})); }, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.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)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.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)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
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)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
} }
}); });
} }
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else {
query.andWhere('note.userId = :meId', { meId: me.id });
}
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.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)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.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)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
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)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
}
} }

View file

@ -59,11 +59,15 @@ class HomeTimelineChannel extends Channel {
if (!note.visibleUserIds!.includes(this.user!.id)) return; if (!note.visibleUserIds!.includes(this.user!.id)) return;
} }
// 関係ない返信は除外 if (note.reply) {
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (this.following[note.userId]?.withReplies) {
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
} }
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;

View file

@ -72,11 +72,16 @@ class HybridTimelineChannel extends Channel {
// Ignore notes from instances the user has muted // Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return; if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
// 関係ない返信は除外
if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) {
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
} }
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;

View file

@ -90,11 +90,15 @@ class UserListChannel extends Channel {
if (!note.visibleUserIds!.includes(this.user!.id)) return; if (!note.visibleUserIds!.includes(this.user!.id)) return;
} }
// 関係ない返信は除外 if (note.reply) {
if (note.reply && !this.membershipsMap[note.userId]?.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (this.membershipsMap[note.userId]?.withReplies) {
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
} }
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View file

@ -159,6 +159,10 @@ describe('Streaming', () => {
}); });
*/ */
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
// TODO
});
test('フォローしていないユーザーの投稿は流れない', async () => { test('フォローしていないユーザーの投稿は流れない', async () => {
const fired = await waitFire( const fired = await waitFire(
kyoko, 'homeTimeline', // kyoko:home kyoko, 'homeTimeline', // kyoko:home

View file

@ -93,6 +93,7 @@ describe('ActivityPub', () => {
const metaInitial = { const metaInitial = {
cacheRemoteFiles: true, cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true, cacheRemoteSensitiveFiles: true,
enableFanoutTimeline: true,
perUserHomeTimelineCacheMax: 100, perUserHomeTimelineCacheMax: 100,
perLocalUserUserTimelineCacheMax: 100, perLocalUserUserTimelineCacheMax: 100,
perRemoteUserUserTimelineCacheMax: 100, perRemoteUserUserTimelineCacheMax: 100,

View file

@ -26,8 +26,7 @@
"@tabler/icons-webfont": "2.37.0", "@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.4.0", "@vitejs/plugin-vue": "4.4.0",
"@vue-macros/reactivity-transform": "0.3.23", "@vue-macros/reactivity-transform": "0.3.23",
"@vue/compiler-sfc": "3.3.4", "@vue/compiler-sfc": "3.3.6",
"@vue/compiler-sfc": "3.3.5",
"astring": "1.8.6", "astring": "1.8.6",
"autosize": "6.0.1", "autosize": "6.0.1",
"broadcast-channel": "5.5.0", "broadcast-channel": "5.5.0",
@ -74,7 +73,7 @@
"v-code-diff": "1.7.1", "v-code-diff": "1.7.1",
"vanilla-tilt": "1.8.1", "vanilla-tilt": "1.8.1",
"vite": "4.5.0", "vite": "4.5.0",
"vue": "3.3.5", "vue": "3.3.6",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next" "vuedraggable": "next"
}, },
@ -113,11 +112,11 @@
"@typescript-eslint/eslint-plugin": "6.8.0", "@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "6.8.0",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.5", "@vue/runtime-core": "3.3.6",
"acorn": "8.10.0", "acorn": "8.10.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.3.2", "cypress": "13.3.2",
"eslint": "8.51.0", "eslint": "8.52.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"eslint-plugin-vue": "9.17.0", "eslint-plugin-vue": "9.17.0",
"fast-glob": "3.3.1", "fast-glob": "3.3.1",

View file

@ -175,7 +175,7 @@ export async function common(createVue: () => App<Element>) {
defaultStore.set('darkMode', isDeviceDarkmode()); defaultStore.set('darkMode', isDeviceDarkmode());
} }
window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
if (ColdDeviceStorage.get('syncDeviceDarkMode')) { if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
defaultStore.set('darkMode', mql.matches); defaultStore.set('darkMode', mql.matches);
} }

View file

@ -87,9 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection> </FormSection>
<FormSection> <FormSection>
<template #label>Timeline caching</template> <template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkSwitch v-model="enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}</template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</template>
</MkSwitch>
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number"> <MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax</template> <template #label>perLocalUserUserTimelineCacheMax</template>
</MkInput> </MkInput>
@ -165,6 +170,7 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false);
let swPublicKey: any = $ref(null); let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null); let swPrivateKey: any = $ref(null);
let enableFanoutTimeline: boolean = $ref(false);
let perLocalUserUserTimelineCacheMax: number = $ref(0); let perLocalUserUserTimelineCacheMax: number = $ref(0);
let perRemoteUserUserTimelineCacheMax: number = $ref(0); let perRemoteUserUserTimelineCacheMax: number = $ref(0);
let perUserHomeTimelineCacheMax: number = $ref(0); let perUserHomeTimelineCacheMax: number = $ref(0);
@ -185,6 +191,7 @@ async function init(): Promise<void> {
enableServiceWorker = meta.enableServiceWorker; enableServiceWorker = meta.enableServiceWorker;
swPublicKey = meta.swPublickey; swPublicKey = meta.swPublickey;
swPrivateKey = meta.swPrivateKey; swPrivateKey = meta.swPrivateKey;
enableFanoutTimeline = meta.enableFanoutTimeline;
perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax; perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax;
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax; perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax; perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
@ -206,6 +213,7 @@ async function save(): void {
enableServiceWorker, enableServiceWorker,
swPublicKey, swPublicKey,
swPrivateKey, swPrivateKey,
enableFanoutTimeline,
perLocalUserUserTimelineCacheMax, perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax, perUserHomeTimelineCacheMax,

View file

@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, nextTick } from 'vue'; import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue'; import MkLoading from '@/components/global/MkLoading.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
@ -120,9 +120,8 @@ const errorKV = ref<{
description: '', description: '',
}); });
const urlParams = new URLSearchParams(window.location.search); const url = ref<string | null>(null);
const url = urlParams.get('url'); const hash = ref<string | null>(null);
const hash = urlParams.get('hash');
const data = ref<{ const data = ref<{
type: 'plugin' | 'theme'; type: 'plugin' | 'theme';
@ -152,7 +151,7 @@ function goToMisskey(): void {
} }
async function fetch() { async function fetch() {
if (!url || !hash) { if (!url.value || !hash.value) {
errorKV.value = { errorKV.value = {
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title, title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description, description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
@ -161,8 +160,8 @@ async function fetch() {
return; return;
} }
const res = await os.api('fetch-external-resources', { const res = await os.api('fetch-external-resources', {
url, url: url.value,
hash, hash: hash.value,
}).catch((err) => { }).catch((err) => {
switch (err.id) { switch (err.id) {
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856': case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
@ -297,10 +296,17 @@ async function install() {
} }
} }
onMounted(() => { onActivated(() => {
const urlParams = new URLSearchParams(window.location.search);
url.value = urlParams.get('url');
hash.value = urlParams.get('hash');
fetch(); fetch();
}); });
onDeactivated(() => {
uiPhase.value = 'fetching';
});
const headerActions = computed(() => []); const headerActions = computed(() => []);
const headerTabs = computed(() => []); const headerTabs = computed(() => []);

View file

@ -26,7 +26,7 @@
"@types/node": "20.8.7", "@types/node": "20.8.7",
"@typescript-eslint/eslint-plugin": "6.8.0", "@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "6.8.0",
"eslint": "8.51.0", "eslint": "8.52.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-fetch-mock": "3.0.3", "jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.5.0", "jest-websocket-mock": "2.5.0",
@ -39,7 +39,7 @@
], ],
"dependencies": { "dependencies": {
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.93", "@swc/core": "1.3.94",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0" "reconnecting-websocket": "4.4.0"
} }

View file

@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "6.8.0",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.51.0", "eslint": "8.52.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },

File diff suppressed because it is too large Load diff