Revert "feat: improve tl performance"

This commit is contained in:
Mar0xy 2023-10-03 20:21:26 +02:00
parent 38e35e1472
commit bf3d493d04
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
84 changed files with 960 additions and 2072 deletions

View file

@ -95,14 +95,6 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -105,16 +105,6 @@ redis:
# # You can specify more ioredis options... # # You can specify more ioredis options...
# #username: example-username # #username: example-username
#redisForTimelines:
# host: localhost
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# # You can specify more ioredis options...
# #username: example-username
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -95,14 +95,6 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -13,25 +13,11 @@
--> -->
## 2023.10.0 ## 2023.10.0
### NOTE
- muted_noteテーブルは使われなくなったため手動で削除を行ってください。
### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
- API: notes/global-timeline は現在常に `[]` を返します
### General
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
### Client ### Client
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に - Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server
- Enhance: タイムライン取得時のパフォーマンスを改善
## 2023.9.3 ## 2023.9.3
### General ### General
- Enhance: ノートの翻訳機能の利用可否をロールで設定可能に - Enhance: ノートの翻訳機能の利用可否をロールで設定可能に

View file

@ -116,14 +116,6 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

7
locales/index.d.ts vendored
View file

@ -1129,8 +1129,6 @@ export interface Locale {
"notificationRecieveConfig": string; "notificationRecieveConfig": string;
"mutualFollow": string; "mutualFollow": string;
"fileAttachedOnly": string; "fileAttachedOnly": string;
"showRepliesToOthersInTimeline": string;
"hideRepliesToOthersInTimeline": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;
@ -1721,6 +1719,11 @@ export interface Locale {
"muteWords": string; "muteWords": string;
"muteWordsDescription": string; "muteWordsDescription": string;
"muteWordsDescription2": string; "muteWordsDescription2": string;
"softDescription": string;
"hardDescription": string;
"soft": string;
"hard": string;
"mutedNotes": string;
}; };
"_instanceMute": { "_instanceMute": {
"instanceMuteDescription": string; "instanceMuteDescription": string;

View file

@ -1126,8 +1126,6 @@ edited: "編集済み"
notificationRecieveConfig: "通知の受信設定" notificationRecieveConfig: "通知の受信設定"
mutualFollow: "相互フォロー" mutualFollow: "相互フォロー"
fileAttachedOnly: "ファイル付きのみ" fileAttachedOnly: "ファイル付きのみ"
showRepliesToOthersInTimeline: "TLに他の人への返信を含める"
hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@ -1638,6 +1636,11 @@ _wordMute:
muteWords: "ミュートするワード" muteWords: "ミュートするワード"
muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。"
muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。"
softDescription: "指定した条件のノートをタイムラインから隠します。"
hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。"
soft: "ソフト"
hard: "ハード"
mutedNotes: "ミュートされたノート"
_instanceMute: _instanceMute:
instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのートとRenoteをミュートします。" instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのートとRenoteをミュートします。"

View file

@ -216,6 +216,4 @@ module.exports = {
maxWorkers: 1, // Make it use worker (that can be killed and restarted) maxWorkers: 1, // Make it use worker (that can be killed and restarted)
logHeapUsage: true, // To debug when out-of-memory happens on CI logHeapUsage: true, // To debug when out-of-memory happens on CI
workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
maxConcurrency: 32,
}; };

View file

@ -1,20 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WithReplies1696222183852 {
name = 'WithReplies1696222183852'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`);
}
}

View file

@ -1,11 +0,0 @@
export class UserListMembership1696323464251 {
name = 'UserListMembership1696323464251'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`);
}
}

View file

@ -1,17 +0,0 @@
export class Hibernation1696331570827 {
name = 'Hibernation1696331570827'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`);
await queryRunner.query(`ALTER TABLE "user" ADD "isHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerHibernated" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerHibernated"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isHibernated"`);
await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `);
}
}

View file

@ -70,19 +70,11 @@ const $redisForSub: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $redisForTimelines: Provider = {
provide: DI.redisForTimelines,
useFactory: (config: Config) => {
return new Redis.Redis(config.redisForTimelines);
},
inject: [DI.config],
};
@Global() @Global()
@Module({ @Module({
imports: [RepositoryModule], imports: [RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule],
}) })
export class GlobalModule implements OnApplicationShutdown { export class GlobalModule implements OnApplicationShutdown {
constructor( constructor(
@ -90,7 +82,6 @@ export class GlobalModule implements OnApplicationShutdown {
@Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForPub) private redisForPub: Redis.Redis,
@Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis,
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
) {} ) {}
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@ -107,7 +98,6 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisClient.disconnect(), this.redisClient.disconnect(),
this.redisForPub.disconnect(), this.redisForPub.disconnect(),
this.redisForSub.disconnect(), this.redisForSub.disconnect(),
this.redisForTimelines.disconnect(),
]); ]);
} }

View file

@ -47,7 +47,6 @@ type Source = {
redis: RedisOptionsSource; redis: RedisOptionsSource;
redisForPubsub?: RedisOptionsSource; redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource;
redisForTimelines?: RedisOptionsSource;
meilisearch?: { meilisearch?: {
host: string; host: string;
port: string; port: string;
@ -162,7 +161,6 @@ export type Config = {
redis: RedisOptions & RedisOptionsSource; redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
perChannelMaxNoteCacheCount: number; perChannelMaxNoteCacheCount: number;
perUserNotificationsMaxCount: number; perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number; deactivateAntennaThreshold: number;
@ -229,7 +227,6 @@ export function loadConfig(): Config {
redis, redis,
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
id: config.id, id: config.id,
proxy: config.proxy, proxy: config.proxy,
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,

View file

@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -42,8 +42,8 @@ export class AccountMoveService {
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
@ -215,40 +215,40 @@ export class AccountMoveService {
@bindThis @bindThis
public async updateLists(src: ThinUser, dst: MiUser): Promise<void> { public async updateLists(src: ThinUser, dst: MiUser): Promise<void> {
// Return if there is no list to be updated. // Return if there is no list to be updated.
const oldMemberships = await this.userListMembershipsRepository.find({ const oldJoinings = await this.userListJoiningsRepository.find({
where: { where: {
userId: src.id, userId: src.id,
}, },
}); });
if (oldMemberships.length === 0) return; if (oldJoinings.length === 0) return;
const existingUserListIds = await this.userListMembershipsRepository.find({ const existingUserListIds = await this.userListJoiningsRepository.find({
where: { where: {
userId: dst.id, userId: dst.id,
}, },
}).then(memberships => memberships.map(membership => membership.userListId)); }).then(joinings => joinings.map(joining => joining.userListId));
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map(); const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
// 重複しないようにIDを生成 // 重複しないようにIDを生成
const genId = (): string => { const genId = (): string => {
let id: string; let id: string;
do { do {
id = this.idService.genId(); id = this.idService.genId();
} while (newMemberships.has(id)); } while (newJoinings.has(id));
return id; return id;
}; };
for (const membership of oldMemberships) { for (const joining of oldJoinings) {
if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
newMemberships.set(genId(), { newJoinings.set(genId(), {
createdAt: new Date(), createdAt: new Date(),
userId: dst.id, userId: dst.id,
userListId: membership.userListId, userListId: joining.userListId,
}); });
} }
const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] })); const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
await this.userListMembershipsRepository.insert(arrayToInsert); await this.userListJoiningsRepository.insert(arrayToInsert);
// Have the proxy account follow the new account in the same way as UserListService.push // Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) { if (this.userEntityService.isRemoteUser(dst)) {

View file

@ -12,7 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown {
private antennas: MiAntenna[]; private antennas: MiAntenna[];
constructor( constructor(
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository, private antennasRepository: AntennasRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown {
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisClient.pipeline();
for (const antenna of matchedAntennas) { for (const antenna of matchedAntennas) {
redisPipeline.xadd( redisPipeline.xadd(
@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') { if (antenna.src === 'home') {
// TODO // TODO
} else if (antenna.src === 'list') { } else if (antenna.src === 'list') {
const listUsers = (await this.userListMembershipsRepository.findBy({ const listUsers = (await this.userListJoiningsRepository.findBy({
userListId: antenna.userListId!, userListId: antenna.userListId!,
})).map(x => x.userId); })).map(x => x.userId);

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: RedisKVCache<Set<string>>; public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>; public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>; public userFollowingsCache: RedisKVCache<Set<string>>;
public userFollowingChannelsCache: RedisKVCache<Set<string>>; public userFollowingChannelsCache: RedisKVCache<Set<string>>;
constructor( constructor(
@ -136,18 +136,12 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)),
}); });
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', { this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))),
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; toRedisConverter: (value) => JSON.stringify(Array.from(value)),
for (const x of xs) { fromRedisConverter: (value) => new Set(JSON.parse(value)),
obj[x.followeeId] = { withReplies: x.withReplies };
}
return obj;
}),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
}); });
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', { this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
@ -194,7 +188,6 @@ export class CacheService implements OnApplicationShutdown {
if (follower) follower.followingCount++; if (follower) follower.followingCount++;
const followee = this.userByIdCache.get(body.followeeId); const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++; if (followee) followee.followersCount++;
this.userFollowingsCache.delete(body.followerId);
break; break;
} }
default: default:

View file

@ -47,7 +47,6 @@ import { SignupService } from './SignupService.js';
import { WebAuthnService } from './WebAuthnService.js'; import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js'; import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js'; import { CacheService } from './CacheService.js';
import { UserService } from './UserService.js';
import { UserFollowingService } from './UserFollowingService.js'; import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js'; import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js'; import { UserListService } from './UserListService.js';
@ -176,7 +175,6 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
@ -308,7 +306,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserService,
UserFollowingService, UserFollowingService,
UserKeypairService, UserKeypairService,
UserListService, UserListService,
@ -433,7 +430,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserService,
$UserFollowingService, $UserFollowingService,
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
@ -559,7 +555,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService, WebAuthnService,
UserBlockingService, UserBlockingService,
CacheService, CacheService,
UserService,
UserFollowingService, UserFollowingService,
UserKeypairService, UserKeypairService,
UserListService, UserListService,
@ -683,7 +678,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService, $WebAuthnService,
$UserBlockingService, $UserBlockingService,
$CacheService, $CacheService,
$UserService,
$UserFollowingService, $UserFollowingService,
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,

View file

@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises'; import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { In, DataSource, IsNull, LessThan } from 'typeorm'; import { In, DataSource } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2'; import RE2 from 're2';
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@ -54,6 +54,8 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager { class NotificationManager {
@ -155,8 +157,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -173,8 +175,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.mutedNotesRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.channelsRepository) @Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository, private channelsRepository: ChannelsRepository,
@ -185,9 +187,6 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private idService: IdService, private idService: IdService,
@ -335,7 +334,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
if (data.channel) { if (data.channel) {
this.redisForTimelines.xadd( this.redisClient.xadd(
`channelTimeline:${data.channel.id}`, `channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*', '*',
@ -481,13 +480,26 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
if (data.visibility === 'public' || data.visibility === 'home') { // Word mute
this.pushToTl(note, user); mutedWordsCache.fetch(() => this.userProfilesRepository.find({
} else if (data.visibility === 'followers') { where: {
this.pushToTl(note, user); enableWordMute: true,
} else if (data.visibility === 'specified') { },
// TODO select: ['userId', 'mutedWords'],
} })).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
this.antennaService.addNoteToAntennas(note, user); this.antennaService.addNoteToAntennas(note, user);
@ -496,13 +508,11 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
if (data.reply == null) { if (data.reply == null) {
// TODO: キャッシュ
this.followingsRepository.findBy({ this.followingsRepository.findBy({
followeeId: user.id, followeeId: user.id,
notify: 'normal', notify: 'normal',
}).then(followings => { }).then(followings => {
for (const following of followings) { for (const following of followings) {
// TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', { this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id, noteId: note.id,
}, user.id); }, user.id);
@ -801,205 +811,6 @@ export class NoteCreateService implements OnApplicationShutdown {
return mentionedUsers; return mentionedUsers;
} }
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const redisPipeline = this.redisForTimelines.pipeline();
if (note.channelId) {
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
followeeId: note.channelId,
},
select: ['followerId'],
});
for (const channelFollowing of channelFollowings) {
redisPipeline.xadd(
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
} else {
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],
});
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue;
}
redisPipeline.xadd(
`homeTimeline:${following.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${following.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
}
redisPipeline.xadd(
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
{ // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
if (note.visibility === 'public' || note.visibility === 'home') {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
} else {
redisPipeline.xadd(
`userTimeline:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd(
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
}
}
}
if (Math.random() < 0.1) {
process.nextTick(() => {
this.checkHibernation(followings);
});
}
}
redisPipeline.exec();
}
@bindThis
public async checkHibernation(followings: MiFollowing[]) {
if (followings.length === 0) return;
const shuffle = (array: MiFollowing[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
// ランダムに最大1000件サンプリング
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
const hibernatedUsers = await this.usersRepository.find({
where: {
id: In(samples.map(x => x.followerId)),
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
},
select: ['id'],
});
if (hibernatedUsers.length > 0) {
this.usersRepository.update({
id: In(hibernatedUsers.map(x => x.id)),
}, {
isHibernated: true,
});
this.followingsRepository.update({
followerId: In(hibernatedUsers.map(x => x.id)),
}, {
isFollowerHibernated: true,
});
}
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.#shutdownController.abort(); this.#shutdownController.abort();

View file

@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js'; import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@ -49,6 +49,8 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager { class NotificationManager {
@ -149,8 +151,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -170,11 +172,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.channelsRepository) @Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository, private channelsRepository: ChannelsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.mutedNotesRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.noteThreadMutingsRepository) @Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository, private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@ -423,7 +422,7 @@ export class NoteEditService implements OnApplicationShutdown {
await this.notesRepository.update(oldnote.id, note); await this.notesRepository.update(oldnote.id, note);
if (data.channel) { if (data.channel) {
this.redisForTimelines.xadd( this.redisClient.xadd(
`channelTimeline:${data.channel.id}`, `channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*', '*',
@ -461,6 +460,27 @@ export class NoteEditService implements OnApplicationShutdown {
this.hashtagService.updateHashtags(user, tags); this.hashtagService.updateHashtags(user, tags);
} }
// Word mute
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
if (data.poll && data.poll.expiresAt) { if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now(); const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.add(note.id, { this.queueService.endedPollNotificationQueue.add(note.id, {
@ -471,14 +491,6 @@ export class NoteEditService implements OnApplicationShutdown {
}); });
} }
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
}
if (!silent) { if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
@ -639,163 +651,6 @@ export class NoteEditService implements OnApplicationShutdown {
this.index(note); this.index(note);
} }
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const redisPipeline = this.redisForTimelines.pipeline();
if (note.channelId) {
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
followeeId: note.channelId,
},
select: ['followerId'],
});
for (const channelFollowing of channelFollowings) {
redisPipeline.xadd(
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
} else {
// TODO: キャッシュ?
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
}
redisPipeline.xadd(
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
{ // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '100',
'*',
'note', note.id);
}
}
if (note.visibility === 'public' || note.visibility === 'home') {
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd(
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
} else {
redisPipeline.xadd(
`userTimeline:${user.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd(
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) {
redisPipeline.xadd(
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
}
}
}
}
}
redisPipeline.exec();
}
@bindThis
public async checkHibernation(followings: MiFollowing[]) {
if (followings.length === 0) return;
const shuffle = (array: MiFollowing[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
// ランダムに最大1000件サンプリング
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
const hibernatedUsers = await this.usersRepository.find({
where: {
id: In(samples.map(x => x.followerId)),
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
},
select: ['id'],
});
if (hibernatedUsers.length > 0) {
this.usersRepository.update({
id: In(hibernatedUsers.map(x => x.id)),
}, {
isHibernated: true,
});
}
}
@bindThis @bindThis
private isSensitive(note: Option, sensitiveWord: string[]): boolean { private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) { if (sensitiveWord.length > 0) {

View file

@ -99,19 +99,19 @@ export class NotificationService implements OnApplicationShutdown {
} }
if (recieveConfig?.type === 'following') { if (recieveConfig?.type === 'following') {
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId));
if (!isFollowing) { if (!isFollowing) {
return null; return null;
} }
} else if (recieveConfig?.type === 'follower') { } else if (recieveConfig?.type === 'follower') {
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId));
if (!isFollower) { if (!isFollower) {
return null; return null;
} }
} else if (recieveConfig?.type === 'mutualFollow') { } else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([ const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
]); ]);
if (!isFollowing && !isFollower) { if (!isFollowing && !isFollower) {
return null; return null;

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm'; import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder } from 'typeorm';
@ -23,6 +23,9 @@ export class QueryService {
@Inject(DI.channelFollowingsRepository) @Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@ -105,6 +108,39 @@ export class QueryService {
q.setParameters(blockedQuery.getParameters()); q.setParameters(blockedQuery.getParameters());
} }
@bindThis
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
if (me == null) {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@bindThis
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}
@bindThis @bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
@ -176,6 +212,32 @@ export class QueryService {
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
} }
@bindThis
public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<MiUser, 'id'> | null): void {
if (me == null) {
q.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');
}));
}));
} else if (!withReplies) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where('note.replyId IS NOT NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
}
@bindThis @bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.

View file

@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private cacheService: CacheService, private cacheService: CacheService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit {
}); });
for (const userList of userLists) { for (const userList of userLists) {
await this.userListMembershipsRepository.delete({ await this.userListJoiningsRepository.delete({
userListId: userList.id, userListId: userList.id,
userId: user.id, userId: user.id,
}); });

View file

@ -123,11 +123,7 @@ export class UserFollowingService implements OnModuleInit {
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if ( if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
followee.isLocked ||
(followeeProfile.carefulBot && follower.isBot) ||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true')
) {
let autoAccept = false; let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー // 鍵アカウントであっても、既にフォローされていた場合はスルー

View file

@ -5,10 +5,10 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { UserListMembershipsRepository } from '@/models/_.js'; import type { UserListJoiningsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import type { MiUserListMembership } from '@/models/UserListMembership.js'; import type { MiUserListJoining } from '@/models/UserListJoining.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -33,8 +33,8 @@ export class UserListService implements OnApplicationShutdown {
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
@ -46,7 +46,7 @@ export class UserListService implements OnApplicationShutdown {
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', { this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), fetcher: (key) => this.userListJoiningsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)), toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)),
}); });
@ -85,19 +85,19 @@ export class UserListService implements OnApplicationShutdown {
@bindThis @bindThis
public async addMember(target: MiUser, list: MiUserList, me: MiUser) { public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
const currentCount = await this.userListMembershipsRepository.countBy({ const currentCount = await this.userListJoiningsRepository.countBy({
userListId: list.id, userListId: list.id,
}); });
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new UserListService.TooManyUsersError(); throw new UserListService.TooManyUsersError();
} }
await this.userListMembershipsRepository.insert({ await this.userListJoiningsRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: new Date(),
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
} as MiUserListMembership); } as MiUserListJoining);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
@ -113,7 +113,7 @@ export class UserListService implements OnApplicationShutdown {
@bindThis @bindThis
public async removeMember(target: MiUser, list: MiUserList) { public async removeMember(target: MiUser, list: MiUserList) {
await this.userListMembershipsRepository.delete({ await this.userListJoiningsRepository.delete({
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
}); });
@ -122,24 +122,6 @@ export class UserListService implements OnApplicationShutdown {
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
} }
@bindThis
public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) {
const membership = await this.userListMembershipsRepository.findOneBy({
userId: target.id,
userListId: list.id,
});
if (membership == null) {
throw new Error('User is not a member of the list');
}
await this.userListMembershipsRepository.update({
id: membership.id,
}, {
withReplies: options.withReplies,
});
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.redisForSub.off('message', this.onMessage); this.redisForSub.off('message', this.onMessage);

View file

@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class UserService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
) {
}
@bindThis
public async updateLastActiveDate(user: MiUser): Promise<void> {
if (user.isHibernated) {
const result = await this.usersRepository.createQueryBuilder().update()
.set({
lastActiveDate: new Date(),
})
.where('id = :id', { id: user.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
const wokeUp = result.isHibernated;
if (wokeUp) {
this.usersRepository.update(user.id, {
isHibernated: false,
});
this.followingsRepository.update({
followerId: user.id,
}, {
isFollowerHibernated: false,
});
}
} else {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
}
}
}

View file

@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit {
} else if (meId === packedNote.userId) { } else if (meId === packedNote.userId) {
hide = false; hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) { } else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ // 自分の投稿に対するリプライ
hide = false; hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション // 自分へのメンション
hide = false; hide = false;
} else { } else {
// フォロワーかどうか // フォロワーかどうか
const isFollowing = await this.followingsRepository.exist({ const isFollowing = await this.followingsRepository.exist({
where: { where: {
followeeId: packedNote.userId, followeeId: packedNote.userId,

View file

@ -493,7 +493,6 @@ export class UserEntityService implements OnModuleInit {
isMuted: relation.isMuted, isMuted: relation.isMuted,
isRenoteMuted: relation.isRenoteMuted, isRenoteMuted: relation.isRenoteMuted,
notify: relation.following?.notify ?? 'none', notify: relation.following?.notify ?? 'none',
withReplies: relation.following?.withReplies ?? false,
} : {}), } : {}),
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; } as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;

View file

@ -5,12 +5,11 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable() @Injectable()
export class UserListEntityService { export class UserListEntityService {
@ -18,10 +17,8 @@ export class UserListEntityService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService,
) { ) {
} }
@ -31,7 +28,7 @@ export class UserListEntityService {
): Promise<Packed<'UserList'>> { ): Promise<Packed<'UserList'>> {
const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src });
const users = await this.userListMembershipsRepository.findBy({ const users = await this.userListJoiningsRepository.findBy({
userListId: userList.id, userListId: userList.id,
}); });
@ -43,18 +40,5 @@ export class UserListEntityService {
isPublic: userList.isPublic, isPublic: userList.isPublic,
}; };
} }
@bindThis
public async packMembershipsMany(
memberships: MiUserListMembership[],
) {
return Promise.all(memberships.map(async x => ({
id: x.id,
createdAt: x.createdAt.toISOString(),
userId: x.userId,
user: await this.userEntityService.pack(x.userId),
withReplies: x.withReplies,
})));
}
} }

View file

@ -10,7 +10,6 @@ export const DI = {
redis: Symbol('redis'), redis: Symbol('redis'),
redisForPub: Symbol('redisForPub'), redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'), redisForSub: Symbol('redisForSub'),
redisForTimelines: Symbol('redisForTimelines'),
//#region Repositories //#region Repositories
usersRepository: Symbol('usersRepository'), usersRepository: Symbol('usersRepository'),
@ -31,7 +30,7 @@ export const DI = {
userPublickeysRepository: Symbol('userPublickeysRepository'), userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'), userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'), userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListMembershipsRepository: Symbol('userListMembershipsRepository'), userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'), userIpsRepository: Symbol('userIpsRepository'),
usedUsernamesRepository: Symbol('usedUsernamesRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'),
@ -64,6 +63,7 @@ export const DI = {
promoNotesRepository: Symbol('promoNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'),
promoReadsRepository: Symbol('promoReadsRepository'), promoReadsRepository: Symbol('promoReadsRepository'),
relaysRepository: Symbol('relaysRepository'), relaysRepository: Symbol('relaysRepository'),
mutedNotesRepository: Symbol('mutedNotesRepository'),
channelsRepository: Symbol('channelsRepository'), channelsRepository: Symbol('channelsRepository'),
channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'),
channelFavoritesRepository: Symbol('channelFavoritesRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'),

View file

@ -9,7 +9,6 @@ import { MiUser } from './User.js';
@Entity('following') @Entity('following')
@Index(['followerId', 'followeeId'], { unique: true }) @Index(['followerId', 'followeeId'], { unique: true })
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
export class MiFollowing { export class MiFollowing {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@ -46,17 +45,6 @@ export class MiFollowing {
@JoinColumn() @JoinColumn()
public follower: MiUser | null; public follower: MiUser | null;
@Column('boolean', {
default: false,
})
public isFollowerHibernated: boolean;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 32, length: 32,

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
import { mutedNoteReasons } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import { MiUser } from './User.js';
@Entity('muted_note')
@Index(['noteId', 'userId'], { unique: true })
export class MiMutedNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: 'The note ID.',
})
public noteId: MiNote['id'];
@ManyToOne(type => MiNote, {
onDelete: 'CASCADE',
})
@JoinColumn()
public note: MiNote | null;
@Index()
@Column({
...id(),
comment: 'The user ID.',
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
/**
*
*/
@Index()
@Column('enum', {
enum: mutedNoteReasons,
comment: 'The reason of the MutedNote.',
})
public reason: typeof mutedNoteReasons[number];
}

View file

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -117,9 +117,9 @@ const $userListFavoritesRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $userListMembershipsRepository: Provider = { const $userListJoiningsRepository: Provider = {
provide: DI.userListMembershipsRepository, provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserListMembership), useFactory: (db: DataSource) => db.getRepository(MiUserListJoining),
inject: [DI.db], inject: [DI.db],
}; };
@ -315,6 +315,12 @@ const $relaysRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $mutedNotesRepository: Provider = {
provide: DI.mutedNotesRepository,
useFactory: (db: DataSource) => db.getRepository(MiMutedNote),
inject: [DI.db],
};
const $channelsRepository: Provider = { const $channelsRepository: Provider = {
provide: DI.channelsRepository, provide: DI.channelsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChannel), useFactory: (db: DataSource) => db.getRepository(MiChannel),
@ -421,7 +427,7 @@ const $noteEditRepository: Provider = {
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository, $userListFavoritesRepository,
$userListMembershipsRepository, $userListJoiningsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,
$usedUsernamesRepository, $usedUsernamesRepository,
@ -454,6 +460,7 @@ const $noteEditRepository: Provider = {
$promoNotesRepository, $promoNotesRepository,
$promoReadsRepository, $promoReadsRepository,
$relaysRepository, $relaysRepository,
$mutedNotesRepository,
$channelsRepository, $channelsRepository,
$channelFollowingsRepository, $channelFollowingsRepository,
$channelFavoritesRepository, $channelFavoritesRepository,
@ -488,7 +495,7 @@ const $noteEditRepository: Provider = {
$userPublickeysRepository, $userPublickeysRepository,
$userListsRepository, $userListsRepository,
$userListFavoritesRepository, $userListFavoritesRepository,
$userListMembershipsRepository, $userListJoiningsRepository,
$userNotePiningsRepository, $userNotePiningsRepository,
$userIpsRepository, $userIpsRepository,
$usedUsernamesRepository, $usedUsernamesRepository,
@ -521,6 +528,7 @@ const $noteEditRepository: Provider = {
$promoNotesRepository, $promoNotesRepository,
$promoReadsRepository, $promoReadsRepository,
$relaysRepository, $relaysRepository,
$mutedNotesRepository,
$channelsRepository, $channelsRepository,
$channelFollowingsRepository, $channelFollowingsRepository,
$channelFavoritesRepository, $channelFavoritesRepository,

View file

@ -187,11 +187,6 @@ export class MiUser {
}) })
public isExplorable: boolean; public isExplorable: boolean;
@Column('boolean', {
default: false,
})
public isHibernated: boolean;
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
@Column('boolean', { @Column('boolean', {
default: false, default: false,

View file

@ -8,14 +8,14 @@ import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiUserList } from './UserList.js'; import { MiUserList } from './UserList.js';
@Entity('user_list_membership') @Entity('user_list_joining')
@Index(['userId', 'userListId'], { unique: true }) @Index(['userId', 'userListId'], { unique: true })
export class MiUserListMembership { export class MiUserListJoining {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
comment: 'The created date of the UserListMembership.', comment: 'The created date of the UserListJoining.',
}) })
public createdAt: Date; public createdAt: Date;
@ -44,10 +44,4 @@ export class MiUserListMembership {
}) })
@JoinColumn() @JoinColumn()
public userList: MiUserList | null; public userList: MiUserList | null;
// タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', {
default: false,
})
public withReplies: boolean;
} }

View file

@ -28,6 +28,7 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js'; import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
@ -52,7 +53,7 @@ import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js'; import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js'; import { MiUserList } from '@/models/UserList.js';
import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserListJoining } from '@/models/UserListJoining.js';
import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js'; import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
@ -96,6 +97,7 @@ export {
MiInstance, MiInstance,
MiMeta, MiMeta,
MiModerationLog, MiModerationLog,
MiMutedNote,
MiMuting, MiMuting,
MiRenoteMuting, MiRenoteMuting,
MiNote, MiNote,
@ -121,7 +123,7 @@ export {
MiUserKeypair, MiUserKeypair,
MiUserList, MiUserList,
MiUserListFavorite, MiUserListFavorite,
MiUserListMembership, MiUserListJoining,
MiUserNotePining, MiUserNotePining,
MiUserPending, MiUserPending,
MiUserProfile, MiUserProfile,
@ -163,6 +165,7 @@ export type HashtagsRepository = Repository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance>; export type InstancesRepository = Repository<MiInstance>;
export type MetasRepository = Repository<MiMeta>; export type MetasRepository = Repository<MiMeta>;
export type ModerationLogsRepository = Repository<MiModerationLog>; export type ModerationLogsRepository = Repository<MiModerationLog>;
export type MutedNotesRepository = Repository<MiMutedNote>;
export type MutingsRepository = Repository<MiMuting>; export type MutingsRepository = Repository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting>; export type RenoteMutingsRepository = Repository<MiRenoteMuting>;
export type NotesRepository = Repository<MiNote>; export type NotesRepository = Repository<MiNote>;
@ -188,7 +191,7 @@ export type UserIpsRepository = Repository<MiUserIp>;
export type UserKeypairsRepository = Repository<MiUserKeypair>; export type UserKeypairsRepository = Repository<MiUserKeypair>;
export type UserListsRepository = Repository<MiUserList>; export type UserListsRepository = Repository<MiUserList>;
export type UserListFavoritesRepository = Repository<MiUserListFavorite>; export type UserListFavoritesRepository = Repository<MiUserListFavorite>;
export type UserListMembershipsRepository = Repository<MiUserListMembership>; export type UserListJoiningsRepository = Repository<MiUserListJoining>;
export type UserNotePiningsRepository = Repository<MiUserNotePining>; export type UserNotePiningsRepository = Repository<MiUserNotePining>;
export type UserPendingsRepository = Repository<MiUserPending>; export type UserPendingsRepository = Repository<MiUserPending>;
export type UserProfilesRepository = Repository<MiUserProfile>; export type UserProfilesRepository = Repository<MiUserProfile>;

View file

@ -283,10 +283,6 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string', type: 'string',
nullable: false, optional: true, nullable: false, optional: true,
}, },
withReplies: {
type: 'boolean',
nullable: false, optional: true,
},
//#endregion //#endregion
}, },
} as const; } as const;

View file

@ -36,6 +36,7 @@ import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMutedNote } from '@/models/MutedNote.js';
import { MiMuting } from '@/models/Muting.js'; import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
@ -61,7 +62,7 @@ import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js'; import { MiUserList } from '@/models/UserList.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserListJoining } from '@/models/UserListJoining.js';
import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js'; import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
@ -138,7 +139,7 @@ export const entities = [
MiUserPublickey, MiUserPublickey,
MiUserList, MiUserList,
MiUserListFavorite, MiUserListFavorite,
MiUserListMembership, MiUserListJoining,
MiUserNotePining, MiUserNotePining,
MiUserSecurityKey, MiUserSecurityKey,
MiUsedUsername, MiUsedUsername,
@ -174,6 +175,7 @@ export const entities = [
MiPromoNote, MiPromoNote,
MiPromoRead, MiPromoRead,
MiRelay, MiRelay,
MiMutedNote,
MiChannel, MiChannel,
MiChannelFollowing, MiChannelFollowing,
MiChannelFavorite, MiChannelFavorite,

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -25,6 +25,9 @@ export class CleanProcessorService {
@Inject(DI.userIpsRepository) @Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository, private userIpsRepository: UserIpsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository, private antennasRepository: AntennasRepository,
@ -45,6 +48,16 @@ export class CleanProcessorService {
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
}); });
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
// 使われてないアンテナを停止 // 使われてないアンテナを停止
if (this.config.deactivateAntennaThreshold > 0) { if (this.config.deactivateAntennaThreshold > 0) {
this.antennasRepository.update({ this.antennasRepository.update({

View file

@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { format as DateFormat } from 'date-fns'; import { format as DateFormat } from 'date-fns';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js'; import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, MiUser } from '@/models/_.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -29,8 +29,8 @@ export class ExportAntennasProcessorService {
@Inject(DI.antennasRepository) @Inject(DI.antennasRepository)
private antennsRepository: AntennasRepository, private antennsRepository: AntennasRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private driveService: DriveService, private driveService: DriveService,
private utilityService: UtilityService, private utilityService: UtilityService,
@ -65,9 +65,9 @@ export class ExportAntennasProcessorService {
for (const [index, antenna] of antennas.entries()) { for (const [index, antenna] of antennas.entries()) {
let users: MiUser[] | undefined; let users: MiUser[] | undefined;
if (antenna.userListId !== null) { if (antenna.userListId !== null) {
const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId }); const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId });
users = await this.usersRepository.findBy({ users = await this.usersRepository.findBy({
id: In(memberships.map(j => j.userId)), id: In(joinings.map(j => j.userId)),
}); });
} }
write(JSON.stringify({ write(JSON.stringify({

View file

@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; import type { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
@ -29,8 +29,8 @@ export class ExportUserListsProcessorService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private driveService: DriveService, private driveService: DriveService,
@ -61,9 +61,9 @@ export class ExportUserListsProcessorService {
const stream = fs.createWriteStream(path, { flags: 'a' }); const stream = fs.createWriteStream(path, { flags: 'a' });
for (const list of lists) { for (const list of lists) {
const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id }); const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id });
const users = await this.usersRepository.findBy({ const users = await this.usersRepository.findBy({
id: In(memberships.map(j => j.userId)), id: In(joinings.map(j => j.userId)),
}); });
for (const u of users) { for (const u of users) {

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
@ -33,8 +33,8 @@ export class ImportUserListsProcessorService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private idService: IdService, private idService: IdService,
@ -99,7 +99,7 @@ export class ImportUserListsProcessorService {
target = await this.remoteUserResolveService.resolveUser(username, host); target = await this.remoteUserResolveService.resolveUser(username, host);
} }
if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
this.userListService.addMember(target, list!, user); this.userListService.addMember(target, list!, user);
} catch (e) { } catch (e) {

View file

@ -205,6 +205,7 @@ 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_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.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_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_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -336,9 +337,7 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js'; import * as ep___users_flashs from './endpoints/users/flashs.js';
@ -556,6 +555,7 @@ 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_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_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_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_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_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 }; const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
@ -687,9 +687,7 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass:
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default };
const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default };
@ -911,6 +909,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites, $i_favorites,
$i_gallery_likes, $i_gallery_likes,
$i_gallery_posts, $i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking, $i_importBlocking,
$i_importFollowing, $i_importFollowing,
$i_importMuting, $i_importMuting,
@ -1042,9 +1041,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update, $users_lists_update,
$users_lists_favorite, $users_lists_favorite,
$users_lists_unfavorite, $users_lists_unfavorite,
$users_lists_createFromPublic, $users_lists_create_from_public,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_flashs, $users_flashs,
@ -1260,6 +1257,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_favorites, $i_favorites,
$i_gallery_likes, $i_gallery_likes,
$i_gallery_posts, $i_gallery_posts,
$i_getWordMutedNotesCount,
$i_importBlocking, $i_importBlocking,
$i_importFollowing, $i_importFollowing,
$i_importMuting, $i_importMuting,
@ -1388,9 +1386,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_update, $users_lists_update,
$users_lists_favorite, $users_lists_favorite,
$users_lists_unfavorite, $users_lists_unfavorite,
$users_lists_createFromPublic, $users_lists_create_from_public,
$users_lists_updateMembership,
$users_lists_getMemberships,
$users_notes, $users_notes,
$users_pages, $users_pages,
$users_flashs, $users_flashs,

View file

@ -14,7 +14,6 @@ import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js'; import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js'; import { ChannelsService } from './stream/ChannelsService.js';
@ -38,7 +37,6 @@ export class StreamingApiServerService {
private authenticateService: AuthenticateService, private authenticateService: AuthenticateService,
private channelsService: ChannelsService, private channelsService: ChannelsService,
private notificationService: NotificationService, private notificationService: NotificationService,
private usersService: UserService,
) { ) {
} }
@ -132,10 +130,14 @@ export class StreamingApiServerService {
this.#connections.set(connection, Date.now()); this.#connections.set(connection, Date.now());
const userUpdateIntervalId = user ? setInterval(() => { const userUpdateIntervalId = user ? setInterval(() => {
this.usersService.updateLastActiveDate(user); this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
}, 1000 * 60 * 5) : null; }, 1000 * 60 * 5) : null;
if (user) { if (user) {
this.usersService.updateLastActiveDate(user); this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
} }
connection.once('close', () => { connection.once('close', () => {

View file

@ -205,6 +205,7 @@ 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_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.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_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_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js';
@ -335,10 +336,8 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js';
import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js';
import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_flashs from './endpoints/users/flashs.js'; import * as ep___users_flashs from './endpoints/users/flashs.js';
@ -554,6 +553,7 @@ const eps = [
['i/favorites', ep___i_favorites], ['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts], ['i/gallery/posts', ep___i_gallery_posts],
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking], ['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing], ['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting], ['i/import-muting', ep___i_importMuting],
@ -685,9 +685,7 @@ const eps = [
['users/lists/favorite', ep___users_lists_favorite], ['users/lists/favorite', ep___users_lists_favorite],
['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update], ['users/lists/update', ep___users_lists_update],
['users/lists/create-from-public', ep___users_lists_createFromPublic], ['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/lists/update-membership', ep___users_lists_updateMembership],
['users/lists/get-memberships', ep___users_lists_getMemberships],
['users/notes', ep___users_notes], ['users/notes', ep___users_notes],
['users/pages', ep___users_pages], ['users/pages', ep___users_pages],
['users/flashs', ep___users_flashs], ['users/flashs', ep___users_flashs],

View file

@ -56,8 +56,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -86,7 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisForTimelines.xrevrange( const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View file

@ -54,8 +54,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -83,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
let noteIdsRes: [string, string[]][] = []; let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) { if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange( noteIdsRes = await this.redisClient.xrevrange(
`channelTimeline:${channel.id}`, `channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-', '-',
@ -104,6 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
} }
//#endregion //#endregion
@ -128,6 +129,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
} }
//#endregion //#endregion

View file

@ -57,9 +57,8 @@ export const paramDef = {
properties: { properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
notify: { type: 'string', enum: ['normal', 'none'] }, notify: { type: 'string', enum: ['normal', 'none'] },
withReplies: { type: 'boolean' },
}, },
required: ['userId'], required: ['userId', 'notify'],
} as const; } as const;
@Injectable() @Injectable()
@ -99,8 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.followingsRepository.update({ await this.followingsRepository.update({
id: exist.id, id: exist.id,
}, { }, {
notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, notify: ps.notify === 'none' ? null : ps.notify,
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
}); });
return await this.userEntityService.pack(follower.id, me); return await this.userEntityService.pack(follower.id, me);

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MutedNotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
optional: false, nullable: false,
properties: {
count: {
type: 'number',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
) {
super(meta, paramDef, async (ps, me) => {
return {
count: await this.mutedNotesRepository.countBy({
userId: me.id,
reason: 'word',
}),
};
});
}
}

View file

@ -40,6 +40,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
@ -67,8 +68,49 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled); throw new ApiError(meta.errors.gtlDisabled);
} }
// TODO? //#region Construct query
return []; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'')
.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');
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
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 != \'{}\'');
}));
}));
}
//#endregion
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

@ -5,16 +5,14 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -53,6 +51,7 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
}, },
required: [], required: [],
@ -61,17 +60,17 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id); const policies = await this.roleService.getUserPolicies(me.id);
@ -79,75 +78,79 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.stlDisabled); throw new ApiError(meta.errors.stlDisabled);
} }
const [ //#region Construct query
userIdsWhoMeMuting, const followingQuery = this.followingsRepository.createQueryBuilder('following')
userIdsWhoMeMutingRenotes, .select('following.followeeId')
userIdsWhoBlockingMe, .where('following.followerId = :followerId', { followerId: me.id });
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let timeline: MiNote[] = []; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
let htlNoteIdsRes: [string, string[]][] = []; .andWhere(new Brackets(qb => {
let ltlNoteIdsRes: [string, string[]][] = []; qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id })
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
if (!ps.sinceId && !ps.sinceDate) { }))
htlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
ltlNoteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .setParameters(followingQuery.getParameters());
timeline = await query.getMany(); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = timeline.filter(note => { if (ps.includeMyRenotes === false) {
if (note.userId === me.id) { query.andWhere(new Brackets(qb => {
return true; qb.orWhere('note.userId != :meId', { meId: me.id });
} qb.orWhere('note.renoteId IS NULL');
if (isUserRelated(note, userIdsWhoBlockingMe)) return false; qb.orWhere('note.text IS NOT NULL');
if (isUserRelated(note, userIdsWhoMeMuting)) return false; qb.orWhere('note.fileIds != \'{}\'');
if (note.renoteId) { qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { }));
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; }
if (ps.withRenotes === false) return false;
}
}
return true; 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)');
}));
}
// TODO: フィルタした結果件数が足りなかった場合の対応 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)');
}));
}
timeline.sort((a, b) => a.id > b.id ? -1 : 1); if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
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 != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View file

@ -5,16 +5,14 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -43,7 +41,11 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
@ -57,17 +59,14 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -75,63 +74,56 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.ltlDisabled); throw new ApiError(meta.errors.ltlDisabled);
} }
const [ //#region Construct query
userIdsWhoMeMuting, const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
userIdsWhoMeMutingRenotes, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
userIdsWhoBlockingMe, .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
] = me ? await Promise.all([ .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
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 timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
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 query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = timeline.filter(note => { if (ps.withFiles) {
if (me && (note.userId === me.id)) { query.andWhere('note.fileIds != \'{}\'');
return true; }
}
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; if (ps.fileType != null) {
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; query.andWhere('note.fileIds != \'{}\'');
if (note.renoteId) { query.andWhere(new Brackets(qb => {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { for (const type of ps.fileType!) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; const i = ps.fileType!.indexOf(type);
if (ps.withRenotes === false) return false; qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
} }
}));
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)');
} }
}
return true; 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 != \'{}\'');
}));
}));
}
//#endregion
timeline.sort((a, b) => a.id > b.id ? -1 : 1); const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
if (me) { if (me) {

View file

@ -5,16 +5,13 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -44,6 +41,7 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
}, },
required: [], required: [],
@ -52,82 +50,96 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const [ const followees = await this.followingsRepository.createQueryBuilder('following')
followings, .select('following.followeeId')
userIdsWhoMeMuting, .where('following.followerId = :followerId', { followerId: me.id })
userIdsWhoMeMutingRenotes, .getMany();
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 timeline: MiNote[] = []; //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
let noteIdsRes: [string, string[]][] = []; // パフォーマンス上の利点が無さそう?
//.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
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 query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
timeline = timeline.filter(note => { query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
if (note.userId === me.id) { } else {
return true; query.andWhere('note.userId = :meId', { meId: me.id });
} }
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;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
return true; this.queryService.generateChannelQuery(query, me);
}); this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
// TODO: フィルタした結果件数が足りなかった場合の対応 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)');
}));
}
timeline.sort((a, b) => a.id > b.id ? -1 : 1); 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 != \'{}\'');
}
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 != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
process.nextTick(() => { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View file

@ -5,16 +5,12 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -67,19 +63,18 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({ const list = await this.userListsRepository.findOneBy({
@ -91,65 +86,72 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
const [ //#region Construct query
userIdsWhoMeMuting, const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
userIdsWhoMeMutingRenotes, .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
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[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`,
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 query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
timeline = await query.getMany(); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
timeline = timeline.filter(note => { if (ps.includeMyRenotes === false) {
if (note.userId === me.id) { query.andWhere(new Brackets(qb => {
return true; qb.orWhere('note.userId != :meId', { meId: me.id });
} qb.orWhere('note.renoteId IS NULL');
if (isUserRelated(note, userIdsWhoBlockingMe)) return false; qb.orWhere('note.text IS NOT NULL');
if (isUserRelated(note, userIdsWhoMeMuting)) return false; qb.orWhere('note.fileIds != \'{}\'');
if (note.renoteId) { qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { }));
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; }
if (ps.withRenotes === false) return false;
}
}
return true; 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)');
}));
}
// TODO: フィルタした結果件数が足りなかった場合の対応 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)');
}));
}
timeline.sort((a, b) => a.id > b.id ? -1 : 1); if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
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 != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me); this.activeUsersChart.read(me);

View file

@ -53,8 +53,8 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines) @Inject(DI.redis)
private redisForTimelines: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisForTimelines.xrevrange( const noteIdsRes = await this.redisClient.xrevrange(
`roleTimeline:${role.id}`, `roleTimeline:${role.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name, name: ps.name,
} as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
const users = (await this.userListMembershipsRepository.findBy({ const users = (await this.userListJoiningsRepository.findBy({
userListId: ps.listId, userListId: ps.listId,
})).map(x => x.userId); })).map(x => x.userId);
@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListMembershipsRepository.exist({ const exist = await this.userListJoiningsRepository.exist({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: currentUser.id, userId: currentUser.id,

View file

@ -1,79 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
requireCredential: false,
kind: 'read:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
forPublic: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private userListEntityService: UserListEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
} : {
id: ps.listId,
isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId)
.andWhere('membership.userListId = :userListId', { userListId: userList.id })
.innerJoinAndSelect('membership.user', 'user');
const memberships = await query
.limit(ps.limit)
.getMany();
return this.userListEntityService.packMembershipsMany(memberships);
});
}
}

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { UserListService } from '@/core/UserListService.js'; import { UserListService } from '@/core/UserListService.js';
@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const exist = await this.userListMembershipsRepository.exist({ const exist = await this.userListJoiningsRepository.exist({
where: { where: {
userListId: userList.id, userListId: userList.id,
userId: user.id, userId: user.id,

View file

@ -1,79 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { UserListService } from '@/core/UserListService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'users'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:account',
errors: {
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '588e7f72-c744-4a61-b180-d354e912bda2',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean' },
},
required: ['listId', 'userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListService: UserListService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
// Fetch the 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;
});
await this.userListService.updateMembership(user, userList, {
withReplies: ps.withReplies,
});
});
}
}

View file

@ -5,14 +5,12 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js';
import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.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'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -52,6 +50,9 @@ export const paramDef = {
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
includeMyRenotes: { type: 'boolean', default: true }, includeMyRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false },
}, },
required: ['userId'], required: ['userId'],
@ -60,52 +61,64 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
private getterService: GetterService, private getterService: GetterService,
private cacheService: CacheService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let timeline: MiNote[] = []; // 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;
});
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 //#region Construct query
let noteIdsRes: [string, string[]][] = []; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.userId = :userId', { userId: user.id })
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.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 ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser') .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');
}));
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 });
}
}));
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) { if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL'); query.andWhere('note.replyId IS NULL');
} }
if (ps.withRenotes === false) { if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL'); qb.orWhere('note.renoteId IS NULL');
@ -116,21 +129,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
} }
timeline = await query.getMany(); 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)');
}));
}
timeline = timeline.filter(note => { //#endregion
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (ps.withRenotes === false) return false;
}
}
if (note.visibility === 'followers' && !isFollowing) return false; const timeline = await query.limit(ps.limit).getMany();
return true;
});
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View file

@ -11,7 +11,7 @@ import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js'; import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js'; import { MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import type { ChannelsService } from './ChannelsService.js'; import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events'; import type { EventEmitter } from 'events';
@ -30,7 +30,7 @@ export default class Connection {
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private cachedNotes: Packed<'Note'>[] = []; private cachedNotes: Packed<'Note'>[] = [];
public userProfile: MiUserProfile | null = null; public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; public following: Set<string> = new Set();
public followingChannels: Set<string> = new Set(); public followingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = new Set();

View file

@ -18,6 +18,7 @@ class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline'; public readonly chName = 'globalTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@ -37,6 +38,7 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return; if (!policies.gtlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@ -62,7 +64,7 @@ class GlobalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.following[note.userId]?.withReplies) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -80,6 +82,13 @@ class GlobalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View file

@ -16,6 +16,7 @@ class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline'; public readonly chName = 'homeTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@ -30,6 +31,7 @@ class HomeTimelineChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -41,7 +43,7 @@ class HomeTimelineChannel extends Channel {
if (!this.followingChannels.has(note.channelId)) return; if (!this.followingChannels.has(note.channelId)) return;
} else { } else {
// その投稿のユーザーをフォローしていなかったら弾く // その投稿のユーザーをフォローしていなかったら弾く
if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return; if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return;
} }
// Ignore notes from instances the user has muted // Ignore notes from instances the user has muted
@ -71,7 +73,7 @@ class HomeTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && !this.following[note.userId]?.withReplies) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -86,6 +88,13 @@ class HomeTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View file

@ -18,6 +18,7 @@ class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline'; public readonly chName = 'hybridTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = true; public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@ -37,6 +38,7 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@ -51,7 +53,7 @@ class HybridTimelineChannel extends Channel {
// フォローしているチャンネルの投稿 の場合だけ // フォローしているチャンネルの投稿 の場合だけ
if (!( if (!(
(note.channelId == null && this.user!.id === note.userId) || (note.channelId == null && this.user!.id === note.userId) ||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) || (note.channelId == null && this.following.has(note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId)) (note.channelId != null && this.followingChannels.has(note.channelId))
)) return; )) return;
@ -83,7 +85,7 @@ class HybridTimelineChannel extends Channel {
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) { if (note.reply && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
@ -98,6 +100,13 @@ class HybridTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View file

@ -17,6 +17,7 @@ class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline'; public readonly chName = 'localTimeline';
public static shouldShare = true; public static shouldShare = true;
public static requireCredential = false; public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean; private withRenotes: boolean;
constructor( constructor(
@ -36,6 +37,7 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
// Subscribe events // Subscribe events
@ -62,7 +64,7 @@ class LocalTimelineChannel extends Channel {
} }
// 関係ない返信は除外 // 関係ない返信は除外
if (note.reply && this.user && !this.following[note.userId]?.withReplies) { if (note.reply && this.user && !this.withReplies) {
const reply = note.reply; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
@ -77,6 +79,13 @@ class LocalTimelineChannel extends Channel {
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note); this.connection.cacheNote(note);
this.send('note', note); this.send('note', note);

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
@ -18,12 +18,12 @@ class UserListChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private listId: string; private listId: string;
public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; public listUsers: MiUser['id'][] = [];
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
constructor( constructor(
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
id: string, id: string,
@ -58,25 +58,19 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async updateListUsers() { private async updateListUsers() {
const memberships = await this.userListMembershipsRepository.find({ const users = await this.userListJoiningsRepository.find({
where: { where: {
userListId: this.listId, userListId: this.listId,
}, },
select: ['userId'], select: ['userId'],
}); });
const membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; this.listUsers = users.map(x => x.userId);
for (const membership of memberships) {
membershipsMap[membership.userId] = {
withReplies: membership.withReplies,
};
}
this.membershipsMap = membershipsMap;
} }
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (!this.listUsers.includes(note.userId)) return;
if (['followers', 'specified'].includes(note.visibility)) { if (['followers', 'specified'].includes(note.visibility)) {
note = await this.noteEntityService.pack(note.id, this.user, { note = await this.noteEntityService.pack(note.id, this.user, {
@ -101,13 +95,6 @@ class UserListChannel extends Channel {
} }
} }
// 関係ない返信は除外
if (note.reply && !this.membershipsMap[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return; if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
@ -137,8 +124,8 @@ export class UserListChannelService {
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListJoiningsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
) { ) {
@ -148,7 +135,7 @@ export class UserListChannelService {
public create(id: string, connection: Channel['connection']): UserListChannel { public create(id: string, connection: Channel['connection']): UserListChannel {
return new UserListChannel( return new UserListChannel(
this.userListsRepository, this.userListsRepository,
this.userListMembershipsRepository, this.userListJoiningsRepository,
this.noteEntityService, this.noteEntityService,
id, id,
connection, connection,

View file

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js'; import { signup, api, post, react, startServer, waitFire } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@ -42,9 +42,6 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -59,9 +56,6 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);

View file

@ -1,701 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
import * as assert from 'assert';
import { signup, api, post, react, startServer, waitFire, sleep, uploadUrl } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
let app: INestApplicationContext;
beforeAll(async () => {
app = await startServer();
}, 1000 * 60 * 2);
afterAll(async () => {
await app.close();
});
describe('Timelines', () => {
describe('Home TL', () => {
test.concurrent('自分の visibility: followers なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi');
});
test.concurrent('フォローしているユーザーのノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
const carolNote = await post(carol, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'followers' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi');
});
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/create', { userId: carol.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
});
test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('自分の他人への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
});
test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {
withRenotes: false,
}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const [bobFile, carolFile] = await Promise.all([
uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'),
]);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [bobFile.id] });
const carolNote1 = await post(carol, { text: 'hi' });
const carolNote2 = await post(carol, { fileIds: [carolFile.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false);
}, 1000 * 10);
});
describe('Local TL', () => {
test.concurrent('visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
// 含まれても良いと思うけど実装が面倒なので含まれない
test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', {
userId: carol.id,
}, alice);
const carolNote = await post(carol, { text: 'hi', visibility: 'home' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await api('/following/update', { userId: bob.id, withReplies: true }, alice);
await api('/mute/create', { userId: carol.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('Social TL', () => {
test.concurrent('ローカルユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]);
await api('/following/create', { userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
describe('User List TL', () => {
test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
/*
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
*/
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null);
});
test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
});
test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice);
const carolNote = await post(carol, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'home' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png');
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { fileIds: [file.id] });
await sleep(100); // redisに追加されるのを待つ
const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
});
// TODO: リノートミュート済みユーザーのテスト
// TODO: ページネーションのテスト
});

View file

@ -38,10 +38,23 @@ describe('users/notes', () => {
await app.close(); await app.close();
}); });
test('withFiles', async () => { test('ファイルタイプ指定 (jpg)', async () => {
const res = await api('/users/notes', { const res = await api('/users/notes', {
userId: alice.id, userId: alice.id,
withFiles: true, fileType: ['image/jpeg'],
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 2);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
});
test('ファイルタイプ指定 (jpg or png)', async () => {
const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png'],
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);

View file

@ -133,7 +133,6 @@ describe('ユーザー', () => {
isMuted: user.isMuted ?? false, isMuted: user.isMuted ?? false,
isRenoteMuted: user.isRenoteMuted ?? false, isRenoteMuted: user.isRenoteMuted ?? false,
notify: user.notify ?? 'none', notify: user.notify ?? 'none',
withReplies: user.withReplies ?? false,
}); });
}; };

View file

@ -99,17 +99,9 @@ export const relativeFetch = async (path: string, init?: RequestInit | undefined
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
}; };
function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) {
let randomString = '';
for (let i = 0; i < length; i++) {
randomString += chars[Math.floor(Math.random() * chars.length)];
}
return randomString;
}
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => { export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
const q = Object.assign({ const q = Object.assign({
username: randomString(), username: 'test',
password: 'test', password: 'test',
}, params); }, params);

View file

@ -169,7 +169,7 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js'; import { shouldCollapsed } from '@/scripts/collapsed.js';
@ -226,7 +226,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).fil
const isLong = shouldCollapsed(appearNote); const isLong = shouldCollapsed(appearNote);
const collapsed = ref(appearNote.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref<any>(null); const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);

View file

@ -218,7 +218,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
@ -266,7 +266,7 @@ const renoteUri = appearNote.renote ? appearNote.renote.uri : null;
const isMyRenote = $i && ($i.id === note.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null;

View file

@ -108,7 +108,7 @@ const props = withDefaults(defineProps<{
}); });
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const muted = ref(checkWordMute(props.note, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);

View file

@ -23,9 +23,11 @@ const props = withDefaults(defineProps<{
role?: string; role?: string;
sound?: boolean; sound?: boolean;
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}>(), { }>(), {
withRenotes: true, withRenotes: true,
withReplies: false,
onlyFiles: false, onlyFiles: false,
}); });
@ -68,10 +70,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/timeline'; endpoint = 'notes/timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('homeTimeline', { connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@ -81,10 +85,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/local-timeline'; endpoint = 'notes/local-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('localTimeline', { connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@ -92,10 +98,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/hybrid-timeline'; endpoint = 'notes/hybrid-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('hybridTimeline', { connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@ -103,10 +111,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/global-timeline'; endpoint = 'notes/global-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('globalTimeline', { connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@ -130,11 +140,13 @@ if (props.src === 'antenna') {
endpoint = 'notes/user-list-timeline'; endpoint = 'notes/user-list-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}; };
connection = stream.useChannel('userList', { connection = stream.useChannel('userList', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}); });

View file

@ -29,22 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s"> <div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
<div v-for="user in users" :key="user.id" :class="$style.userItem">
<MkPagination ref="paginationEl" :pagination="membershipsPagination"> <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
<template #default="{ items }"> <MkUserCardMini :user="user"/>
<div class="_gaps_s"> </MkA>
<div v-for="item in items" :key="item.id"> <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ph-x ph-bold ph-lg"></i></button>
<div :class="$style.userItem"> </div>
<MkA :class="$style.userItemBody" :to="`${userPage(item.user)}`"> <MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers">
<MkUserCardMini :user="item.user"/> {{ i18n.ts.loadMore }}
</MkA> </MkButton>
<button class="_button" :class="$style.menu" @click="showMembershipMenu(item, $event)"><i class="ph-dots-three ph-bold ph-lg"></i></button> <MkLoading v-if="fetching" class="loading"/>
<button class="_button" :class="$style.remove" @click="removeUser(item, $event)"><i class="ph-x ph-bold ph-lg"></i></button>
</div>
</div>
</div>
</template>
</MkPagination>
</div> </div>
</MkFolder> </MkFolder>
</div> </div>
@ -65,11 +59,9 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache.js'; import { userListsCache } from '@/cache';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
const { const {
enableInfiniteScroll, enableInfiniteScroll,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
@ -78,25 +70,40 @@ const props = defineProps<{
listId: string; listId: string;
}>(); }>();
const paginationEl = ref<InstanceType<typeof MkPagination>>(); const FETCH_USERS_LIMIT = 20;
let list = $ref<Misskey.entities.UserList | null>(null); let list = $ref<Misskey.entities.UserList | null>(null);
let users = $ref<Misskey.entities.UserLite[]>([]);
let queueUserIds = $ref<string[]>([]);
let fetching = $ref(true);
const isPublic = ref(false); const isPublic = ref(false);
const name = ref(''); const name = ref('');
const membershipsPagination = {
endpoint: 'users/lists/get-memberships' as const,
limit: 30,
params: computed(() => ({
listId: props.listId,
})),
};
function fetchList() { function fetchList() {
fetching = true;
os.api('users/lists/show', { os.api('users/lists/show', {
listId: props.listId, listId: props.listId,
}).then(_list => { }).then(_list => {
list = _list; list = _list;
name.value = list.name; name.value = list.name;
isPublic.value = list.isPublic; isPublic.value = list.isPublic;
queueUserIds = list.userIds;
return fetchMoreUsers();
});
}
function fetchMoreUsers() {
if (!list) return;
if (fetching && users.length !== 0) return; // fetchingtrueusers
fetching = true;
os.api('users/show', {
userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT),
}).then(_users => {
users = users.concat(_users);
queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT);
}).finally(() => {
fetching = false;
}); });
} }
@ -107,12 +114,12 @@ function addUser() {
listId: list.id, listId: list.id,
userId: user.id, userId: user.id,
}).then(() => { }).then(() => {
paginationEl.value.reload(); users.push(user);
}); });
}); });
} }
async function removeUser(item, ev) { async function removeUser(user, ev) {
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.remove, text: i18n.ts.remove,
icon: 'ph-x ph-bold ph-lg', icon: 'ph-x ph-bold ph-lg',
@ -121,28 +128,9 @@ async function removeUser(item, ev) {
if (!list) return; if (!list) return;
os.api('users/lists/pull', { os.api('users/lists/pull', {
listId: list.id, listId: list.id,
userId: item.userId, userId: user.id,
}).then(() => { }).then(() => {
paginationEl.value.removeItem(item.id); users = users.filter(x => x.id !== user.id);
});
},
}], ev.currentTarget ?? ev.target);
}
async function showMembershipMenu(item, ev) {
os.popupMenu([{
text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
action: async () => {
os.api('users/lists/update-membership', {
listId: list.id,
userId: item.userId,
withReplies: !item.withReplies,
}).then(() => {
paginationEl.value.updateItem(item.id, (old) => ({
...old,
withReplies: !item.withReplies,
}));
}); });
}, },
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
@ -214,12 +202,6 @@ definePageMetadata(computed(() => list ? {
align-self: center; align-self: center;
} }
.menu {
width: 32px;
height: 32px;
align-self: center;
}
.more { .more {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;

View file

@ -5,11 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkTab v-model="tab">
<option value="soft">{{ i18n.ts._wordMute.soft }}</option>
<option value="hard">{{ i18n.ts._wordMute.hard }}</option>
</MkTab>
<div> <div>
<MkTextarea v-model="mutedWords"> <div v-show="tab === 'soft'" class="_gaps_m">
<span>{{ i18n.ts._wordMute.muteWords }}</span> <MkInfo>{{ i18n.ts._wordMute.softDescription }}</MkInfo>
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> <MkTextarea v-model="softMutedWords">
</MkTextarea> <span>{{ i18n.ts._wordMute.muteWords }}</span>
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
</MkTextarea>
</div>
<div v-show="tab === 'hard'" class="_gaps_m">
<MkInfo>{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo>
<MkTextarea v-model="hardMutedWords">
<span>{{ i18n.ts._wordMute.muteWords }}</span>
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
</MkTextarea>
<MkKeyValue v-if="hardWordMutedNotesCount != null">
<template #key>{{ i18n.ts._wordMute.mutedNotes }}</template>
<template #value>{{ number(hardWordMutedNotesCount) }}</template>
</MkKeyValue>
</div>
</div> </div>
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ph-floppy-disk ph-bold pg-lg"></i> {{ i18n.ts.save }}</MkButton> <MkButton primary inline :disabled="!changed" @click="save()"><i class="ph-floppy-disk ph-bold pg-lg"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
@ -38,15 +56,25 @@ const render = (mutedWords) => mutedWords.map(x => {
}).join('\n'); }).join('\n');
const tab = ref('soft'); const tab = ref('soft');
const mutedWords = ref(render($i!.mutedWords)); const softMutedWords = ref(render(defaultStore.state.mutedWords));
const hardMutedWords = ref(render($i!.mutedWords));
const hardWordMutedNotesCount = ref(null);
const changed = ref(false); const changed = ref(false);
watch(mutedWords, () => { os.api('i/get-word-muted-notes-count', {}).then(response => {
hardWordMutedNotesCount.value = response?.count;
});
watch(softMutedWords, () => {
changed.value = true;
});
watch(hardMutedWords, () => {
changed.value = true; changed.value = true;
}); });
async function save() { async function save() {
const parseMutes = (mutes) => { const parseMutes = (mutes, tab) => {
// split into lines, remove empty lines and unnecessary whitespace // split into lines, remove empty lines and unnecessary whitespace
let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
@ -64,7 +92,7 @@ async function save() {
os.alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.regexpError, title: i18n.ts.regexpError,
text: i18n.t('regexpErrorDescription', { tab: 'word mute', line: i + 1 }) + '\n' + err.toString(), text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
}); });
// re-throw error so these invalid settings are not saved // re-throw error so these invalid settings are not saved
throw err; throw err;
@ -77,16 +105,18 @@ async function save() {
return lines; return lines;
}; };
let parsed; let softMutes, hardMutes;
try { try {
parsed = parseMutes(mutedWords.value); softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft);
hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard);
} catch (err) { } catch (err) {
// already displayed error message in parseMutes // already displayed error message in parseMutes
return; return;
} }
defaultStore.set('mutedWords', softMutes);
await os.api('i/update', { await os.api('i/update', {
mutedWords: parsed, mutedWords: hardMutes,
}); });
changed.value = false; changed.value = false;

View file

@ -15,10 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tl"> <div :class="$style.tl">
<MkTimeline <MkTimeline
ref="tlComponent" ref="tlComponent"
:key="src + withRenotes + onlyFiles" :key="src + withRenotes + withReplies + onlyFiles"
:src="src.split(':')[0]" :src="src.split(':')[0]"
:list="src.split(':')[1]" :list="src.split(':')[1]"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
:sound="true" :sound="true"
@queue="queueUpdated" @queue="queueUpdated"
@ -61,6 +62,7 @@ let queue = $ref(0);
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
const withRenotes = $ref(true); const withRenotes = $ref(true);
const withReplies = $ref(false);
const onlyFiles = $ref(false); const onlyFiles = $ref(false);
watch($$(src), () => queue = 0); watch($$(src), () => queue = 0);
@ -142,6 +144,11 @@ const headerActions = $computed(() => [{
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
icon: 'ph-repeat ph-bold ph-lg', icon: 'ph-repeat ph-bold ph-lg',
ref: $$(withRenotes), ref: $$(withRenotes),
}, {
type: 'switch',
text: i18n.ts.withReplies,
icon: 'ti ti-arrow-back-up',
ref: $$(withReplies),
}, { }, {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,

View file

@ -128,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow"> <template v-if="narrow">
<XFiles :key="user.id" :user="user"/> <XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</template> </template>
<MkStickyContainer> <MkStickyContainer>
@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</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;">
<XFiles :key="user.id" :user="user"/> <XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/> <XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/>
</div> </div>
@ -193,7 +193,7 @@ function calcAge(birthdate: string): number {
return yearDiff; return yearDiff;
} }
const XFiles = defineAsyncComponent(() => import('./index.files.vue')); const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const XListenBrainz = defineAsyncComponent(() => import("./index.listenbrainz.vue")); const XListenBrainz = defineAsyncComponent(() => import("./index.listenbrainz.vue"));

View file

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

View file

@ -80,15 +80,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
}); });
} }
async function toggleWithReplies() {
os.apiWithDialog('following/update', {
userId: user.id,
withReplies: !user.withReplies,
}).then(() => {
user.withReplies = !user.withReplies;
});
}
async function toggleNotify() { async function toggleNotify() {
os.apiWithDialog('following/update', { os.apiWithDialog('following/update', {
userId: user.id, userId: user.id,
@ -291,10 +282,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
//if (user.isFollowing) { //if (user.isFollowing) {
menu = menu.concat([{ menu = menu.concat([{
icon: user.withReplies ? 'ph-envelope ph-bold pg-lg-off' : 'ph-envelope ph-bold',
text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
action: toggleWithReplies,
}, {
icon: user.notify === 'none' ? 'ph-bell ph-bold pg-lg' : 'ph-bell ph-bold pg-lg-off', icon: user.notify === 'none' ? 'ph-bell ph-bold pg-lg' : 'ph-bell ph-bold pg-lg-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify, action: toggleNotify,

View file

@ -5,7 +5,7 @@
import { markRaw, ref } from 'vue'; import { markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage.js'; import { miLocalStorage } from './local-storage';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
interface PostFormAction { interface PostFormAction {
@ -105,6 +105,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
}, },
mutedWords: {
where: 'account',
default: [],
},
mutedAds: { mutedAds: {
where: 'account', where: 'account',
default: [] as string[], default: [] as string[],

View file

@ -23,9 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTimeline <MkTimeline
v-else-if="column.tl" v-else-if="column.tl"
ref="timeline" ref="timeline"
:key="column.tl + withRenotes + onlyFiles" :key="column.tl + withRenotes + withReplies + onlyFiles"
:src="column.tl" :src="column.tl"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
/> />
</XColumn> </XColumn>
@ -51,6 +52,7 @@ let disabled = $ref(false);
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
const withRenotes = $ref(props.column.withRenotes ?? true); const withRenotes = $ref(props.column.withRenotes ?? true);
const withReplies = $ref(props.column.withReplies ?? false);
const onlyFiles = $ref(props.column.onlyFiles ?? false); const onlyFiles = $ref(props.column.onlyFiles ?? false);
watch($$(withRenotes), v => { watch($$(withRenotes), v => {
@ -59,6 +61,12 @@ watch($$(withRenotes), v => {
}); });
}); });
watch($$(withReplies), v => {
updateColumn(props.column.id, {
withReplies: v,
});
});
watch($$(onlyFiles), v => { watch($$(onlyFiles), v => {
updateColumn(props.column.id, { updateColumn(props.column.id, {
onlyFiles: v, onlyFiles: v,
@ -107,6 +115,10 @@ const menu = [{
type: 'switch', type: 'switch',
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
ref: $$(withRenotes), ref: $$(withRenotes),
}, {
type: 'switch',
text: i18n.ts.withReplies,
ref: $$(withReplies),
}, { }, {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,

View file

@ -1381,6 +1381,10 @@ export type Endpoints = {
req: TODO; req: TODO;
res: TODO; res: TODO;
}; };
'i/get-word-muted-notes-count': {
req: TODO;
res: TODO;
};
'i/import-following': { 'i/import-following': {
req: TODO; req: TODO;
res: TODO; res: TODO;
@ -2977,7 +2981,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// //
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts // src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:595:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/entities.ts:595:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts

View file

@ -371,6 +371,7 @@ export type Endpoints = {
'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; }; 'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; };
'i/gallery/likes': { req: TODO; res: TODO; }; 'i/gallery/likes': { req: TODO; res: TODO; };
'i/gallery/posts': { 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-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; }; 'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; }; 'i/move': { req: TODO; res: TODO; };