diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4ecb3ffe..87db026183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,18 @@ - Enhance: URLプレビューの有効化・無効化を設定できるように #13569 - Enhance: アンテナでBotによるノートを除外できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545) +- Enhance: クリップのノート数を表示するように +- Enhance: コンディショナルロールの条件として以下を新たに追加 (#13667) + - 猫ユーザーか + - botユーザーか + - サスペンド済みユーザーか + - 鍵アカウントユーザーか + - 「アカウントを見つけやすくする」が有効なユーザーか - Fix: Play作成時に設定した公開範囲が機能していない問題を修正 +- Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正 ### Client +- Feat: アップロードするファイルの名前をランダム文字列にできるように - Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように - Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように - Enhance: リアクション・いいねの総数を表示するように @@ -25,6 +34,8 @@ - Enhance: ノートについているリアクションの「もっと!」から、リアクションの一覧を表示できるように - Enhance: リプライにて引用がある場合テキストが空でもノートできるように - 引用したいノートのURLをコピーしリプライ投稿画面にペーストして添付することで達成できます +- Enhance: フォローするかどうかの確認ダイアログを出せるように +- Chore: AiScriptを0.18.0にバージョンアップ - Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: ローカルURLのプレビューポップアップが左上に表示される @@ -38,6 +49,7 @@ - Fix: CWのみの引用リノートが詳細ページで純粋なリノートとして誤って扱われてしまう問題を修正 - Fix: ノート詳細ページにおいてCW付き引用リノートのCWボタンのラベルに「引用」が含まれていない問題を修正 - Fix: ダイアログの入力で字数制限に違反していてもEnterキーが押せてしまう問題を修正 +- Fix: ダイレクト投稿の宛先が保存されない問題を修正 ### Server - Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに @@ -47,6 +59,13 @@ - Fix: エンドポイント`notes/translate`のエラーを改善 - Fix: CleanRemoteFilesProcessorService report progress from 100% (#13632) - Fix: 一部の音声ファイルが映像ファイルとして扱われる問題を修正 +- Fix: リプライのみの引用リノートと、CWのみの引用リノートが純粋なリノートとして誤って扱われてしまう問題を修正 +- Fix: 登録にメール認証が必須になっている場合、登録されているメールアドレスを削除できないように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/606) +- Fix: nginx経由で/files/にRangeリクエストされた場合に正しく応答できないのを修正 +- Fix: 一部のタイムラインのストリーミングでインスタンスミュートが効かない問題を修正 +- Fix: グローバルタイムラインで返信が表示されないことがある問題を修正 +- Fix: リノートをミュートしたユーザの投稿のリノートがミュートされる問題を修正 ## 2024.3.1 diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c2d92e1663..281f2e6ccd 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -30,9 +30,13 @@ Cypress.Commands.add('visitHome', () => { }) Cypress.Commands.add('resetState', () => { + // iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。 + // see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123 + /* cy.window().then(win => { win.indexedDB.deleteDatabase('keyval-store'); }); + */ cy.request('POST', '/api/reset-db', {}).as('reset'); cy.get('@reset').its('status').should('equal', 204); cy.reload(true); diff --git a/locales/index.d.ts b/locales/index.d.ts index 28f428d51b..d3a4424c2a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5133,6 +5133,22 @@ export interface Locale extends ILocale { * 動画・音声の再生にブラウザのUIを使用する */ "useNativeUIForVideoAudioPlayer": string; + /** + * オリジナルのファイル名を保持 + */ + "keepOriginalFilename": string; + /** + * この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。 + */ + "keepOriginalFilenameDescription": string; + /** + * 説明文はありません + */ + "noDescription": string; + /** + * フォローの際常に確認する + */ + "alwaysConfirmFollow": string; "_bubbleGame": { /** * 遊び方 @@ -6781,6 +6797,26 @@ export interface Locale extends ILocale { * リモートユーザー */ "isRemote": string; + /** + * 猫ユーザー + */ + "isCat": string; + /** + * botユーザー + */ + "isBot": string; + /** + * サスペンド済みユーザー + */ + "isSuspended": string; + /** + * 鍵アカウントユーザー + */ + "isLocked": string; + /** + * 「アカウントを見つけやすくする」が有効なユーザー + */ + "isExplorable": string; /** * アカウント作成から~以内 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 807dedecd1..597b420c39 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1279,6 +1279,10 @@ useTotp: "ワンタイムパスワードを使う" useBackupCode: "バックアップコードを使う" launchApp: "アプリを起動" useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する" +keepOriginalFilename: "オリジナルのファイル名を保持" +keepOriginalFilenameDescription: "この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。" +noDescription: "説明文はありません" +alwaysConfirmFollow: "フォローの際常に確認する" _bubbleGame: howToPlay: "遊び方" @@ -1750,6 +1754,11 @@ _role: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" isRemote: "リモートユーザー" + isCat: "猫ユーザー" + isBot: "botユーザー" + isSuspended: "サスペンド済みユーザー" + isLocked: "鍵アカウントユーザー" + isExplorable: "「アカウントを見つけやすくする」が有効なユーザー" createdLessThan: "アカウント作成から~以内" createdMoreThan: "アカウント作成から~経過" followersLessThanOrEq: "フォロワー数が~以下" diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html index 061608bf53..1b7ff9e0d2 100644 --- a/packages/backend/assets/redoc.html +++ b/packages/backend/assets/redoc.html @@ -19,6 +19,6 @@ - + diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 6aa63d7d55..2f4d98fab4 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; @@ -101,7 +101,7 @@ export class FanoutTimelineEndpointService { if (ps.excludePureRenotes) { const parentFilter = filter; - filter = (note) => !isPureRenote(note) && parentFilter(note); + filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note); } if (ps.me) { @@ -122,9 +122,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (note.mentions.some(mention => userIdsWhoMeMuting.has(mention))) return false; - if (isPureRenote(note) && note.renote && note.renote.mentions.some(mention => userIdsWhoMeMuting.has(mention))) return false; - if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; return parentFilter(note); diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index b7c9064cef..79e810ce4e 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -10,6 +10,7 @@ import { Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; @@ -33,6 +34,8 @@ export class MfmService { // some AP servers like Pixelfed use br tags as well as newlines html = html.replace(/\r?\n/gi, '\n'); + const normalizedHashtagNames = hashtagNames == null ? undefined : new Set(hashtagNames.map(x => normalizeForSearch(x))); + const dom = parse5.parseFragment(html); let text = ''; @@ -85,7 +88,7 @@ export class MfmService { const href = node.attrs.find(x => x.name === 'href'); // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 631d7074bd..9b6d4d2901 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -495,7 +495,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -855,7 +855,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // If it is renote - if (data.renote) { + if (this.isRenote(data)) { const type = this.isQuote(data) ? 'quote' : 'renote'; // Notify @@ -1055,9 +1055,20 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); + private isRenote(note: Option): note is Option & { renote: MiNote } { + return note.renote != null; + } + + @bindThis + private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } + ) { + // NOTE: SYNC WITH misc/is-quote.ts + return note.text != null || + note.reply != null || + note.cw != null || + note.poll != null || + (note.files != null && note.files.length > 0); } @bindThis @@ -1133,7 +1144,7 @@ export class NoteCreateService implements OnApplicationShutdown { private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { if (data.localOnly) return null; - const content = data.renote && !this.isQuote(data) + const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 471ade92c7..af65750a01 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -86,7 +86,7 @@ export class NoteDeleteService { let renote: MiNote | null = null; // if deleted note is renote - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2059a6e784..f5a753afc7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -209,45 +209,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { try { switch (value.type) { + // ~かつ~ case 'and': { return value.values.every(v => this.evalCond(user, roles, v)); } + // ~または~ case 'or': { return value.values.some(v => this.evalCond(user, roles, v)); } + // ~ではない case 'not': { return !this.evalCond(user, roles, value.value); } + // マニュアルロールがアサインされている case 'roleAssignedTo': { return roles.some(r => r.id === value.roleId); } + // ローカルユーザのみ case 'isLocal': { return this.userEntityService.isLocalUser(user); } + // リモートユーザのみ case 'isRemote': { return this.userEntityService.isRemoteUser(user); } + // サスペンド済みユーザである + case 'isSuspended': { + return user.isSuspended; + } + // 鍵アカウントユーザである + case 'isLocked': { + return user.isLocked; + } + // botユーザである + case 'isBot': { + return user.isBot; + } + // 猫である + case 'isCat': { + return user.isCat; + } + // 「ユーザを見つけやすくする」が有効なアカウント + case 'isExplorable': { + return user.isExplorable; + } + // ユーザが作成されてから指定期間経過した case 'createdLessThan': { return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000)); } + // ユーザが作成されてから指定期間経っていない case 'createdMoreThan': { return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000)); } + // フォロワー数が指定値以下 case 'followersLessThanOrEq': { return user.followersCount <= value.value; } + // フォロワー数が指定値以上 case 'followersMoreThanOrEq': { return user.followersCount >= value.value; } + // フォロー数が指定値以下 case 'followingLessThanOrEq': { return user.followingCount <= value.value; } + // フォロー数が指定値以上 case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + // ノート数が指定値以下 case 'notesLessThanOrEq': { return user.notesCount <= value.value; } + // ノート数が指定値以上 case 'notesMoreThanOrEq': { return user.notesCount >= value.value; } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 26fcd6714d..ce49c3458c 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; +import type { ClipNotesRepository, ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; @@ -20,6 +20,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, @@ -47,6 +50,7 @@ export class ClipEntityService { isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, + notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, }); } diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts deleted file mode 100644 index f9c2243a06..0000000000 --- a/packages/backend/src/misc/is-pure-renote.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiNote } from '@/models/Note.js'; - -export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable } { - if (!note.renoteId) return false; - - if (note.text) return false; // it's quoted with text - if (note.fileIds.length !== 0) return false; // it's quoted with files - if (note.hasPoll) return false; // it's quoted with poll - return true; -} diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts deleted file mode 100644 index 75b29f63f4..0000000000 --- a/packages/backend/src/misc/is-quote.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiNote } from '@/models/Note.js'; - -// eslint-disable-next-line import/no-default-export -export default function(note: MiNote): boolean { - // sync with NoteCreateService.isQuote - return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); -} diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts new file mode 100644 index 0000000000..48f821806c --- /dev/null +++ b/packages/backend/src/misc/is-renote.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { MiNote } from '@/models/Note.js'; +import type { Packed } from '@/misc/json-schema.js'; + +type Renote = + MiNote & { + renoteId: NonNullable + }; + +type Quote = + Renote & ({ + text: NonNullable + } | { + cw: NonNullable + } | { + replyId: NonNullable + reply: NonNullable + } | { + hasPoll: true + }); + +export function isRenote(note: MiNote): note is Renote { + return note.renoteId != null; +} + +export function isQuote(note: Renote): note is Quote { + // NOTE: SYNC WITH NoteCreateService.isQuote + return note.text != null || + note.cw != null || + note.replyId != null || + note.hasPoll || + note.fileIds.length > 0; +} + +type PackedRenote = + Packed<'Note'> & { + renoteId: NonNullable['renoteId']> + }; + +type PackedQuote = + PackedRenote & ({ + text: NonNullable['text']> + } | { + cw: NonNullable['cw']> + } | { + replyId: NonNullable['replyId']> + } | { + poll: NonNullable['poll']> + } | { + fileIds: NonNullable['fileIds']> + }); + +export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { + return note.renoteId != null; +} + +export function isQuotePacked(note: PackedRenote): note is PackedQuote { + return note.text != null || + note.cw != null || + note.replyId != null || + note.poll != null || + (note.fileIds != null && note.fileIds.length > 0); +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 46b0bb2fab..a620d7c94b 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -48,6 +48,7 @@ import { packedRoleCondFormulaValueCreatedSchema, packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaValueSchema, + packedRoleCondFormulaValueUserSettingBooleanSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; @@ -97,6 +98,7 @@ export const refs = { RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, RoleCondFormulaValueNot: packedRoleCondFormulaValueNot, RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema, + RoleCondFormulaValueUserSettingBooleanSchema: packedRoleCondFormulaValueUserSettingBooleanSchema, RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema, RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema, RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index 058abe3118..a173971b2c 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -6,69 +6,149 @@ import { Entity, Column, PrimaryColumn } from 'typeorm'; import { id } from './util/id.js'; +/** + * ~かつ~ + * 複数の条件を同時に満たす場合のみ成立とする + */ type CondFormulaValueAnd = { type: 'and'; values: RoleCondFormulaValue[]; }; +/** + * ~または~ + * 複数の条件のうち、いずれかを満たす場合のみ成立とする + */ type CondFormulaValueOr = { type: 'or'; values: RoleCondFormulaValue[]; }; +/** + * ~ではない + * 条件を満たさない場合のみ成立とする + */ type CondFormulaValueNot = { type: 'not'; value: RoleCondFormulaValue; }; +/** + * ローカルユーザーのみ成立とする + */ type CondFormulaValueIsLocal = { type: 'isLocal'; }; +/** + * リモートユーザーのみ成立とする + */ type CondFormulaValueIsRemote = { type: 'isRemote'; }; +/** + * 既に指定のマニュアルロールにアサインされている場合のみ成立とする + */ type CondFormulaValueRoleAssignedTo = { type: 'roleAssignedTo'; roleId: string; }; +/** + * サスペンド済みアカウントの場合のみ成立とする + */ +type CondFormulaValueIsSuspended = { + type: 'isSuspended'; +}; + +/** + * 鍵アカウントの場合のみ成立とする + */ +type CondFormulaValueIsLocked = { + type: 'isLocked'; +}; + +/** + * botアカウントの場合のみ成立とする + */ +type CondFormulaValueIsBot = { + type: 'isBot'; +}; + +/** + * 猫アカウントの場合のみ成立とする + */ +type CondFormulaValueIsCat = { + type: 'isCat'; +}; + +/** + * 「ユーザを見つけやすくする」が有効なアカウントの場合のみ成立とする + */ +type CondFormulaValueIsExplorable = { + type: 'isExplorable'; +}; + +/** + * ユーザが作成されてから指定期間経過した場合のみ成立とする + */ type CondFormulaValueCreatedLessThan = { type: 'createdLessThan'; sec: number; }; +/** + * ユーザが作成されてから指定期間経っていない場合のみ成立とする + */ type CondFormulaValueCreatedMoreThan = { type: 'createdMoreThan'; sec: number; }; +/** + * フォロワー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowersLessThanOrEq = { type: 'followersLessThanOrEq'; value: number; }; +/** + * フォロワー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowersMoreThanOrEq = { type: 'followersMoreThanOrEq'; value: number; }; +/** + * フォロー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowingLessThanOrEq = { type: 'followingLessThanOrEq'; value: number; }; +/** + * フォロー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowingMoreThanOrEq = { type: 'followingMoreThanOrEq'; value: number; }; +/** + * 投稿数が指定値以下の場合のみ成立とする + */ type CondFormulaValueNotesLessThanOrEq = { type: 'notesLessThanOrEq'; value: number; }; +/** + * 投稿数が指定値以上の場合のみ成立とする + */ type CondFormulaValueNotesMoreThanOrEq = { type: 'notesMoreThanOrEq'; value: number; @@ -80,6 +160,11 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueNot | CondFormulaValueIsLocal | CondFormulaValueIsRemote | + CondFormulaValueIsSuspended | + CondFormulaValueIsLocked | + CondFormulaValueIsBot | + CondFormulaValueIsCat | + CondFormulaValueIsExplorable | CondFormulaValueRoleAssignedTo | CondFormulaValueCreatedLessThan | CondFormulaValueCreatedMoreThan | diff --git a/packages/backend/src/models/json-schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts index ca4886c978..c4e7055cd8 100644 --- a/packages/backend/src/models/json-schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -52,5 +52,9 @@ export const packedClipSchema = { type: 'boolean', optional: true, nullable: false, }, + notesCount: { + type: 'integer', + optional: true, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7eba1d5443..08580d22de 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -57,6 +57,20 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = { }, } as const; +export const packedRoleCondFormulaValueUserSettingBooleanSchema = { + type: 'object', + properties: { + id: { + type: 'string', optional: false, + }, + type: { + type: 'string', + nullable: false, optional: false, + enum: ['isSuspended', 'isLocked', 'isBot', 'isCat', 'isExplorable'], + }, + }, +} as const; + export const packedRoleCondFormulaValueAssignedRoleSchema = { type: 'object', properties: { @@ -135,6 +149,9 @@ export const packedRoleCondFormulaValueSchema = { { ref: 'RoleCondFormulaValueIsLocalOrRemote', }, + { + ref: 'RoleCondFormulaValueUserSettingBooleanSchema', + }, { ref: 'RoleCondFormulaValueAssignedRole', }, diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 64bce07a98..398ad54f6a 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -33,7 +33,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; import type Logger from '@/logger.js'; @@ -107,7 +107,7 @@ export class ActivityPubServerService { */ @bindThis private async packActivity(note: MiNote): Promise { - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 3cb353f2b3..e0b187f3cf 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -212,6 +212,8 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition( 'inline', @@ -254,6 +256,7 @@ export class FileServerService { return fs.createReadStream(file.path); } else { reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', file.filename)); @@ -528,9 +531,7 @@ export class FileServerService { if (!file.storedInternal) { if (!(file.isLink && file.uri)) return '204'; const result = await this.downloadAndDetectTypeFromUrl(file.uri); - if (!file.size) { - file.size = (await fs.promises.stat(result.path)).size; - } + file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので return { ...result, url: file.uri, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 5a456e09ad..6adf8d9f9f 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -122,12 +122,20 @@ export class ServerService implements OnApplicationShutdown { return; } - const name = path.split('@')[0].replace(/\.webp$/i, ''); - const host = path.split('@')[1]?.replace(/\.webp$/i, ''); + const emojiPath = path.replace(/\.webp$/i, ''); + const pathChunks = emojiPath.split('@'); + + if (pathChunks.length > 2) { + reply.code(400); + return; + } + + const name = pathChunks.shift(); + const host = pathChunks.pop(); const emoji = await this.emojisRepository.findOneBy({ // `@.` is the spec of ReactionService.decodeReaction - host: (host == null || host === '.') ? IsNull() : host, + host: (host === undefined || host === '.') ? IsNull() : host, name: name, }); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 08a8301bd1..7332026d84 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -16,6 +16,7 @@ import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { UserAuthService } from '@/core/UserAuthService.js'; +import { MetaService } from '@/core/MetaService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,6 +41,12 @@ export const meta = { code: 'UNAVAILABLE', id: 'a2defefb-f220-8849-0af6-17f816099323', }, + + emailRequired: { + message: 'Email address is required.', + code: 'EMAIL_REQUIRED', + id: '324c7a88-59f2-492f-903f-89134f93e47e', + }, }, res: { @@ -67,6 +74,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + private metaService: MetaService, private userEntityService: UserEntityService, private emailService: EmailService, private userAuthService: UserAuthService, @@ -98,6 +106,8 @@ export default class extends Endpoint { // eslint- if (!res.available) { throw new ApiError(meta.errors.unavailable); } + } else if ((await this.metaService.fetch()).emailRequiredForSignup) { + throw new ApiError(meta.errors.emailRequired); } await this.userProfilesRepository.update(me.id, { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 95ebda2f21..296698522d 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -16,7 +16,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -286,7 +286,7 @@ export default class extends Endpoint { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isPureRenote(renote)) { + } else if (isRenote(renote) && !isQuote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -332,7 +332,7 @@ export default class extends Endpoint { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isPureRenote(reply)) { + } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 44a143538b..a267d27fba 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -4,6 +4,10 @@ */ import { bindThis } from '@/decorators.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { Packed } from '@/misc/json-schema.js'; import type Connection from './Connection.js'; /** @@ -54,6 +58,24 @@ export default abstract class Channel { return this.connection.subscriber; } + /* + * ミュートとブロックされてるを処理する + */ + protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return true; + + // 流れてきたNoteがミュートしているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; + // 流れてきたNoteがブロックされているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true; + + // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの + if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + + return false; + } + constructor(id: string, connection: Connection) { this.id = id; this.connection = connection; diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 135d162e63..4a1d2dd109 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -4,7 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -40,12 +39,7 @@ class AntennaChannel extends Channel { if (data.type === 'note') { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 90ee1ecda5..140dd3dd9b 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -4,10 +4,10 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChannelChannel extends Channel { @@ -38,14 +38,9 @@ class ChannelChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index fc25724782..0a894147a2 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -4,14 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class GlobalTimelineChannel extends Channel { @@ -55,31 +53,13 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (this.isNoteMutedOrBlocked(note)) return; - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 377b1a0162..57bada5d9c 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -5,10 +5,10 @@ import { Injectable } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HashtagChannel extends Channel { @@ -43,14 +43,9 @@ class HashtagChannel extends Channel { const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); if (!matched) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; 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 0a4852ee8d..84ff241469 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -4,12 +4,10 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HomeTimelineChannel extends Channel { @@ -51,9 +49,6 @@ class HomeTimelineChannel extends Channel { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; - if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } else if (note.visibility === 'specified') { @@ -74,7 +69,7 @@ class HomeTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; // 純粋なリノート(引用リノートでないリノート)の場合 - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) { + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; @@ -83,17 +78,9 @@ class HomeTimelineChannel extends Channel { } } - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 02786e9e16..b83d3ec817 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -4,14 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -74,8 +72,7 @@ class HybridTimelineChannel extends Channel { if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; + if (this.isNoteMutedOrBlocked(note)) return; if (note.reply) { const reply = note.reply; @@ -90,17 +87,7 @@ class HybridTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 71b5675402..48cc76c497 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -4,13 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class LocalTimelineChannel extends Channel { @@ -66,19 +65,11 @@ class LocalTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 80aab4b35e..6a4ad22460 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -4,8 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -46,12 +44,7 @@ class RoleTimelineChannel extends Channel { } if (note.visibility !== 'public') return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; this.send('note', note); } else { diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index f7bb106c03..14b30a157c 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -5,12 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { @@ -106,25 +105,17 @@ class UserListChannel extends Channel { } } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; } } - // 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する - if (isInstanceMuted(note, this.userMutedInstances)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 9826068e48..1abbb4f044 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -63,6 +63,22 @@ describe('Renote Mute', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); + // #12956 + test('タイムラインにリノートミュートしているユーザーの通常ノートのリノートが含まれる', async () => { + const carolNote = await post(carol, { text: 'hi' }); + const bobRenote = await post(bob, { renoteId: carolNote.id }); + + // redisに追加されるのを待つ + await sleep(100); + + const res = await api('notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { const bobNote = await post(bob, { text: 'hi' }); @@ -86,4 +102,17 @@ describe('Renote Mute', () => { assert.strictEqual(fired, true); }); + + // #12956 + test('ストリームにリノートミュートしているユーザーの通常ノートのリノートが流れてくる', async () => { + const carolbNote = await post(carol, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: carolbNote.id }, bob), + msg => msg.type === 'note' && msg.body.userId === bob.id, + ); + + assert.strictEqual(fired, true); + }); }); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 26bb68ec6c..b0a70074c6 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -63,7 +63,7 @@ describe('Streaming', () => { takumiNote = await post(takumi, { text: 'piyo' }); // Follow: ayano => kyoko - await api('following/create', { userId: kyoko.id }, ayano); + await api('following/create', { userId: kyoko.id, withReplies: false }, ayano); // Follow: ayano => akari await follow(ayano, akari); @@ -509,6 +509,16 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + test('withReplies = falseでフォローしてる人によるリプライが流れてくる', async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + ); + + assert.strictEqual(fired, true); + }); }); describe('UserList Timeline', () => { diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts new file mode 100644 index 0000000000..f2d4c8ffbb --- /dev/null +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MiNote } from '@/models/Note.js'; +import { IPoll } from '@/models/Poll.js'; +import { MiDriveFile } from '@/models/DriveFile.js'; + +describe('NoteCreateService', () => { + let noteCreateService: NoteCreateService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + noteCreateService = app.get(NoteCreateService); + }); + + describe('is-renote', () => { + const base: MiNote = { + id: 'some-note-id', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: null, + name: null, + cw: null, + userId: 'some-user-id', + user: null, + localOnly: false, + reactionAcceptance: null, + renoteCount: 0, + repliesCount: 0, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, + }; + + const poll: IPoll = { + choices: ['kinoko', 'takenoko'], + multiple: false, + expiresAt: null, + }; + + const file: MiDriveFile = { + id: 'some-file-id', + userId: null, + user: null, + userHost: null, + md5: '', + name: '', + type: '', + size: 0, + comment: null, + blurhash: null, + properties: {}, + storedInternal: false, + url: '', + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId: null, + folder: null, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }; + + test('note without renote should not be Renote', () => { + const note = { renote: null }; + expect(noteCreateService['isRenote'](note)).toBe(false); + }); + + test('note with renote should be Renote and not be Quote', () => { + const note = { renote: base }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(false); + }); + + test('note with renote and text should be Quote', () => { + const note = { renote: base, text: 'some-text' }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and cw should be Quote', () => { + const note = { renote: base, cw: 'some-cw' }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and reply should be Quote', () => { + const note = { renote: base, reply: { ...base, id: 'another-note-id' } }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and poll should be Quote', () => { + const note = { renote: base, poll }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + + test('note with renote and non-empty files should be Quote', () => { + const note = { renote: base, files: [file] }; + expect(noteCreateService['isRenote'](note)).toBe(true); + expect(noteCreateService['isQuote'](note)).toBe(true); + }); + }); +}); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 19d03570e0..ec441735d7 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; @@ -20,6 +22,7 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { RoleCondFormulaValue } from '@/models/Role.js'; import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -52,12 +55,26 @@ describe('RoleService', () => { id: genAidx(Date.now()), updatedAt: new Date(), lastUsedAt: new Date(), + name: '', description: '', ...data, }) .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); } + function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial = {}) { + return createRole({ + name: `[conditional] ${condFormula.type}`, + target: 'conditional', + condFormula: condFormula, + ...data, + }); + } + + function aidx() { + return genAidx(Date.now()); + } + beforeEach(async () => { clock = lolex.install({ now: new Date(), @@ -73,6 +90,7 @@ describe('RoleService', () => { CacheService, IdService, GlobalEventService, + UserEntityService, { provide: NotificationService, useFactory: () => ({ @@ -209,79 +227,6 @@ describe('RoleService', () => { expect(result.driveCapacityMb).toBe(100); }); - test('conditional role', async () => { - const user1 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - }); - const user2 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - followersCount: 10, - }); - await createRole({ - name: 'a', - policies: { - canManageCustomEmojis: { - useDefault: false, - priority: 0, - value: true, - }, - }, - target: 'conditional', - condFormula: { - id: '232a4221-9816-49a6-a967-ae0fac52ec5e', - type: 'and', - values: [{ - id: '2a37ef43-2d93-4c4d-87f6-f2fdb7d9b530', - type: 'followersMoreThanOrEq', - value: 10, - }, { - id: '1bd67839-b126-4f92-bad0-4e285dab453b', - type: 'createdMoreThan', - sec: 60 * 60 * 24 * 7, - }], - }, - }); - - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const user1Policies = await roleService.getUserPolicies(user1.id); - const user2Policies = await roleService.getUserPolicies(user2.id); - expect(user1Policies.canManageCustomEmojis).toBe(false); - 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({ @@ -320,6 +265,427 @@ describe('RoleService', () => { }); }); + describe('conditional role', () => { + test('~かつ~', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + createUser({ isBot: false, isCat: false, isSuspended: true }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role3 = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'and', + values: [role1.condFormula, role2.condFormula], + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + const actual4 = await roleService.getUserRoles(user4.id); + expect(actual1.some(r => r.id === role4.id)).toBe(false); + expect(actual2.some(r => r.id === role4.id)).toBe(false); + expect(actual3.some(r => r.id === role4.id)).toBe(true); + expect(actual4.some(r => r.id === role4.id)).toBe(false); + }); + + test('~または~', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + createUser({ isBot: false, isCat: false, isSuspended: true }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role3 = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'or', + values: [role1.condFormula, role2.condFormula], + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + const actual4 = await roleService.getUserRoles(user4.id); + expect(actual1.some(r => r.id === role4.id)).toBe(true); + expect(actual2.some(r => r.id === role4.id)).toBe(true); + expect(actual3.some(r => r.id === role4.id)).toBe(true); + expect(actual4.some(r => r.id === role4.id)).toBe(false); + }); + + test('~ではない', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'not', + value: role1.condFormula, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role4.id)).toBe(false); + expect(actual2.some(r => r.id === role4.id)).toBe(true); + expect(actual3.some(r => r.id === role4.id)).toBe(false); + }); + + test('マニュアルロールにアサイン済み', async () => { + const [user1, user2, role1] = await Promise.all([ + createUser(), + createUser(), + createRole({ + name: 'manual role', + }), + ]); + const role2 = await createConditionalRole({ + id: aidx(), + 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('ローカルユーザのみ', async () => { + const [user1, user2] = await Promise.all([ + createUser({ host: null }), + createUser({ host: 'example.com' }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isLocal', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(false); + }); + + test('リモートユーザのみ', async () => { + const [user1, user2] = await Promise.all([ + createUser({ host: null }), + createUser({ host: 'example.com' }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isRemote', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('サスペンド済みユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isSuspended: false }), + createUser({ isSuspended: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('鍵アカウントユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isLocked: false }), + createUser({ isLocked: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isLocked', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('botユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isBot: false }), + createUser({ isBot: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('猫である', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isCat: false }), + createUser({ isCat: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('「ユーザを見つけやすくする」が有効なアカウント', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isExplorable: false }), + createUser({ isExplorable: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isExplorable', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('ユーザが作成されてから指定期間経過した', async () => { + const base = new Date(); + base.setMinutes(base.getMinutes() - 5); + + const d1 = new Date(base); + const d2 = new Date(base); + const d3 = new Date(base); + d1.setSeconds(d1.getSeconds() - 1); + d3.setSeconds(d3.getSeconds() + 1); + + const [user1, user2, user3] = await Promise.all([ + // 4:59 + createUser({ id: genAidx(d1.getTime()) }), + // 5:00 + createUser({ id: genAidx(d2.getTime()) }), + // 5:01 + createUser({ id: genAidx(d3.getTime()) }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'createdLessThan', + // 5 minutes + sec: 300, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(false); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('ユーザが作成されてから指定期間経っていない', async () => { + const base = new Date(); + base.setMinutes(base.getMinutes() - 5); + + const d1 = new Date(base); + const d2 = new Date(base); + const d3 = new Date(base); + d1.setSeconds(d1.getSeconds() - 1); + d3.setSeconds(d3.getSeconds() + 1); + + const [user1, user2, user3] = await Promise.all([ + // 4:59 + createUser({ id: genAidx(d1.getTime()) }), + // 5:00 + createUser({ id: genAidx(d2.getTime()) }), + // 5:01 + createUser({ id: genAidx(d3.getTime()) }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'createdMoreThan', + // 5 minutes + sec: 300, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(false); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロワー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followersCount: 99 }), + createUser({ followersCount: 100 }), + createUser({ followersCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followersLessThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロワー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followersCount: 99 }), + createUser({ followersCount: 100 }), + createUser({ followersCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followersMoreThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('フォロー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followingCount: 99 }), + createUser({ followingCount: 100 }), + createUser({ followingCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followingLessThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロー数が指定値以上', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followingCount: 99 }), + createUser({ followingCount: 100 }), + createUser({ followingCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followingMoreThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('ノート数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ notesCount: 9 }), + createUser({ notesCount: 10 }), + createUser({ notesCount: 11 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'notesLessThanOrEq', + value: 10, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('ノート数が指定値以上', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ notesCount: 9 }), + createUser({ notesCount: 10 }), + createUser({ notesCount: 11 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'notesMoreThanOrEq', + value: 10, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + }); + describe('assign', () => { test('公開ロールの場合は通知される', async () => { const user = await createUser(); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts new file mode 100644 index 0000000000..0b713e8bf6 --- /dev/null +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { MiNote } from '@/models/Note.js'; + +const base: MiNote = { + id: 'some-note-id', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: null, + name: null, + cw: null, + userId: 'some-user-id', + user: null, + localOnly: false, + reactionAcceptance: null, + renoteCount: 0, + repliesCount: 0, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, +}; + +describe('misc:is-renote', () => { + test('note without renoteId should not be Renote', () => { + expect(isRenote(base)).toBe(false); + }); + + test('note with renoteId should be Renote and not be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(false); + }); + + test('note with renoteId and text should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', text: 'some-text' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and cw should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', cw: 'some-cw' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and replyId should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', replyId: 'some-reply-id' }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and poll should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', hasPoll: true }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); + + test('note with renoteId and non-empty fileIds should be Quote', () => { + const note: MiNote = { ...base, renoteId: 'some-renote-id', fileIds: ['some-file-id'] }; + expect(isRenote(note)).toBe(true); + expect(isQuote(note as any)).toBe(true); + }); +}); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d37ccba84b..81f3388f41 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "5.0.5", "@rollup/pluginutils": "5.1.0", "@transfem-org/sfm-js": "0.24.4", - "@syuilo/aiscript": "0.17.0", + "@syuilo/aiscript": "0.18.0", "@phosphor-icons/web": "^2.0.3", "@twemoji/parser": "15.0.0", "@vitejs/plugin-vue": "5.0.4", diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index c51ad4356d..6299a28e9f 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -4,37 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only -->