summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authorMar0xy <marie@kaifa.ch>2023-10-03 15:20:49 +0200
committerMar0xy <marie@kaifa.ch>2023-10-03 15:20:49 +0200
commit38e35e14726ba5540a52c894131b38ee41f80b91 (patch)
tree3deb7aa93df80b4403308d0d07ad81574f5b2899 /packages/backend/src/core
parentmerge: increase comment length for files (#45) (diff)
parentfix: deck uiでuser listを見たときにリプライが表示されない (... (diff)
downloadsharkey-38e35e14726ba5540a52c894131b38ee41f80b91.tar.gz
sharkey-38e35e14726ba5540a52c894131b38ee41f80b91.tar.bz2
sharkey-38e35e14726ba5540a52c894131b38ee41f80b91.zip
merge: upstream
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/AccountMoveService.ts30
-rw-r--r--packages/backend/src/core/AntennaService.ts18
-rw-r--r--packages/backend/src/core/CacheService.ts23
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts2
-rw-r--r--packages/backend/src/core/GlobalEventService.ts271
-rw-r--r--packages/backend/src/core/MetaService.ts4
-rw-r--r--packages/backend/src/core/NoteCreateService.ts253
-rw-r--r--packages/backend/src/core/NoteEditService.ts213
-rw-r--r--packages/backend/src/core/NotificationService.ts50
-rw-r--r--packages/backend/src/core/QueryService.ts64
-rw-r--r--packages/backend/src/core/ReactionService.ts3
-rw-r--r--packages/backend/src/core/RoleService.ts7
-rw-r--r--packages/backend/src/core/UserBlockingService.ts8
-rw-r--r--packages/backend/src/core/UserFollowingService.ts15
-rw-r--r--packages/backend/src/core/UserListService.ts110
-rw-r--r--packages/backend/src/core/UserService.ts53
-rw-r--r--packages/backend/src/core/WebhookService.ts4
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts8
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts4
-rw-r--r--packages/backend/src/core/entities/UserListEntityService.ts24
21 files changed, 939 insertions, 231 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index ec1d013922..ba3413007d 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
-import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js';
+import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
import { IdService } from '@/core/IdService.js';
@@ -42,8 +42,8 @@ export class AccountMoveService {
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@@ -215,40 +215,40 @@ export class AccountMoveService {
@bindThis
public async updateLists(src: ThinUser, dst: MiUser): Promise<void> {
// Return if there is no list to be updated.
- const oldJoinings = await this.userListJoiningsRepository.find({
+ const oldMemberships = await this.userListMembershipsRepository.find({
where: {
userId: src.id,
},
});
- if (oldJoinings.length === 0) return;
+ if (oldMemberships.length === 0) return;
- const existingUserListIds = await this.userListJoiningsRepository.find({
+ const existingUserListIds = await this.userListMembershipsRepository.find({
where: {
userId: dst.id,
},
- }).then(joinings => joinings.map(joining => joining.userListId));
+ }).then(memberships => memberships.map(membership => membership.userListId));
- const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
+ const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
// 重複しないようにIDを生成
const genId = (): string => {
let id: string;
do {
id = this.idService.genId();
- } while (newJoinings.has(id));
+ } while (newMemberships.has(id));
return id;
};
- for (const joining of oldJoinings) {
- if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
- newJoinings.set(genId(), {
+ for (const membership of oldMemberships) {
+ if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list
+ newMemberships.set(genId(), {
createdAt: new Date(),
userId: dst.id,
- userListId: joining.userListId,
+ userListId: membership.userListId,
});
}
- const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
- await this.userListJoiningsRepository.insert(arrayToInsert);
+ const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.userListMembershipsRepository.insert(arrayToInsert);
// Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) {
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 841ce4b84a..95712b35b7 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -12,10 +12,10 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
-import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js';
+import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
-import { StreamMessages } from '@/server/api/stream/types.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown {
private antennas: MiAntenna[];
constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
@@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
- const { type, body } = obj.message as StreamMessages['internal']['payload'];
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'antennaCreated':
this.antennas.push({
@@ -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 matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
- const redisPipeline = this.redisClient.pipeline();
+ const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) {
redisPipeline.xadd(
@@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
- const listUsers = (await this.userListJoiningsRepository.findBy({
+ const listUsers = (await this.userListMembershipsRepository.findBy({
userListId: antenna.userListId!,
})).map(x => x.userId);
diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts
index 6ca684d53c..22c510cc37 100644
--- a/packages/backend/src/core/CacheService.ts
+++ b/packages/backend/src/core/CacheService.ts
@@ -5,13 +5,13 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
-import { StreamMessages } from '@/server/api/stream/types.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
- public userFollowingsCache: RedisKVCache<Set<string>>;
+ public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
constructor(
@@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
- this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', {
+ this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
- fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))),
- toRedisConverter: (value) => JSON.stringify(Array.from(value)),
- fromRedisConverter: (value) => new Set(JSON.parse(value)),
+ fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
+ const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
+ for (const x of xs) {
+ 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', {
@@ -160,7 +166,7 @@ export class CacheService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
- const { type, body } = obj.message as StreamMessages['internal']['payload'];
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userChangeSuspendedState':
case 'remoteUserUpdated': {
@@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown {
if (follower) follower.followingCount++;
const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++;
+ this.userFollowingsCache.delete(body.followerId);
break;
}
default:
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 7d6b76e9c2..955b9fdcf6 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -47,6 +47,7 @@ import { SignupService } from './SignupService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
+import { UserService } from './UserService.js';
import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js';
@@ -175,6 +176,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
+const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
@@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
+ UserService,
UserFollowingService,
UserKeypairService,
UserListService,
@@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService,
$UserBlockingService,
$CacheService,
+ $UserService,
$UserFollowingService,
$UserKeypairService,
$UserListService,
@@ -555,6 +559,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
+ UserService,
UserFollowingService,
UserKeypairService,
UserListService,
@@ -678,6 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService,
$UserBlockingService,
$CacheService,
+ $UserService,
$UserFollowingService,
$UserKeypairService,
$UserListService,
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index f9368eb6d3..d1cd2e97c3 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import { query } from '@/misc/prelude/url.js';
-import type { Serialized } from '@/server/api/stream/types.js';
+import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Config } from '@/config.js';
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 4bc4f54c21..b74fbbe584 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -5,27 +5,254 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
+import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
+import type { MiUserProfile } from '@/models/UserProfile.js';
import type { MiNote } from '@/models/Note.js';
-import type { MiUserList } from '@/models/UserList.js';
import type { MiAntenna } from '@/models/Antenna.js';
-import type {
- StreamChannels,
- AdminStreamTypes,
- AntennaStreamTypes,
- BroadcastTypes,
- DriveStreamTypes,
- InternalStreamTypes,
- MainStreamTypes,
- NoteStreamTypes,
- UserListStreamTypes,
- RoleTimelineStreamTypes,
-} from '@/server/api/stream/types.js';
+import type { MiDriveFile } from '@/models/DriveFile.js';
+import type { MiDriveFolder } from '@/models/DriveFolder.js';
+import type { MiUserList } from '@/models/UserList.js';
+import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
+import type { MiSignin } from '@/models/Signin.js';
+import type { MiPage } from '@/models/Page.js';
+import type { MiWebhook } from '@/models/Webhook.js';
+import type { MiMeta } from '@/models/Meta.js';
+import { MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
-import { MiRole } from '@/models/_.js';
+import { Serialized } from '@/types.js';
+import type Emitter from 'strict-event-emitter-types';
+import type { EventEmitter } from 'events';
+
+//#region Stream type-body definitions
+export interface BroadcastTypes {
+ emojiAdded: {
+ emoji: Packed<'EmojiDetailed'>;
+ };
+ emojiUpdated: {
+ emojis: Packed<'EmojiDetailed'>[];
+ };
+ emojiDeleted: {
+ emojis: {
+ id?: string;
+ name: string;
+ [other: string]: any;
+ }[];
+ };
+ announcementCreated: {
+ announcement: Packed<'Announcement'>;
+ };
+}
+
+export interface MainEventTypes {
+ notification: Packed<'Notification'>;
+ mention: Packed<'Note'>;
+ reply: Packed<'Note'>;
+ renote: Packed<'Note'>;
+ follow: Packed<'UserDetailedNotMe'>;
+ followed: Packed<'User'>;
+ unfollow: Packed<'User'>;
+ meUpdated: Packed<'User'>;
+ pageEvent: {
+ pageId: MiPage['id'];
+ event: string;
+ var: any;
+ userId: MiUser['id'];
+ user: Packed<'User'>;
+ };
+ urlUploadFinished: {
+ marker?: string | null;
+ file: Packed<'DriveFile'>;
+ };
+ readAllNotifications: undefined;
+ unreadNotification: Packed<'Notification'>;
+ unreadMention: MiNote['id'];
+ readAllUnreadMentions: undefined;
+ unreadSpecifiedNote: MiNote['id'];
+ readAllUnreadSpecifiedNotes: undefined;
+ readAllAntennas: undefined;
+ unreadAntenna: MiAntenna;
+ readAllAnnouncements: undefined;
+ myTokenRegenerated: undefined;
+ signin: MiSignin;
+ registryUpdated: {
+ scope?: string[];
+ key: string;
+ value: any | null;
+ };
+ driveFileCreated: Packed<'DriveFile'>;
+ readAntenna: MiAntenna;
+ receiveFollowRequest: Packed<'User'>;
+ announcementCreated: {
+ announcement: Packed<'Announcement'>;
+ };
+}
+
+export interface DriveEventTypes {
+ fileCreated: Packed<'DriveFile'>;
+ fileDeleted: MiDriveFile['id'];
+ fileUpdated: Packed<'DriveFile'>;
+ folderCreated: Packed<'DriveFolder'>;
+ folderDeleted: MiDriveFolder['id'];
+ folderUpdated: Packed<'DriveFolder'>;
+}
+
+export interface NoteEventTypes {
+ pollVoted: {
+ choice: number;
+ userId: MiUser['id'];
+ };
+ deleted: {
+ deletedAt: Date;
+ };
+ updated: {
+ cw: string | null;
+ text: string;
+ };
+ reacted: {
+ reaction: string;
+ emoji?: {
+ name: string;
+ url: string;
+ } | null;
+ userId: MiUser['id'];
+ };
+ unreacted: {
+ reaction: string;
+ userId: MiUser['id'];
+ };
+}
+type NoteStreamEventTypes = {
+ [key in keyof NoteEventTypes]: {
+ id: MiNote['id'];
+ body: NoteEventTypes[key];
+ };
+};
+
+export interface UserListEventTypes {
+ userAdded: Packed<'User'>;
+ userRemoved: Packed<'User'>;
+}
+
+export interface AntennaEventTypes {
+ note: MiNote;
+}
+
+export interface RoleTimelineEventTypes {
+ note: Packed<'Note'>;
+}
+
+export interface AdminEventTypes {
+ newAbuseUserReport: {
+ id: MiAbuseUserReport['id'];
+ targetUserId: MiUser['id'],
+ reporterId: MiUser['id'],
+ comment: string;
+ };
+}
+//#endregion
+
+// 辞書(interface or type)から{ type, body }ユニオンを定義
+// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
+// VS Codeの展開を防止するためにEvents型を定義
+type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
+type EventUnionFromDictionary<
+ T extends object,
+ U = Events<T>
+> = U[keyof U];
+
+type SerializedAll<T> = {
+ [K in keyof T]: Serialized<T[K]>;
+};
+
+export interface InternalEventTypes {
+ userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
+ userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
+ remoteUserUpdated: { id: MiUser['id']; };
+ follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
+ unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
+ blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
+ blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
+ policiesUpdated: MiRole['policies'];
+ roleCreated: MiRole;
+ roleDeleted: MiRole;
+ roleUpdated: MiRole;
+ userRoleAssigned: MiRoleAssignment;
+ userRoleUnassigned: MiRoleAssignment;
+ webhookCreated: MiWebhook;
+ webhookDeleted: MiWebhook;
+ webhookUpdated: MiWebhook;
+ antennaCreated: MiAntenna;
+ antennaDeleted: MiAntenna;
+ antennaUpdated: MiAntenna;
+ metaUpdated: MiMeta;
+ followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
+ unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
+ updateUserProfile: MiUserProfile;
+ mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
+ unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
+ userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
+ userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
+}
+
+// name/messages(spec) pairs dictionary
+export type GlobalEvents = {
+ internal: {
+ name: 'internal';
+ payload: EventUnionFromDictionary<SerializedAll<InternalEventTypes>>;
+ };
+ broadcast: {
+ name: 'broadcast';
+ payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
+ };
+ main: {
+ name: `mainStream:${MiUser['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<MainEventTypes>>;
+ };
+ drive: {
+ name: `driveStream:${MiUser['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<DriveEventTypes>>;
+ };
+ note: {
+ name: `noteStream:${MiNote['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
+ };
+ userList: {
+ name: `userListStream:${MiUserList['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<UserListEventTypes>>;
+ };
+ roleTimeline: {
+ name: `roleTimelineStream:${MiRole['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<RoleTimelineEventTypes>>;
+ };
+ antenna: {
+ name: `antennaStream:${MiAntenna['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<AntennaEventTypes>>;
+ };
+ admin: {
+ name: `adminStream:${MiUser['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<AdminEventTypes>>;
+ };
+ notes: {
+ name: 'notesStream';
+ payload: Serialized<Packed<'Note'>>;
+ };
+};
+
+// API event definitions
+// ストリームごとのEmitterの辞書を用意
+type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default<EventEmitter, { [y in GlobalEvents[x]['name']]: (e: GlobalEvents[x]['payload']) => void }> };
+// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
+type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
+// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
+export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof GlobalEvents]>;
+// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
+
+// provide stream channels union
+export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name'];
@Injectable()
export class GlobalEventService {
@@ -51,7 +278,7 @@ export class GlobalEventService {
}
@bindThis
- public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
+ public publishInternalEvent<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): void {
this.publish('internal', type, typeof value === 'undefined' ? null : value);
}
@@ -61,17 +288,17 @@ export class GlobalEventService {
}
@bindThis
- public publishMainStream<K extends keyof MainStreamTypes>(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void {
+ public publishMainStream<K extends keyof MainEventTypes>(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void {
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
- public publishDriveStream<K extends keyof DriveStreamTypes>(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void {
+ public publishDriveStream<K extends keyof DriveEventTypes>(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void {
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
- public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void {
+ public publishNoteStream<K extends keyof NoteEventTypes>(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void {
this.publish(`noteStream:${noteId}`, type, {
id: noteId,
body: value,
@@ -79,17 +306,17 @@ export class GlobalEventService {
}
@bindThis
- public publishUserListStream<K extends keyof UserListStreamTypes>(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void {
+ public publishUserListStream<K extends keyof UserListEventTypes>(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
- public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
+ public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
- public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
+ public publishRoleTimelineStream<K extends keyof RoleTimelineEventTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void {
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
}
@@ -99,7 +326,7 @@ export class GlobalEventService {
}
@bindThis
- public publishAdminStream<K extends keyof AdminStreamTypes>(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void {
+ public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
}
diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts
index 00e1e3c1fc..508544dc07 100644
--- a/packages/backend/src/core/MetaService.ts
+++ b/packages/backend/src/core/MetaService.ts
@@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
-import { StreamMessages } from '@/server/api/stream/types.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
- const { type, body } = obj.message as StreamMessages['internal']['payload'];
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'metaUpdated': {
this.cache = body;
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 972319ddcf..8fb34fd637 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
-import { In, DataSource } from 'typeorm';
+import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
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 type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.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';
class NotificationManager {
@@ -110,9 +108,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.notificationService.createNotification(x.target, x.reason, {
- notifierId: this.notifier.id,
noteId: this.note.id,
- });
+ }, this.notifier.id);
}
}
}
@@ -158,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.db)
private db: DataSource,
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -176,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.mutedNotesRepository)
- private mutedNotesRepository: MutedNotesRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@@ -188,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.channelFollowingsRepository)
+ private channelFollowingsRepository: ChannelFollowingsRepository,
+
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@@ -335,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
if (data.channel) {
- this.redisClient.xadd(
+ this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
@@ -481,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user)
this.incNotesCountOfUser(user);
- // 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.visibility === 'public' || data.visibility === 'home') {
+ this.pushToTl(note, user);
+ } else if (data.visibility === 'followers') {
+ this.pushToTl(note, user);
+ } else if (data.visibility === 'specified') {
+ // TODO
+ }
this.antennaService.addNoteToAntennas(note, user);
@@ -509,15 +496,16 @@ export class NoteCreateService implements OnApplicationShutdown {
}
if (data.reply == null) {
+ // TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
for (const following of followings) {
+ // TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', {
- notifierId: user.id,
noteId: note.id,
- });
+ }, user.id);
}
});
}
@@ -814,6 +802,205 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@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
public dispose(): void {
this.#shutdownController.abort();
}
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 81a4930135..f331c8a9a8 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
-import { In } from 'typeorm';
+import { In, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
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 type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -49,8 +49,6 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.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';
class NotificationManager {
@@ -105,9 +103,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.notificationService.createNotification(x.target, x.reason, {
- notifierId: this.notifier.id,
noteId: this.note.id,
- });
+ }, this.notifier.id);
}
}
}
@@ -152,8 +149,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.config)
private config: Config,
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -170,12 +167,15 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.mutedNotesRepository)
- private mutedNotesRepository: MutedNotesRepository,
-
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
+
+ @Inject(DI.channelFollowingsRepository)
+ private channelFollowingsRepository: ChannelFollowingsRepository,
+
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@@ -423,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown {
await this.notesRepository.update(oldnote.id, note);
if (data.channel) {
- this.redisClient.xadd(
+ this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
@@ -461,27 +461,6 @@ export class NoteEditService implements OnApplicationShutdown {
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) {
const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.add(note.id, {
@@ -492,6 +471,14 @@ 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 (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
@@ -524,7 +511,8 @@ export class NoteEditService implements OnApplicationShutdown {
const noteObj = await this.noteEntityService.pack(note);
this.globalEventService.publishNoteStream(note.id, 'updated', {
- updatedAt: note.updatedAt!,
+ cw: note.cw,
+ text: note.text!,
});
this.roleService.addNoteToRoleTimeline(noteObj);
@@ -652,6 +640,163 @@ export class NoteEditService implements OnApplicationShutdown {
}
@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
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) {
const text = note.cw ?? note.text ?? '';
diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts
index 258ae44f7d..32d54d2576 100644
--- a/packages/backend/src/core/NotificationService.ts
+++ b/packages/backend/src/core/NotificationService.ts
@@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
+import { UserListService } from '@/core/UserListService.js';
@Injectable()
export class NotificationService implements OnApplicationShutdown {
@@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService,
private cacheService: CacheService,
+ private userListService: UserListService,
) {
}
@@ -74,27 +76,59 @@ export class NotificationService implements OnApplicationShutdown {
public async createNotification(
notifieeId: MiUser['id'],
type: MiNotification['type'],
- data: Partial<MiNotification>,
+ data: Omit<Partial<MiNotification>, 'notifierId'>,
+ notifierId?: MiUser['id'] | null,
): Promise<MiNotification | null> {
const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
- const isMuted = profile.mutingNotificationTypes.includes(type);
- if (isMuted) return null;
- if (data.notifierId) {
- if (notifieeId === data.notifierId) {
+ // 古いMisskeyバージョンのキャッシュが残っている可能性がある
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const recieveConfig = (profile.notificationRecieveConfig ?? {})[type];
+ if (recieveConfig?.type === 'never') {
+ return null;
+ }
+
+ if (notifierId) {
+ if (notifieeId === notifierId) {
return null;
}
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
- if (mutings.has(data.notifierId)) {
+ if (mutings.has(notifierId)) {
return null;
}
+
+ if (recieveConfig?.type === 'following') {
+ const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
+ if (!isFollowing) {
+ return null;
+ }
+ } else if (recieveConfig?.type === 'follower') {
+ const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
+ if (!isFollower) {
+ return null;
+ }
+ } else if (recieveConfig?.type === 'mutualFollow') {
+ const [isFollowing, isFollower] = await Promise.all([
+ this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
+ this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
+ ]);
+ if (!isFollowing && !isFollower) {
+ return null;
+ }
+ } else if (recieveConfig?.type === 'list') {
+ const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId));
+ if (!isMember) {
+ return null;
+ }
+ }
}
const notification = {
id: this.idService.genId(),
createdAt: new Date(),
type: type,
+ notifierId: notifierId,
...data,
} as MiNotification;
@@ -117,8 +151,8 @@ export class NotificationService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
- if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
- if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
+ if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
+ if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
}, () => { /* aborted, ignore it */ });
return notification;
diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts
index 9145726f86..18bd49286e 100644
--- a/packages/backend/src/core/QueryService.ts
+++ b/packages/backend/src/core/QueryService.ts
@@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
-import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
+import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import type { SelectQueryBuilder } from 'typeorm';
@@ -23,9 +23,6 @@ export class QueryService {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
- @Inject(DI.mutedNotesRepository)
- private mutedNotesRepository: MutedNotesRepository,
-
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@@ -109,39 +106,6 @@ export class QueryService {
}
@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
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('threadMuted.threadId')
@@ -213,32 +177,6 @@ export class QueryService {
}
@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
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) {
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
index d9bde502c8..25464b19a8 100644
--- a/packages/backend/src/core/ReactionService.ts
+++ b/packages/backend/src/core/ReactionService.ts
@@ -219,10 +219,9 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
this.notificationService.createNotification(note.userId, 'reaction', {
- notifierId: user.id,
noteId: note.id,
reaction: reaction,
- });
+ }, user.id);
}
//#region 配信
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 934b7d676b..f2bd9de5ee 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import type { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { StreamMessages } from '@/server/api/stream/types.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@@ -32,6 +32,7 @@ export type RolePolicies = {
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
+ canUseTranslator: boolean;
canHideAds: boolean;
driveCapacityMb: number;
alwaysMarkNsfw: boolean;
@@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
+ canUseTranslator: true,
canHideAds: false,
driveCapacityMb: 100,
alwaysMarkNsfw: false,
@@ -114,7 +116,7 @@ export class RoleService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
- const { type, body } = obj.message as StreamMessages['internal']['payload'];
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'roleCreated': {
const cached = this.rolesCache.get();
@@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown {
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
+ canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts
index 37031e341e..087dfd9214 100644
--- a/packages/backend/src/core/UserBlockingService.ts
+++ b/packages/backend/src/core/UserBlockingService.ts
@@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
-import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
+import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js';
import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
private cacheService: CacheService,
private userEntityService: UserEntityService,
@@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit {
});
for (const userList of userLists) {
- await this.userListJoiningsRepository.delete({
+ await this.userListMembershipsRepository.delete({
userListId: userList.id,
userId: user.id,
});
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index 5b2b0205d9..beffcc2e9c 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit {
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
- if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
+ if (
+ 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;
// 鍵アカウントであっても、既にフォローされていた場合はスルー
@@ -230,8 +234,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
- notifierId: followee.id,
- });
+ }, followee.id);
}
if (alreadyFollowed) return;
@@ -304,8 +307,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(followee.id, 'follow', {
- notifierId: follower.id,
- });
+ }, follower.id);
}
}
@@ -488,9 +490,8 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(followee.id, 'receiveFollowRequest', {
- notifierId: follower.id,
followRequestId: followRequest.id,
- });
+ }, follower.id);
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts
index a71d50bba5..bece1e442e 100644
--- a/packages/backend/src/core/UserListService.ts
+++ b/packages/backend/src/core/UserListService.ts
@@ -3,11 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Inject, Injectable } from '@nestjs/common';
-import type { UserListJoiningsRepository } from '@/models/_.js';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import type { UserListMembershipsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserList } from '@/models/UserList.js';
-import type { MiUserListJoining } from '@/models/UserListJoining.js';
+import type { MiUserListMembership } from '@/models/UserListMembership.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@@ -16,14 +17,24 @@ import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
+import { RedisKVCache } from '@/misc/cache.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
@Injectable()
-export class UserListService {
+export class UserListService implements OnApplicationShutdown {
public static TooManyUsersError = class extends Error {};
+ public membersCache: RedisKVCache<Set<string>>;
+
constructor(
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.redisForSub)
+ private redisForSub: Redis.Redis,
+
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
@@ -32,24 +43,63 @@ export class UserListService {
private proxyAccountService: ProxyAccountService,
private queueService: QueueService,
) {
+ this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
+ lifetime: 1000 * 60 * 30, // 30m
+ memoryCacheLifetime: 1000 * 60, // 1m
+ fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
+ toRedisConverter: (value) => JSON.stringify(Array.from(value)),
+ fromRedisConverter: (value) => new Set(JSON.parse(value)),
+ });
+
+ this.redisForSub.on('message', this.onMessage);
}
@bindThis
- public async push(target: MiUser, list: MiUserList, me: MiUser) {
- const currentCount = await this.userListJoiningsRepository.countBy({
+ private async onMessage(_: string, data: string): Promise<void> {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
+ switch (type) {
+ case 'userListMemberAdded': {
+ const { userListId, memberId } = body;
+ const members = await this.membersCache.get(userListId);
+ if (members) {
+ members.add(memberId);
+ }
+ break;
+ }
+ case 'userListMemberRemoved': {
+ const { userListId, memberId } = body;
+ const members = await this.membersCache.get(userListId);
+ if (members) {
+ members.delete(memberId);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+
+ @bindThis
+ public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
+ const currentCount = await this.userListMembershipsRepository.countBy({
userListId: list.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new UserListService.TooManyUsersError();
}
- await this.userListJoiningsRepository.insert({
+ await this.userListMembershipsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: target.id,
userListId: list.id,
- } as MiUserListJoining);
+ } as MiUserListMembership);
+ this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
@@ -60,4 +110,44 @@ export class UserListService {
}
}
}
+
+ @bindThis
+ public async removeMember(target: MiUser, list: MiUserList) {
+ await this.userListMembershipsRepository.delete({
+ userId: target.id,
+ userListId: list.id,
+ });
+
+ this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id });
+ 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
+ public dispose(): void {
+ this.redisForSub.off('message', this.onMessage);
+ this.membersCache.dispose();
+ }
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts
new file mode 100644
index 0000000000..d16e1be615
--- /dev/null
+++ b/packages/backend/src/core/UserService.ts
@@ -0,0 +1,53 @@
+/*
+ * 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(),
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
index 1344f0ac97..ff70f7bc0c 100644
--- a/packages/backend/src/core/WebhookService.ts
+++ b/packages/backend/src/core/WebhookService.ts
@@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
-import { StreamMessages } from '@/server/api/stream/types.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
- const { type, body } = obj.message as StreamMessages['internal']['payload'];
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'webhookCreated':
if (body.active) {
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index b3e140e588..4444e4118d 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit {
} else if (meId === packedNote.userId) {
hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
- // 自分の投稿に対するリプライ
+ // 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
- // 自分へのメンション
+ // 自分へのメンション
hide = false;
} else {
- // フォロワーかどうか
+ // フォロワーかどうか
const isFollowing = await this.followingsRepository.exist({
where: {
followeeId: packedNote.userId,
@@ -312,6 +312,7 @@ export class NoteEntityService implements OnModuleInit {
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
userId: note.userId,
user: this.userEntityService.pack(note.user ?? note.userId, me, {
detail: false,
@@ -342,7 +343,6 @@ export class NoteEntityService implements OnModuleInit {
mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
- updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined,
...(meId ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
} : {}),
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index e1ba71ffd1..b8fb9f8db7 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -458,7 +458,8 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances,
- mutingNotificationTypes: profile!.mutingNotificationTypes,
+ mutingNotificationTypes: [], // 後方互換性のため
+ notificationRecieveConfig: profile!.notificationRecieveConfig,
emailNotificationTypes: profile!.emailNotificationTypes,
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
@@ -492,6 +493,7 @@ export class UserEntityService implements OnModuleInit {
isMuted: relation.isMuted,
isRenoteMuted: relation.isRenoteMuted,
notify: relation.following?.notify ?? 'none',
+ withReplies: relation.following?.withReplies ?? false,
} : {}),
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;
diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts
index a7f2885194..06b6e852b1 100644
--- a/packages/backend/src/core/entities/UserListEntityService.ts
+++ b/packages/backend/src/core/entities/UserListEntityService.ts
@@ -5,11 +5,12 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
+import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUserList } from '@/models/UserList.js';
import { bindThis } from '@/decorators.js';
+import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class UserListEntityService {
@@ -17,8 +18,10 @@ export class UserListEntityService {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
+
+ private userEntityService: UserEntityService,
) {
}
@@ -28,7 +31,7 @@ export class UserListEntityService {
): Promise<Packed<'UserList'>> {
const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src });
- const users = await this.userListJoiningsRepository.findBy({
+ const users = await this.userListMembershipsRepository.findBy({
userListId: userList.id,
});
@@ -40,5 +43,18 @@ export class UserListEntityService {
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,
+ })));
+ }
}