diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbe22f2f6..bb339ff26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,10 @@ ## 202x.x.x (unreleased) ### General +- 通知がミュート、凍結を考慮するようになりました - Enhance: サーバーごとにモデレーションノートを残せるように +- Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加 +- Enhance: 通知の受信設定に「フォロー中またはフォロワー」を追加 ### Client - Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整 @@ -22,16 +25,23 @@ - Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正 - Fix: チャートのラベルが消えている問題を修正 - Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正 +- Fix: 設定のバックアップ作成時に名前を入力しなかった場合、ローカライゼーションがおかしくなる問題を修正 +- Fix: ページ`/admin/emojis`の絵文字編集ダイアログで「リアクションとして使えるロール」を追加する際に何も選択せずOKを押下すると画面が固まる問題を修正 - Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正 ### Server - Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正 +- Fix: 破損した通知をクライアントに送信しないように + * 通知欄が無限にリロードされる問題が改善する可能性があります - エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました - Fix: 禁止キーワードを含むノートがDelayed Queueに追加されて再処理される問題を修正 +- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正 +- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正 - エンドポイント`admin/emoji/update`の各種修正 - 必須パラメータを`id`または`name`のいずれかのみに - `id`の代わりに`name`で絵文字を指定可能に(`id`・`name`両指定時は従来通り`name`を変更する挙動) - `category`および`licence`が指定なしの時勝手にnullに上書きされる挙動を修正 +- Fix: 通知の受信設定で「相互フォロー」が正しく動作しない問題を修正 ## 2024.2.0 diff --git a/locales/index.d.ts b/locales/index.d.ts index 1a2565b06a..3edc9d235e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4656,6 +4656,10 @@ export interface Locale extends ILocale { * 相互フォロー */ "mutualFollow": string; + /** + * フォロー中またはフォロワー + */ + "followingOrFollower": string; /** * ファイル付きのみ */ @@ -6528,6 +6532,10 @@ export interface Locale extends ILocale { "avatarDecorationLimit": string; }; "_condition": { + /** + * マニュアルロールにアサイン済み + */ + "roleAssignedTo": string; /** * ローカルユーザー */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 61c61b8f96..66ddf6a46d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1160,6 +1160,7 @@ showRenotes: "リノートを表示" edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" +followingOrFollower: "フォロー中またはフォロワー" fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" @@ -1687,6 +1688,7 @@ _role: canUseTranslator: "翻訳機能の利用" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" _condition: + roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" isRemote: "リモートユーザー" createdLessThan: "アカウント作成から~以内" diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index ee16193579..af5755f88b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown { 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 === 'followingOrFollower') { 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)), @@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown { const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); + if (packed == null) return null; + // Publish notification event this.globalEventService.publishMainStream(notifieeId, 'notification', packed); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index c5baaf3fff..8312489a78 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -200,17 +200,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean { + private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { try { switch (value.type) { case 'and': { - return value.values.every(v => this.evalCond(user, v)); + return value.values.every(v => this.evalCond(user, roles, v)); } case 'or': { - return value.values.some(v => this.evalCond(user, v)); + return value.values.some(v => this.evalCond(user, roles, v)); } case 'not': { - return !this.evalCond(user, value.value); + return !this.evalCond(user, roles, value.value); + } + case 'roleAssignedTo': { + return roles.some(r => r.id === value.roleId); } case 'isLocal': { return this.userEntityService.isLocalUser(user); @@ -272,7 +275,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { const assigns = await this.getUserAssigns(userId); const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; - const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); + const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; } @@ -285,13 +288,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); - const assignedRoleIds = assigns.map(x => x.roleId); const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); + const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); + const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; - const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); + const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { return assignedBadgeRoles; diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 0663898edb..94d56c883b 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -14,14 +14,14 @@ import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; -import { FilterUnionByProperty, notificationTypes } from '@/types.js'; +import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; +import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); -const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { @@ -41,6 +41,8 @@ export class NotificationEntityService implements OnModuleInit { @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + private cacheService: CacheService, + //private userEntityService: UserEntityService, //private noteEntityService: NoteEntityService, ) { @@ -52,130 +54,48 @@ export class NotificationEntityService implements OnModuleInit { this.roleEntityService = this.moduleRef.get('RoleEntityService'); } - @bindThis - public async pack( - src: MiNotification, + /** + * 通知をパックする共通処理 + */ + async #packInternal ( + src: T, meId: MiUser['id'], // eslint-disable-next-line @typescript-eslint/ban-types options: { - + checkValidNotifier?: boolean; }, hint?: { packedNotes: Map>; packedUsers: Map>; }, - ): Promise> { + ): Promise | null> { const notification = src; - const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( + + if (options.checkValidNotifier !== false && !(await this.#isValidNotifier(notification, meId))) return null; + + const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification; + const noteIfNeed = needsNote ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) : this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, }) ) : undefined; - const userIfNeed = 'notifierId' in notification ? ( - hint?.packedUsers != null - ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId, { id: meId }) - ) : undefined; - const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined; - - return await awaitAll({ - id: notification.id, - createdAt: new Date(notification.createdAt).toISOString(), - type: notification.type, - userId: 'notifierId' in notification ? notification.notifierId : undefined, - ...(userIfNeed != null ? { user: userIfNeed } : {}), - ...(noteIfNeed != null ? { note: noteIfNeed } : {}), - ...(notification.type === 'reaction' ? { - reaction: notification.reaction, - } : {}), - ...(notification.type === 'roleAssigned' ? { - role: role, - } : {}), - ...(notification.type === 'achievementEarned' ? { - achievement: notification.achievement, - } : {}), - ...(notification.type === 'app' ? { - body: notification.customBody, - header: notification.customHeader, - icon: notification.customIcon, - } : {}), - }); - } - - @bindThis - public async packMany( - notifications: MiNotification[], - meId: MiUser['id'], - ) { - if (notifications.length === 0) return []; - - let validNotifications = notifications; - - const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); - const notes = noteIds.length > 0 ? await this.notesRepository.find({ - where: { id: In(noteIds) }, - relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], - }) : []; - const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { - detail: true, - }); - const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - - validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - - const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull); - const users = userIds.length > 0 ? await this.usersRepository.find({ - where: { id: In(userIds) }, - }) : []; - const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }); - const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); - - // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); - if (followRequestNotifications.length > 0) { - const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, - }); - validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); - } - - return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { - packedNotes, - packedUsers, - }))); - } - - @bindThis - public async packGrouped( - src: MiGroupedNotification, - meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types - options: { - - }, - hint?: { - packedNotes: Map>; - packedUsers: Map>; - }, - ): Promise> { - const notification = src; - const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( - hint?.packedNotes != null - ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId, { id: meId }, { - detail: true, - }) - ) : undefined; - const userIfNeed = 'notifierId' in notification ? ( + // if the note has been deleted, don't show this notification + if (needsNote && !noteIfNeed) return null; + + const needsUser = 'notifierId' in notification; + const userIfNeed = needsUser ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) : this.userEntityService.pack(notification.notifierId, { id: meId }) ) : undefined; + // if the user has been deleted, don't show this notification + if (needsUser && !userIfNeed) return null; + // #region Grouped notifications if (notification.type === 'reaction:grouped') { - const reactions = await Promise.all(notification.reactions.map(async reaction => { + const reactions = (await Promise.all(notification.reactions.map(async reaction => { const user = hint?.packedUsers != null ? hint.packedUsers.get(reaction.userId)! : await this.userEntityService.pack(reaction.userId, { id: meId }); @@ -183,7 +103,12 @@ export class NotificationEntityService implements OnModuleInit { user, reaction: reaction.reaction, }; - })); + }))).filter(r => isNotNull(r.user)); + // if all users have been deleted, don't show this notification + if (reactions.length === 0) { + return null; + } + return await awaitAll({ id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), @@ -192,14 +117,19 @@ export class NotificationEntityService implements OnModuleInit { reactions, }); } else if (notification.type === 'renote:grouped') { - const users = await Promise.all(notification.userIds.map(userId => { + const users = (await Promise.all(notification.userIds.map(userId => { const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null; if (packedUser) { return packedUser; } return this.userEntityService.pack(userId, { id: meId }); - })); + }))).filter(isNotNull); + // if all users have been deleted, don't show this notification + if (users.length === 0) { + return null; + } + return await awaitAll({ id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), @@ -208,8 +138,14 @@ export class NotificationEntityService implements OnModuleInit { users, }); } + // #endregion - const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined; + const needsRole = notification.type === 'roleAssigned'; + const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined; + // if the role has been deleted, don't show this notification + if (needsRole && !role) { + return null; + } return await awaitAll({ id: notification.id, @@ -235,15 +171,16 @@ export class NotificationEntityService implements OnModuleInit { }); } - @bindThis - public async packGroupedMany( - notifications: MiGroupedNotification[], + async #packManyInternal ( + notifications: T[], meId: MiUser['id'], - ) { + ): Promise { if (notifications.length === 0) return []; let validNotifications = notifications; + validNotifications = await this.#filterValidNotifier(validNotifications, meId); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, @@ -269,7 +206,7 @@ export class NotificationEntityService implements OnModuleInit { const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); if (followRequestNotifications.length > 0) { const reqs = await this.followRequestsRepository.find({ where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, @@ -277,9 +214,107 @@ export class NotificationEntityService implements OnModuleInit { validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); } - return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { - packedNotes, - packedUsers, - }))); + const packPromises = validNotifications.map(x => { + return this.pack( + x, + meId, + { checkValidNotifier: false }, + { packedNotes, packedUsers }, + ); + }); + + return (await Promise.all(packPromises)).filter(isNotNull); + } + + @bindThis + public async pack( + src: MiNotification | MiGroupedNotification, + meId: MiUser['id'], + // eslint-disable-next-line @typescript-eslint/ban-types + options: { + checkValidNotifier?: boolean; + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; + }, + ): Promise | null> { + return await this.#packInternal(src, meId, options, hint); + } + + @bindThis + public async packMany( + notifications: MiNotification[], + meId: MiUser['id'], + ): Promise { + return await this.#packManyInternal(notifications, meId); + } + + @bindThis + public async packGroupedMany( + notifications: MiGroupedNotification[], + meId: MiUser['id'], + ): Promise { + return await this.#packManyInternal(notifications, meId); + } + + /** + * notifierが存在するか、ミュートされていないか、サスペンドされていないかを確認するvalidator + */ + #validateNotifier ( + notification: T, + userIdsWhoMeMuting: Set, + userMutedInstances: Set, + notifiers: MiUser[], + ): boolean { + if (!('notifierId' in notification)) return true; + if (userIdsWhoMeMuting.has(notification.notifierId)) return false; + + const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null; + + if (notifier == null) return false; + if (notifier.host && userMutedInstances.has(notifier.host)) return false; + + if (notifier.isSuspended) return false; + + return true; + } + + /** + * notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に確認する + */ + async #isValidNotifier( + notification: MiNotification | MiGroupedNotification, + meId: MiUser['id'], + ): Promise { + return (await this.#filterValidNotifier([notification], meId)).length === 1; + } + + /** + * notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に複数確認する + */ + async #filterValidNotifier ( + notifications: T[], + meId: MiUser['id'], + ): Promise { + const [ + userIdsWhoMeMuting, + userMutedInstances, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(meId), + this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)), + ]); + + const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull); + const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(notifierIds) }, + }) : []; + + const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => { + const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers); + return isValid ? notification : null; + }))) as [T | null] ).filter(isNotNull); + + return filteredNotifications; } } diff --git a/packages/backend/src/misc/FileWriterStream.ts b/packages/backend/src/misc/FileWriterStream.ts new file mode 100644 index 0000000000..828851df0e --- /dev/null +++ b/packages/backend/src/misc/FileWriterStream.ts @@ -0,0 +1,31 @@ +import * as fs from 'node:fs/promises'; +import type { PathLike } from 'node:fs'; + +/** + * `fs.createWriteStream()`相当のことを行う`WritableStream` (Web標準) + */ +export class FileWriterStream extends WritableStream { + constructor(path: PathLike) { + let file: fs.FileHandle | null = null; + + super({ + start: async () => { + file = await fs.open(path, 'a'); + }, + write: async (chunk, controller) => { + if (file === null) { + controller.error(); + throw new Error(); + } + + await file.write(chunk); + }, + close: async () => { + await file?.close(); + }, + abort: async () => { + await file?.close(); + }, + }); + } +} diff --git a/packages/backend/src/misc/JsonArrayStream.ts b/packages/backend/src/misc/JsonArrayStream.ts new file mode 100644 index 0000000000..ad35bb3a79 --- /dev/null +++ b/packages/backend/src/misc/JsonArrayStream.ts @@ -0,0 +1,30 @@ +import { TransformStream } from 'node:stream/web'; + +/** + * ストリームに流れてきた各データについて`JSON.stringify()`した上で、それらを一つの配列にまとめる + */ +export class JsonArrayStream extends TransformStream { + constructor() { + /** 最初の要素かどうかを変数に記録 */ + let isFirst = true; + + super({ + start(controller) { + controller.enqueue('['); + }, + flush(controller) { + controller.enqueue(']'); + }, + transform(chunk, controller) { + if (isFirst) { + isFirst = false; + } else { + // 妥当なJSON配列にするためには最初以外の要素の前に`,`を挿入しなければならない + controller.enqueue(',\n'); + } + + controller.enqueue(JSON.stringify(chunk)); + }, + }); + } +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 8449e5ff07..46b0bb2fab 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -44,6 +44,7 @@ import { packedRoleCondFormulaLogicsSchema, packedRoleCondFormulaValueNot, packedRoleCondFormulaValueIsLocalOrRemoteSchema, + packedRoleCondFormulaValueAssignedRoleSchema, packedRoleCondFormulaValueCreatedSchema, packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaValueSchema, @@ -96,6 +97,7 @@ export const refs = { RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, RoleCondFormulaValueNot: packedRoleCondFormulaValueNot, RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema, + RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema, RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema, RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, RoleCondFormulaValue: packedRoleCondFormulaValueSchema, diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index fa05ea8637..058abe3118 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -29,6 +29,11 @@ type CondFormulaValueIsRemote = { type: 'isRemote'; }; +type CondFormulaValueRoleAssignedTo = { + type: 'roleAssignedTo'; + roleId: string; +}; + type CondFormulaValueCreatedLessThan = { type: 'createdLessThan'; sec: number; @@ -75,6 +80,7 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueNot | CondFormulaValueIsLocal | CondFormulaValueIsRemote | + CondFormulaValueRoleAssignedTo | CondFormulaValueCreatedLessThan | CondFormulaValueCreatedMoreThan | CondFormulaValueFollowersLessThanOrEq | diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 1ca2f55850..7dbe0b3717 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -249,6 +249,8 @@ export class MiUserProfile { type: 'follower'; } | { type: 'mutualFollow'; + } | { + type: 'followingOrFollower'; } | { type: 'list'; userListId: MiUserList['id']; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index ef6b279bee..9f2b5b17ed 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -57,6 +57,23 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = { }, } as const; +export const packedRoleCondFormulaValueAssignedRoleSchema = { + type: 'object', + properties: { + type: { + type: 'string', + nullable: false, optional: false, + enum: ['roleAssignedTo'], + }, + roleId: { + type: 'string', + nullable: false, optional: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + }, +} as const; + export const packedRoleCondFormulaValueCreatedSchema = { type: 'object', properties: { @@ -115,6 +132,9 @@ export const packedRoleCondFormulaValueSchema = { { ref: 'RoleCondFormulaValueIsLocalOrRemote', }, + { + ref: 'RoleCondFormulaValueAssignedRole', + }, { ref: 'RoleCondFormulaValueCreated', }, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 952cd6bf80..947a9317d7 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -13,7 +13,7 @@ export const notificationRecieveConfig = { type: { type: 'string', nullable: false, - enum: ['all', 'following', 'follower', 'mutualFollow', 'never'], + enum: ['all', 'following', 'follower', 'mutualFollow', 'followingOrFollower', 'never'], }, }, required: ['type'], diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index f2ae0ce4b4..c7611012d7 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as fs from 'node:fs'; +import { ReadableStream, TextEncoderStream } from 'node:stream/web'; import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; @@ -18,10 +18,82 @@ import { bindThis } from '@/decorators.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { JsonArrayStream } from '@/misc/JsonArrayStream.js'; +import { FileWriterStream } from '@/misc/FileWriterStream.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; +class NoteStream extends ReadableStream> { + constructor( + job: Bull.Job, + notesRepository: NotesRepository, + pollsRepository: PollsRepository, + driveFileEntityService: DriveFileEntityService, + idService: IdService, + userId: string, + ) { + let exportedNotesCount = 0; + let cursor: MiNote['id'] | null = null; + + const serialize = ( + note: MiNote, + poll: MiPoll | null, + files: Packed<'DriveFile'>[], + ): Record => { + return { + id: note.id, + text: note.text, + createdAt: idService.parse(note.id).date.toISOString(), + fileIds: note.fileIds, + files: files, + replyId: note.replyId, + renoteId: note.renoteId, + poll: poll, + cw: note.cw, + visibility: note.visibility, + visibleUserIds: note.visibleUserIds, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + }; + }; + + super({ + async pull(controller): Promise { + const notes = await notesRepository.find({ + where: { + userId, + ...(cursor !== null ? { id: MoreThan(cursor) } : {}), + }, + take: 100, // 100件ずつ取得 + order: { id: 1 }, + }); + + if (notes.length === 0) { + job.updateProgress(100); + controller.close(); + } + + cursor = notes.at(-1)?.id ?? null; + + for (const note of notes) { + const poll = note.hasPoll + ? await pollsRepository.findOneByOrFail({ noteId: note.id }) // N+1 + : null; + const files = await driveFileEntityService.packManyByIds(note.fileIds); // N+1 + const content = serialize(note, poll, files); + + controller.enqueue(content); + exportedNotesCount++; + } + + const total = await notesRepository.countBy({ userId }); + job.updateProgress(exportedNotesCount / total); + }, + }); + } +} + @Injectable() export class ExportNotesProcessorService { private logger: Logger; @@ -59,67 +131,19 @@ export class ExportNotesProcessorService { this.logger.info(`Temp file is ${path}`); try { - const stream = fs.createWriteStream(path, { flags: 'a' }); + // メモリが足りなくならないようにストリームで処理する + await new NoteStream( + job, + this.notesRepository, + this.pollsRepository, + this.driveFileEntityService, + this.idService, + user.id, + ) + .pipeThrough(new JsonArrayStream()) + .pipeThrough(new TextEncoderStream()) + .pipeTo(new FileWriterStream(path)); - const write = (text: string): Promise => { - return new Promise((res, rej) => { - stream.write(text, err => { - if (err) { - this.logger.error(err); - rej(err); - } else { - res(); - } - }); - }); - }; - - await write('['); - - let exportedNotesCount = 0; - let cursor: MiNote['id'] | null = null; - - while (true) { - const notes = await this.notesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNote[]; - - if (notes.length === 0) { - job.updateProgress(100); - break; - } - - cursor = notes.at(-1)?.id ?? null; - - for (const note of notes) { - let poll: MiPoll | undefined; - if (note.hasPoll) { - poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); - } - const files = await this.driveFileEntityService.packManyByIds(note.fileIds); - const content = JSON.stringify(this.serialize(note, poll, files)); - const isFirst = exportedNotesCount === 0; - await write(isFirst ? content : ',\n' + content); - exportedNotesCount++; - } - - const total = await this.notesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(exportedNotesCount / total); - } - - await write(']'); - - stream.end(); this.logger.succ(`Exported to: ${path}`); const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; @@ -130,22 +154,4 @@ export class ExportNotesProcessorService { cleanup(); } } - - private serialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record { - return { - id: note.id, - text: note.text, - createdAt: this.idService.parse(note.id).date.toISOString(), - fileIds: note.fileIds, - files: files, - replyId: note.replyId, - renoteId: note.renoteId, - poll: poll, - cw: note.cw, - visibility: note.visibility, - visibleUserIds: note.visibleUserIds, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - }; - } } diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index 703808d279..dc6ffd3e02 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In } from 'typeorm'; +import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -48,10 +48,10 @@ export const paramDef = { markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], } }, }, required: [], @@ -79,12 +79,12 @@ export default class extends Endpoint { // eslint- return []; } // excludeTypes に全指定されている場合はクエリしない - if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) { return []; } - const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; + const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const notificationsRes = await this.redisClient.xrevrange( @@ -162,7 +162,6 @@ export default class extends Endpoint { // eslint- } groupedNotifications = groupedNotifications.slice(0, ps.limit); - const noteIds = groupedNotifications .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) .map(notification => notification.noteId!); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 52b6749e3f..320d9fdb00 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In } from 'typeorm'; +import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index ce9d7f5647..f45bf8622e 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -71,7 +71,15 @@ class HomeTimelineChannel extends Channel { } } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + } + } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 506a755cc4..d894ef730e 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -18,6 +18,7 @@ * achievementEarned - 実績を獲得 * app - アプリ通知 * test - テスト通知(サーバー側) + * */ export const notificationTypes = [ 'note', @@ -33,7 +34,15 @@ export const notificationTypes = [ 'roleAssigned', 'achievementEarned', 'app', - 'test'] as const; + 'test', +] as const; + +export const groupedNotificationTypes = [ + ...notificationTypes, + 'reaction:grouped', + 'renote:grouped', +] as const; + export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index e63067cd62..1e4225184a 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -117,5 +117,184 @@ describe('Mute', () => { assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); }); + test('通知にミュートしているユーザーからのリプライが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { text: '@alice hi', replyId: aliceNote.id }); + await post(carol, { text: '@alice hi', replyId: aliceNote.id }); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのリプライが含まれない', async () => { + await post(alice, { text: 'hi' }); + await post(bob, { text: '@alice hi' }); + await post(carol, { text: '@alice hi' }); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { text: 'hi', renoteId: aliceNote.id }); + await post(carol, { text: 'hi', renoteId: aliceNote.id }); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのリノートが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { renoteId: aliceNote.id }); + await post(carol, { renoteId: aliceNote.id }); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { + await api('/i/follow', { userId: alice.id }, bob); + await api('/i/follow', { userId: alice.id }, carol); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => { + await api('/i/update/', { isLocked: true }, alice); + await api('/following/create', { userId: alice.id }, bob); + await api('/following/create', { userId: alice.id }, carol); + + const res = await api('/i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + }); + + describe('Notification (Grouped)', () => { + test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await react(bob, aliceNote, 'like'); + await react(carol, aliceNote, 'like'); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + test('通知にミュートしているユーザーからのリプライが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { text: '@alice hi', replyId: aliceNote.id }); + await post(carol, { text: '@alice hi', replyId: aliceNote.id }); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのリプライが含まれない', async () => { + await post(alice, { text: 'hi' }); + await post(bob, { text: '@alice hi' }); + await post(carol, { text: '@alice hi' }); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { text: 'hi', renoteId: aliceNote.id }); + await post(carol, { text: 'hi', renoteId: aliceNote.id }); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのリノートが含まれない', async () => { + const aliceNote = await post(alice, { text: 'hi' }); + await post(bob, { renoteId: aliceNote.id }); + await post(carol, { renoteId: aliceNote.id }); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { + await api('/i/follow', { userId: alice.id }, bob); + await api('/i/follow', { userId: alice.id }, carol); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); + + test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => { + await api('/i/update/', { isLocked: true }, alice); + await api('/following/create', { userId: alice.id }, bob); + await api('/following/create', { userId: alice.id }, carol); + + const res = await api('/i/notifications-grouped', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + + assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); + assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + }); }); }); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 071daa275f..13d5a683ba 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -40,9 +40,9 @@ describe('Streaming', () => { let chinatsu: misskey.entities.SignupResponse; let takumi: misskey.entities.SignupResponse; - let kyokoNote: any; - let kanakoNote: any; - let takumiNote: any; + let kyokoNote: misskey.entities.Note; + let kanakoNote: misskey.entities.Note; + let takumiNote: misskey.entities.Note; let list: any; beforeAll(async () => { @@ -68,6 +68,9 @@ describe('Streaming', () => { // Follow: ayano => akari await follow(ayano, akari); + // Follow: kyoko => chitose + await api('following/create', { userId: chitose.id }, kyoko); + // Mute: chitose => kanako await api('mute/create', { userId: kanako.id }, chitose); @@ -170,7 +173,28 @@ describe('Streaming', () => { */ test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { - // TODO + const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' }); + + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + + test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信のリノートが流れない', async () => { + const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' }); + const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id }); + + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + ); + + assert.strictEqual(fired, false); }); test('フォローしていないユーザーの投稿は流れない', async () => { @@ -202,6 +226,39 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + test('withRenotes: false のときリノートが流れない', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { renoteId: kyokoNote.id }, kyoko), // kyoko renote + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + { withRenotes: false }, + ); + + assert.strictEqual(fired, false); + }); + + test('withRenotes: false のとき引用リノートが流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'quote', renoteId: kyokoNote.id }, kyoko), // kyoko quote + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + { withRenotes: false }, + ); + + assert.strictEqual(fired, true); + }); + + test('withRenotes: false のとき投票のみのリノートが流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { poll: { choices: ['kinoko', 'takenoko'] }, renoteId: kyokoNote.id }, kyoko), // kyoko renote with poll + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + { withRenotes: false }, + ); + + assert.strictEqual(fired, true); + }); }); // Home describe('Local Timeline', () => { diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 5222745b7f..fe5ad31597 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -251,6 +251,34 @@ describe('RoleService', () => { expect(user2Policies.canManageCustomEmojis).toBe(true); }); + test('コンディショナルロール: マニュアルロールにアサイン済み', async () => { + const [user1, user2, role1] = await Promise.all([ + createUser(), + createUser(), + createRole({ + name: 'manual role', + }), + ]); + const role2 = await createRole({ + name: 'conditional role', + target: 'conditional', + condFormula: { + // idはバックエンドのロジックに必要ない? + id: 'bdc612bd-9d54-4675-ae83-0499c82ea670', + type: 'roleAssignedTo', + roleId: role1.id, + }, + }); + await roleService.assign(user2.id, role1.id); + + const [u1role, u2role] = await Promise.all([ + roleService.getUserRoles(user1.id), + roleService.getUserRoles(user2.id), + ]); + expect(u1role.some(r => r.id === role2.id)).toBe(false); + expect(u2role.some(r => r.id === role2.id)).toBe(true); + }); + test('expired role', async () => { const user = await createUser(); const role = await createRole({ diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index a2220ffae6..cd5dddd68d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -355,7 +355,7 @@ export const uploadUrl = async (user: UserToken, url: string): Promise) => any, params?: any): Promise { +export function connectStream(user: UserToken, channel: C, listener: (message: Record) => any, params?: misskey.Channels[C]['params']): Promise { return new Promise((res, rej) => { const url = new URL(`ws://127.0.0.1:${port}/streaming`); const options: ClientOptions = {}; @@ -390,7 +390,7 @@ export function connectStream(user: UserToken, channel: string, listener: (messa }); } -export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { +export const waitFire = async (user: UserToken, channel: C, trgr: () => any, cond: (msg: Record) => boolean, params?: misskey.Channels[C]['params']) => { return new Promise(async (res, rej) => { let timer: NodeJS.Timeout | null = null; @@ -435,7 +435,7 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any */ export function makeStreamCatcher( user: UserToken, - channel: string, + channel: keyof misskey.Channels, cond: (message: Record) => boolean, extractor: (message: Record) => T, timeout = 60 * 1000): Promise { diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index e606fe368c..7f20e0b1a2 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -290,7 +290,7 @@ export async function openAccountMenu(opts: { text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, - }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 4b7584faaa..4577d37c08 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only -
{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} @@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n.js'; type Input = { - type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; placeholder?: string | null; autocomplete?: string; default: string | number | null; @@ -74,22 +69,17 @@ type Input = { type Select = { items: { - value: string; + value: any; text: string; }[]; - groupedItems: { - label: string; - items: { - value: string; - text: string; - }[]; - }[]; default: string | null; }; +type Result = string | number | true | null; + const props = withDefaults(defineProps<{ type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; - title: string; + title?: string; text?: string; input?: Input; select?: Select; @@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: { canceled: boolean; result: any }): void; + (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; (ev: 'closed'): void; }>(); @@ -139,8 +129,11 @@ const okButtonDisabledReason = computed(); const dialog = shallowRef>(); -const selected = ref([]); +const selected = ref([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(files: Misskey.entities.DriveFile[]) { - selected.value = files; +function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { + selected.value = v; } diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 59f4b51522..adcea839ee 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: any): void; + (ev: 'done', v: string): void; (ev: 'close'): void; (ev: 'closed'): void; }>(); @@ -64,7 +64,7 @@ const emit = defineEmits<{ const modal = shallowRef>(); const picker = shallowRef>(); -function chosen(emoji: any) { +function chosen(emoji: string) { emit('done', emoji); if (props.choseAndClose) { modal.value?.close(); diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue deleted file mode 100644 index 6952943345..0000000000 --- a/packages/frontend/src/components/MkEmojiPickerWindow.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 0d8734799c..deedc5badb 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
- @@ -62,6 +67,7 @@ import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/scripts/clone.js'; +import { rolesCache } from '@/cache.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -77,6 +83,8 @@ const props = defineProps<{ const v = ref(deepClone(props.modelValue)); +const roles = await rolesCache.fetch(); + watch(() => props.modelValue, () => { if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return; v.value = deepClone(props.modelValue); @@ -92,6 +100,7 @@ const type = computed({ if (t === 'and') v.value.values = []; if (t === 'or') v.value.values = []; if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' }; + if (t === 'roleAssignedTo') v.value.roleId = ''; if (t === 'createdLessThan') v.value.sec = 86400; if (t === 'createdMoreThan') v.value.sec = 86400; if (t === 'followersLessThanOrEq') v.value.value = 10; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 12e9416f72..16769ef360 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -135,7 +135,7 @@ async function addRole() { const { canceled, result: role } = await os.select({ items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), }); - if (canceled) return; + if (canceled || role == null) return; rolesThatCanBeUsedThisEmojiAsReaction.value.push(role); } diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 7db6fa5395..28f5838296 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -52,7 +52,7 @@ const directNotesPagination = { function setFilter(ev) { const typeItems = notificationTypes.map(t => ({ text: i18n.ts._notification._types[t], - active: includeTypes.value && includeTypes.value.includes(t), + active: (includeTypes.value && includeTypes.value.includes(t)) ?? false, action: () => { includeTypes.value = [t]; }, @@ -63,7 +63,7 @@ function setFilter(ev) { action: () => { includeTypes.value = null; }, - }, { type: 'divider' }, ...typeItems] : typeItems; + }, { type: 'divider' as const }, ...typeItems] : typeItems; os.popupMenu(items, ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index cd38f9850f..1919f80864 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -113,7 +113,7 @@ if (defaultStore.state.uploadFolder) { function chooseUploadFolder() { os.selectDriveFolder(false).then(async folder => { - defaultStore.set('uploadFolder', folder ? folder.id : null); + defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null); os.success(); if (defaultStore.state.uploadFolder) { uploadFolder.value = await misskeyApi('drive/folders/show', { diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue index 79969427ec..ce296ec183 100644 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ b/packages/frontend/src/pages/settings/emoji-picker.vue @@ -213,7 +213,7 @@ async function pickEmoji(itemsRef: Ref, ev: MouseEvent) { os.pickEmoji(getHTMLElement(ev), { showPinned: false, }).then(it => { - const emoji = it as string; + const emoji = it; if (!itemsRef.value.includes(emoji)) { itemsRef.value.push(emoji); } diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue index d6aac63674..a36f036303 100644 --- a/packages/frontend/src/pages/settings/notifications.notification-config.vue +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only + diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index febcfa32ed..bbcef65283 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : + $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : i18n.ts.all }} diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 676159d1b5..942de19d82 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -203,6 +203,7 @@ async function saveNew(): Promise { const { canceled, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (canceled) return; @@ -371,6 +372,7 @@ async function rename(id: string): Promise { const { canceled: cancel1, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (cancel1 || profiles.value[id].name === name) return; diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index 26a027f461..b0db404f28 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -12,29 +12,37 @@ export type FormItem = { label?: string; type: 'string'; default: string | null; + description?: string; + required?: boolean; hidden?: boolean; multiline?: boolean; + treatAsMfm?: boolean; } | { label?: string; type: 'number'; default: number | null; + description?: string; + required?: boolean; hidden?: boolean; step?: number; } | { label?: string; type: 'boolean'; default: boolean | null; + description?: string; hidden?: boolean; } | { label?: string; type: 'enum'; default: string | null; + required?: boolean; hidden?: boolean; enum: EnumItem[]; } | { label?: string; type: 'radio'; default: unknown | null; + required?: boolean; hidden?: boolean; options: { label: string; @@ -44,9 +52,12 @@ export type FormItem = { label?: string; type: 'range'; default: number | null; - step: number; + description?: string; + required?: boolean; + step?: number; min: number; max: number; + textConverter?: (value: number) => string; } | { label?: string; type: 'object'; @@ -57,6 +68,10 @@ export type FormItem = { type: 'array'; default: unknown[] | null; hidden: boolean; +} | { + type: 'button'; + content?: string; + action: (ev: MouseEvent, v: any) => void; }; export type Form = Record; diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 92d2e23d9b..bdb62dca15 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import { mainRouter } from '@/router/main.js'; +import { MenuItem } from '@/types/menu.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto'; loadDeck(); function changeProfile(ev: MouseEvent) { - const items = ref([{ + let items: MenuItem[] = [{ text: deckStore.state.profile, - active: true.valueOf, - }]); + active: true, + action: () => {}, + }]; getProfiles().then(profiles => { - items.value = [{ - text: deckStore.state.profile, - active: true.valueOf, - }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ text: k, action: () => { deckStore.set('profile', k); unisonReload(); }, - }))), { type: 'divider' }, { + }))), { type: 'divider' as const }, { text: i18n.ts._deck.newProfile, icon: 'ti ti-plus', action: async () => { @@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) { deckStore.set('profile', name); unisonReload(); }, - }]; + }); + }).then(() => { + os.popupMenu(items, ev.currentTarget ?? ev.target); }); - os.popupMenu(items, ev.currentTarget ?? ev.target); } async function deleteProfile() { diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 7a3671a240..b8efd3bda9 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -93,10 +93,10 @@ const fetch = () => { const choose = () => { os.selectDriveFolder(false).then(folder => { - if (folder == null) { + if (folder[0] == null) { return; } - widgetProps.folderId = folder.id; + widgetProps.folderId = folder[0].id; save(); fetch(); }); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index b5e7ec7548..0e990ffd5a 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1712,6 +1712,7 @@ declare namespace entities { RoleCondFormulaLogics, RoleCondFormulaValueNot, RoleCondFormulaValueIsLocalOrRemote, + RoleCondFormulaValueAssignedRole, RoleCondFormulaValueCreated, RoleCondFormulaFollowersOrFollowingOrNotes, RoleCondFormulaValue, @@ -2731,6 +2732,9 @@ type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics']; // @public (undocumented) type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue']; +// @public (undocumented) +type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole']; + // @public (undocumented) type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index ab49f9478a..6f61458600 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -38,6 +38,7 @@ export type Signin = components['schemas']['Signin']; export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics']; export type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot']; export type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormulaValueIsLocalOrRemote']; +export type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole']; export type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated']; export type RoleCondFormulaFollowersOrFollowingOrNotes = components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes']; export type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 733670d704..9a2ff7487f 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3700,7 +3700,7 @@ export type components = { notificationRecieveConfig: { note?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3709,7 +3709,7 @@ export type components = { }]>; follow?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3718,7 +3718,7 @@ export type components = { }]>; mention?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3727,7 +3727,7 @@ export type components = { }]>; reply?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3736,7 +3736,7 @@ export type components = { }]>; renote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3745,7 +3745,7 @@ export type components = { }]>; quote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3754,7 +3754,7 @@ export type components = { }]>; reaction?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3763,7 +3763,7 @@ export type components = { }]>; pollEnded?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3772,7 +3772,7 @@ export type components = { }]>; receiveFollowRequest?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3781,7 +3781,7 @@ export type components = { }]>; followRequestAccepted?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3790,7 +3790,7 @@ export type components = { }]>; roleAssigned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3799,7 +3799,7 @@ export type components = { }]>; achievementEarned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3808,7 +3808,7 @@ export type components = { }]>; app?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -3817,7 +3817,7 @@ export type components = { }]>; test?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -4573,6 +4573,15 @@ export type components = { /** @enum {string} */ type: 'isLocal' | 'isRemote'; }; + RoleCondFormulaValueAssignedRole: { + /** @enum {string} */ + type: 'roleAssignedTo'; + /** + * Format: id + * @example xxxxxxxxxx + */ + roleId: string; + }; RoleCondFormulaValueCreated: { id: string; /** @enum {string} */ @@ -4585,7 +4594,7 @@ export type components = { type: 'followersLessThanOrEq' | 'followersMoreThanOrEq' | 'followingLessThanOrEq' | 'followingMoreThanOrEq' | 'notesLessThanOrEq' | 'notesMoreThanOrEq'; value: number; }; - RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes']; + RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes']; RoleLite: { /** * Format: id @@ -8427,7 +8436,7 @@ export type operations = { notificationRecieveConfig: { note?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8436,7 +8445,7 @@ export type operations = { }]>; follow?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8445,7 +8454,7 @@ export type operations = { }]>; mention?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8454,7 +8463,7 @@ export type operations = { }]>; reply?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8463,7 +8472,7 @@ export type operations = { }]>; renote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8472,7 +8481,7 @@ export type operations = { }]>; quote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8481,7 +8490,7 @@ export type operations = { }]>; reaction?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8490,7 +8499,7 @@ export type operations = { }]>; pollEnded?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8499,7 +8508,7 @@ export type operations = { }]>; receiveFollowRequest?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8508,7 +8517,7 @@ export type operations = { }]>; followRequestAccepted?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8517,7 +8526,7 @@ export type operations = { }]>; roleAssigned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8526,7 +8535,7 @@ export type operations = { }]>; achievementEarned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8535,7 +8544,7 @@ export type operations = { }]>; app?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -8544,7 +8553,7 @@ export type operations = { }]>; test?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -17678,8 +17687,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; }; }; }; @@ -18778,7 +18787,7 @@ export type operations = { notificationRecieveConfig?: { note?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18787,7 +18796,7 @@ export type operations = { }]>; follow?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18796,7 +18805,7 @@ export type operations = { }]>; mention?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18805,7 +18814,7 @@ export type operations = { }]>; reply?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18814,7 +18823,7 @@ export type operations = { }]>; renote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18823,7 +18832,7 @@ export type operations = { }]>; quote?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18832,7 +18841,7 @@ export type operations = { }]>; reaction?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18841,7 +18850,7 @@ export type operations = { }]>; pollEnded?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18850,7 +18859,7 @@ export type operations = { }]>; receiveFollowRequest?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18859,7 +18868,7 @@ export type operations = { }]>; followRequestAccepted?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18868,7 +18877,7 @@ export type operations = { }]>; roleAssigned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18877,7 +18886,7 @@ export type operations = { }]>; achievementEarned?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18886,7 +18895,7 @@ export type operations = { }]>; app?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list'; @@ -18895,7 +18904,7 @@ export type operations = { }]>; test?: OneOf<[{ /** @enum {string} */ - type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never'; + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; }, { /** @enum {string} */ type: 'list';