1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-11 20:14:42 +02:00

Compare commits

...

15 Commits

Author SHA1 Message Date
github-actions[bot]
f066c1c2ef Bump version to 2026.7.0-alpha.1 2026-07-05 05:06:33 +00:00
かっこかり
1d41cb3d29 perf(frontend): UNIX時計ウィジェットでrequestAnimationFrameを使用するように (#17665) 2026-07-05 13:08:44 +09:00
かっこかり
1858d56918 fix(frontend): ワードミュートの判定が誤っている問題を修正 (#17668) 2026-07-05 13:07:17 +09:00
かっこかり
97082efd99 Update CHANGELOG.md [ci skip] 2026-07-04 20:46:03 +09:00
Johann150
187610d516 fix(frontend): correctly detect isMe in mention (#17664) 2026-07-04 20:43:42 +09:00
かっこかり
00b2cb2076 fix(backend/test): streaming.ts e2e の検証不備を修正 (#17663)
fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113)

Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com>
2026-07-04 18:53:10 +09:00
おさむのひと
426f6748e5 fix: frontend-builderのutils.tsが型エラーを起こしていたのを修正 (#17649) 2026-07-04 17:44:42 +09:00
かっこかり
c423bf92a8 Update CHANGELOG.md 2026-07-04 16:47:31 +09:00
かっこかり
f59bf35615 feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (#17635)
* URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214)

* Update Changelog

* fix

* Update Changelog

* fix

* fix: もうセンシティブと判定されている場合はそれをそのまま通すように

* fix: unnecessary import

* fix lint

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
2026-07-04 16:26:22 +09:00
おさむのひと
0f056c4955 refactor: Sentry呼び出し個所の抽象化 (#17661) 2026-07-04 16:06:24 +09:00
かっこかり
eb2c7ff6c6 fix(backend/test): follow-up of #17654 (waitForを他のe2eテストにも展開) (#17659)
test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-04 11:21:23 +09:00
おさむのひと
9f614517c0 fix: tsconfig.jsonにskipLibCheck: trueを追加する (#17651)
* fix: tsconfig.jsonにskipLibCheck: trueを追加する

* skipLibCheckで抜けたチェックを自前でやる
2026-07-03 19:22:23 +09:00
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
54 changed files with 1395 additions and 1108 deletions

View File

@@ -17,7 +17,9 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
pull_request:
paths:
- packages/backend/**
@@ -31,7 +33,9 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
jobs:
pnpm_install:
runs-on: ubuntu-latest
@@ -113,3 +117,22 @@ jobs:
- run: pnpm i --frozen-lockfile
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
check-dts:
needs: [pnpm_install]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: node --test scripts/check-dts.test.mjs
- run: pnpm check-dts

View File

@@ -1,4 +1,4 @@
## 2026.6.1
## 2026.7.0
### Note
@@ -15,12 +15,15 @@
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
### Client
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)

View File

@@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
_imageEditing:
_vars:

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.6.1-alpha.1",
"version": "2026.7.0-alpha.1",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -39,7 +39,8 @@
"migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"lint": "pnpm --no-bail -r lint",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UrlPreviewSensitiveList1782581064131 {
name = 'UrlPreviewSensitiveList1782581064131'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
}
}

View File

@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
namespace probeImageSize {} // Hack
export = probeImageSize;
// eslint-disable-next-line import/no-default-export
export { probeImageSize as default };
}

View File

@@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
@@ -66,26 +67,7 @@ export async function masterMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,

View File

@@ -6,6 +6,7 @@
import cluster from 'node:cluster';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
/**
@@ -16,26 +17,7 @@ export async function workerMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
if (envOption.onlyServer) {
await server();

View File

@@ -154,10 +154,12 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { TelemetryService } from './telemetry/TelemetryService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService };
const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService };
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
@@ -458,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -606,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
exports: [
@@ -757,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -903,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
})

View File

@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@Injectable()
export class TelemetryService implements OnApplicationShutdown {
@bindThis
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
captureMessage(message, opts);
}
@bindThis
public startSpan<T>(name: string, fn: () => T): T {
return startSpan(name, fn);
}
@bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry();
}
}

View File

@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof import('@sentry/node'),
) {
}
public static async create(config: NonNullable<Config['sentryForBackend']>): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.options,
});
return new SentryTelemetryAdapter(Sentry);
}
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
...(opts.userId != null ? { user: { id: opts.userId } } : {}),
extra: opts.extra,
});
}
public startSpan<T>(name: string, fn: () => T): T {
return this.Sentry.startSpan({ name }, fn);
}
public async shutdown(): Promise<void> {
await this.Sentry.close();
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface TelemetryCaptureMessageOptions {
level: 'error';
userId?: string;
extra?: Record<string, unknown>;
}
/**
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
* telemetry-registry.tsのinitTelemetry内で登録する。
*/
export interface TelemetryAdapter {
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
startSpan<T>(name: string, fn: () => T): T;
shutdown(): Promise<void>;
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
* DIを介さないモジュールレベルの状態として有効なアダプタを保持する。
* TelemetryServiceはこの状態への薄いラッパーとして振る舞う。
*/
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> {
if (config.sentryForBackend) {
adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
}
}
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
for (const adapter of adapters) {
adapter.captureMessage(message, opts);
}
}
export function startSpan<T>(name: string, fn: () => T): T {
const wrapped = adapters.reduceRight<() => T>(
(inner, adapter) => () => adapter.startSpan(name, inner),
fn,
);
return wrapped();
}
export async function shutdownTelemetry(): Promise<void> {
await Promise.all(adapters.map(adapter => adapter.shutdown()));
}

View File

@@ -672,6 +672,11 @@ export class MiMeta {
})
public urlPreviewUserAgent: string | null;
@Column('varchar', {
length: 3072, array: true, default: '{}',
})
public urlPreviewSensitiveList: string[];
@Column('varchar', {
length: 128,
default: 'none',

View File

@@ -9,6 +9,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private config: Config,
private queueLoggerService: QueueLoggerService,
private telemetryService: TelemetryService,
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
@@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
let Sentry: typeof import('@sentry/node') | undefined;
if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => {
Sentry = mod;
});
}
//#region system
{
const processer = (job: Bull.Job) => {
@@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
autorun: false,
@@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DB),
autorun: false,
@@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver
{
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
} else {
return this.deliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DELIVER),
autorun: false,
@@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox
{
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
} else {
return this.inboxProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.INBOX),
autorun: false,
@@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver
{
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
} else {
return this.userWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
autorun: false,
@@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver
{
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
} else {
return this.systemWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
autorun: false,
@@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
autorun: false,
@@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
autorun: false,
@@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification
{
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
} else {
return this.endedPollNotificationProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
autorun: false,
@@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region post scheduled note
{
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
} else {
return this.postScheduledNoteProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
autorun: false,

View File

@@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import type { Config } from '@/config.js';
import { ApiError } from './error.js';
import { RateLimiterService } from './RateLimiterService.js';
@@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown {
private logger: Logger;
private userIpHistories: Map<MiUser['id'], Set<string>>;
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
private Sentry: typeof import('@sentry/node') | null = null;
constructor(
@Inject(DI.meta)
@@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown {
private rateLimiterService: RateLimiterService,
private roleService: RoleService,
private apiLoggerService: ApiLoggerService,
private telemetryService: TelemetryService,
) {
this.logger = this.apiLoggerService.logger;
this.userIpHistories = new Map<MiUser['id'], Set<string>>();
@@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown {
this.userIpHistoriesClearIntervalId = setInterval(() => {
this.userIpHistories.clear();
}, 1000 * 60 * 60);
if (this.config.sentryForBackend) {
import('@sentry/node').then((Sentry) => {
this.Sentry = Sentry;
});
}
}
#sendApiError(reply: FastifyReply, err: ApiError): void {
@@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown {
},
});
if (this.Sentry != null) {
this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
user: {
id: userId,
this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
userId,
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
},
});
}
},
});
throw new ApiError(null, {
e: {
@@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown {
}
// API invoking
if (this.Sentry != null) {
return await this.Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
} else {
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
}
@bindThis

View File

@@ -544,6 +544,14 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
urlPreviewSensitiveList: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
federation: {
type: 'string',
enum: ['all', 'specified', 'none'],
@@ -760,6 +768,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
urlPreviewUserAgent: instance.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
urlPreviewSensitiveList: instance.urlPreviewSensitiveList,
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,

View File

@@ -189,6 +189,12 @@ export const paramDef = {
urlPreviewRequireContentLength: { type: 'boolean' },
urlPreviewUserAgent: { type: 'string', nullable: true },
urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
urlPreviewSensitiveList: {
type: 'array', nullable: true,
items: {
type: 'string',
}
},
federation: {
type: 'string',
enum: ['all', 'none', 'specified'],
@@ -734,6 +740,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
}
if (Array.isArray(ps.urlPreviewSensitiveList)) {
set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean);
}
if (ps.federation !== undefined) {
set.federation = ps.federation;
}

View File

@@ -11,6 +11,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js';
@@ -29,6 +30,7 @@ export class UrlPreviewService {
private meta: MiMeta,
private httpRequestService: HttpRequestService,
private utilityService: UtilityService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
@@ -95,6 +97,10 @@ export class UrlPreviewService {
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
if (summary.sensitive !== true) {
summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList);
}
// Cache 1day
reply.header('Cache-Control', 'max-age=86400, immutable');

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, beforeEach, test } from 'vitest';
import { describe, beforeAll, beforeEach, test, vi } from 'vitest';
import { UserToken, api, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('API visibility', () => {
describe('Note visibility', () => {
//#region vars
@@ -409,10 +411,12 @@ describe('API visibility', () => {
//#region HTL
test('[HTL] public-post が 自分が見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
test('[HTL] public-post が 非フォロワーから見れない', async () => {
@@ -423,10 +427,12 @@ describe('API visibility', () => {
});
test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
//#endregion

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { describe, beforeAll, test, vi } from 'vitest';
import { api, castAsError, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Block', () => {
// alice blocks bob
let alice: misskey.entities.SignupResponse;
@@ -75,13 +77,15 @@ describe('Block', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test, expect } from 'vitest';
import { describe, beforeAll, test, expect, vi } from 'vitest';
// node-fetch only supports it's own Blob yet
// https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch';
@@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile
import type * as misskey from 'misskey-js';
import { MiUser } from '@/models/_.js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Endpoints', () => {
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
@@ -1149,12 +1151,14 @@ describe('Endpoints', () => {
visibility: 'followers',
});
const res = await api('notes/timeline', {}, dave);
await vi.waitFor(async () => {
const res = await api('notes/timeline', {}, dave);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, react, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -67,13 +69,15 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
@@ -83,13 +87,15 @@ describe('Mute', () => {
renoteId: carolNote.id,
});
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,11 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { setTimeout } from 'node:timers/promises';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Renote Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -36,16 +37,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
@@ -53,16 +53,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
// #12956
@@ -70,15 +69,14 @@ describe('Renote Mute', () => {
const carolNote = await post(carol, { text: 'hi' });
const bobRenote = await post(bob, { renoteId: carolNote.id });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
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 => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
}, waitForPushToTlOptions);
});
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {

View File

@@ -510,7 +510,7 @@ describe('Streaming', () => {
test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => {
const erinNote = await post(erin, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
erin, 'homeTimeline', // erin:home
erin, 'hybridTimeline', // erin:Hybrid
() => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
);
@@ -521,7 +521,7 @@ describe('Streaming', () => {
test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => {
const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
ayano, 'hybridTimeline', // ayano:Hybrid
() => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post
msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin
);
@@ -530,9 +530,12 @@ describe('Streaming', () => {
});
test('withReplies: true のフォローしていない人のfollowersートに対するリプライが流れない', async () => {
// ayano は kyoko をフォローしているため kyoko の followers 投稿にリプライできるが、
// erin は kyoko をフォローしていないため、そのリプライは erin の Hybrid Timeline には流れないはず
const kyokoFollowersNote = await post(kyoko, { text: 'hi', visibility: 'followers' });
const fired = await waitFire(
erin, 'homeTimeline', // erin:home
() => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post
erin, 'hybridTimeline', // erin:Hybrid
() => api('notes/create', { text: 'hello', replyId: kyokoFollowersNote.id }, ayano), // ayano reply to kyoko's followers post
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
);
@@ -680,7 +683,7 @@ describe('Streaming', () => {
const fired = await waitFire(
chitose, 'userList',
() => api('notes/create', { text: 'foo' }, takumi),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
msg => msg.type === 'note' && msg.body.userId === takumi.id,
{ listId: list.id },
);

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, signup, uploadUrl } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('users/notes', () => {
let alice: misskey.entities.SignupResponse;
let jpgNote: misskey.entities.Note;
@@ -32,16 +34,18 @@ describe('users/notes', () => {
}, 1000 * 60 * 2);
test('withFiles', async () => {
const res = await api('users/notes', {
userId: alice.id,
withFiles: true,
}, alice);
await vi.waitFor(async () => {
const res = await api('users/notes', {
userId: alice.id,
withFiles: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 3);
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
}, waitForPushToTlOptions);
});
});

View File

@@ -7,6 +7,9 @@ import { beforeAll } from 'vitest';
import { initTestDb, sendEnvResetRequest } from './utils.js';
beforeAll(async () => {
await initTestDb(false);
// 前ファイルのNestJSアプリをdispose(env-reset)した後にスキーマをdrop & 再作成する。
// 逆順だと、前ファイルの最後のテストが投げっぱなしにした非同期処理(cacheServiceのrefresh等)が
// dispose前のdrop中に発火し、Unhandled Rejection (relation does not exist) でクラッシュしうる。
await sendEnvResetRequest();
await initTestDb(false);
});

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,

View File

@@ -8,5 +8,5 @@ export function assertNever(x: never): never {
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
}
export function assertType<T>(_node: unknown): asserts node is T {
export function assertType<T>(_node: unknown): asserts _node is T {
}

View File

@@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/theme.js';
import { Theme } from '@@/js/theme.js';
const theme: Theme;

View File

@@ -13,6 +13,7 @@
"module": "ES2022",
"moduleResolution": "Bundler",
"removeComments": false,
"skipLibCheck": true,
"noLib": false,
"strict": true,
"strictNullChecks": true,

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"outDir": "./js-built",
"removeComments": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,

View File

@@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/theme.js';
import { Theme } from '@@/js/theme.js';
const theme: Theme;

View File

@@ -5,7 +5,7 @@
import { watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
import { version, lang, isSafeMode } from '@@/js/config.js';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
@@ -30,6 +30,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { $i } from '@/i.js';
import { launchPlugins } from '@/plugin.js';
import { initTelemetry } from '@/telemetry.js';
export async function common(createVue: () => Promise<App<Element>>) {
console.info(`Misskey v${version}`);
@@ -286,40 +287,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
return root;
})();
if (instance.sentryForFrontend) {
const Sentry = await import('@sentry/vue');
Sentry.init({
app,
integrations: [
...(instance.sentryForFrontend.vueIntegration !== undefined ? [
Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined),
] : []),
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [
Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined),
] : []),
...(instance.sentryForFrontend.replayIntegration !== undefined ? [
Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined),
] : []),
],
// Set tracesSampleRate to 1.0 to capture 100%
tracesSampleRate: 1.0,
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? {
tracePropagationTargets: [apiUrl],
} : {}),
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
...(instance.sentryForFrontend.replayIntegration !== undefined ? {
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
} : {}),
...instance.sentryForFrontend.options,
});
}
await initTelemetry(instance, app);
try {
await launchPlugins();

View File

@@ -33,7 +33,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us
const url = `/${canonical}`;
const isMe = $i && (
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
`@${props.username}@${toUnicode(props.host)}`.toLowerCase() === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
);
const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar

View File

@@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="handleToggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
@@ -196,54 +196,30 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import { inject, ref, useTemplateRef, provide, computed } from 'vue';
import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import * as Misskey from 'misskey-js';
import { useNote } from '@/composables/use-note.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { userPage } from '@/filters/user.js';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import number from '@/filters/number.js';
import { DI } from '@/di.js';
import type { Keymap } from '@/utility/hotkey.js';
// コンポーネント外部の依存関係
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import * as sound from '@/utility/sound.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -254,48 +230,21 @@ const props = withDefaults(defineProps<{
mock: false,
});
provide(DI.mock, props.mock);
const emit = defineEmits<{
(ev: 'reaction', emoji: string): void;
(ev: 'removeReaction', emoji: string): void;
}>();
provide(DI.mock, props.mock);
// 周辺コンテキストのインジェクト
const inTimeline = inject<boolean>('inTimeline', false);
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject(DI.inChannel, null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
const currentAntenna = inject<Ref<Misskey.entities.Antenna | null> | null>('currentAntenna', null);
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
const hideByPlugin = ref(false);
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
if (result == null) {
hideByPlugin.value = true;
} else {
note = result as Misskey.entities.Note;
}
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: note,
mock: props.mock,
});
// Template Refsの定義
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
@@ -303,64 +252,80 @@ const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const muted = ref(checkMute(appearNote, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
// コンポーサブルの呼び出し
const {
note,
appearNote,
$appearNote,
hideByPlugin,
isRenote,
showContent,
translating,
translation,
muted,
hardMuted,
collapsed,
renoteCollapsed,
parsed,
urls,
isLong,
showTicker,
canRenote,
renote,
reply,
react,
reactViaMfmEmoji,
toggleReact,
onContextmenu,
showMenu,
clip,
showRenoteMenu,
blur,
} = useNote(props, {
rootEl,
menuButton,
renoteButton,
renoteTime,
reactButton,
clipButton,
}, {
inTimeline,
tl_withSensitive,
inChannel,
currentClip,
currentAntenna,
});
// provide
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
// MkNote固有
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref(
prefer.s.collapseRenotes && isRenote && (
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
($appearNote.myReaction != null)
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.id}`,
}));
/* eslint-disable no-redeclare */
/** checkOnlyでは純粋なワードミュート結果をbooleanで返却する */
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly?: false): Array<string | string[]> | false | 'sensitiveMute';
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | boolean | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) {
return checkOnly ? (result.length > 0) : result;
function handleToggleReact() {
toggleReact((reaction) => {
if ($appearNote.myReaction === reaction) {
emit('removeReaction', reaction);
} else {
emit('reaction', reaction);
$appearNote.reactions[reaction] = 1;
$appearNote.reactionCount++;
$appearNote.myReaction = reaction;
}
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) {
return checkOnly ? (replyResult.length > 0) : replyResult;
}
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) {
return checkOnly ? (renoteResult.length > 0) : renoteResult;
}
}
if (checkOnly) return false;
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
return false;
});
}
/* eslint-enable no-redeclare */
function emitUpdReaction(emoji: string, delta: number) {
if (delta < 0) {
emit('removeReaction', emoji);
} else if (delta > 0) {
emit('reaction', emoji);
}
}
// キーボードショートカットマップ
const keymap = {
'r': () => {
if (renoteCollapsed.value) return;
@@ -392,7 +357,7 @@ const keymap = {
renoteCollapsed.value = false;
} else if (appearNote.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
} else if (isLong.value) {
collapsed.value = !collapsed.value;
}
},
@@ -402,321 +367,13 @@ const keymap = {
},
'up|k|shift+tab': {
allowRepeat: true,
callback: () => focusBefore(),
callback: () => focusPrev(rootEl.value),
},
'down|j|tab': {
allowRepeat: true,
callback: () => focusAfter(),
callback: () => focusNext(rootEl.value),
},
} as const satisfies Keymap;
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
if (!props.mock) {
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1 || renoteButton.value == null) return;
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
anchorElement: renoteButton.value,
}, {
closed: () => dispose(),
});
});
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.id,
limit: 10,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
if (users.length < 1) return;
const { dispose } = os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: $appearNote.reactionCount,
anchorElement: reactButton.value!,
}, {
closed: () => dispose(),
});
});
}
}
async function renote() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
os.popupMenu(menu, renoteButton.value);
subscribeManuallyToNoteCapture();
}
async function reply() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
os.post({
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
}
async function react() {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
if (props.mock) {
return;
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
});
if (confirm.canceled) return;
}
sound.playMisskeySfx('reaction');
if (props.mock) {
emit('reaction', reaction);
$appearNote.reactions[reaction] = 1;
$appearNote.reactionCount++;
$appearNote.myReaction = reaction;
return;
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
focus();
});
}
}
function undoReact(): void {
const oldReaction = $appearNote.myReaction;
if (!oldReaction) return;
if (props.mock) {
emit('removeReaction', oldReaction);
return;
}
misskeyApi('notes/reactions/delete', {
noteId: appearNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
});
}
function toggleReact() {
if ($appearNote.myReaction == null) {
react();
} else {
undoReact();
}
}
function onContextmenu(ev: PointerEvent): void {
if (props.mock) {
return;
}
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
if (prefer.s.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
function showMenu(): void {
if (props.mock) {
return;
}
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
if (props.mock) {
return;
}
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function showRenoteMenu() {
if (props.mock) {
return;
}
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
function getUnrenote(): MenuItem {
return {
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
},
};
}
const renoteDetailsMenu: MenuItem[] = [{
type: 'link',
text: i18n.ts.renoteDetails,
icon: 'ti ti-info-circle',
to: notePage(note),
}];
if (
props.note.channelId != null &&
(inChannel == null || props.note.channelId !== inChannel.value)
) {
renoteDetailsMenu.push({
type: 'link',
text: i18n.ts.viewRenotedChannel,
icon: 'ti ti-device-tv',
to: `/channels/${props.note.channelId}`,
});
}
if (isMyRenote) {
os.popupMenu([
...renoteDetailsMenu,
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value);
} else {
os.popupMenu([
...renoteDetailsMenu,
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
...(($i?.isModerator || $i?.isAdmin) ? [getUnrenote()] : []),
], renoteTime.value);
}
}
function focus() {
rootEl.value?.focus();
}
function blur() {
rootEl.value?.blur();
}
function focusBefore() {
focusPrev(rootEl.value);
}
function focusAfter() {
focusNext(rootEl.value);
}
function readPromo() {
misskeyApi('promo/read', {
noteId: appearNote.id,
});
}
function emitUpdReaction(emoji: string, delta: number) {
if (delta < 0) {
emit('removeReaction', emoji);
} else if (delta > 0) {
emit('reaction', emoji);
}
}
</script>
<style lang="scss" module>

View File

@@ -239,92 +239,45 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, markRaw, provide, ref, useTemplateRef } from 'vue';
import * as mfm from 'mfm-js';
import { inject, provide, ref, useTemplateRef, markRaw, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { useNote } from '@/composables/use-note.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { Paginator } from '@/utility/paginator.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import { DI } from '@/di.js';
import type { Keymap } from '@/utility/hotkey.js';
import type { MenuItem } from '@/types/menu.js';
// コンポーネント外部の依存関係
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import * as sound from '@/utility/sound.js';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { Paginator } from '@/utility/paginator.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
initialTab?: string;
initialTab?: 'replies' | 'renotes' | 'reactions';
}>(), {
initialTab: 'replies',
});
// 周辺コンテキストのインジェクト
const inChannel = inject(DI.inChannel, null);
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
const hideByPlugin = ref(false);
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
if (result == null) {
hideByPlugin.value = true;
} else {
note = result as Misskey.entities.Note;
}
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: note,
});
// Template Refsの定義
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
@@ -332,30 +285,96 @@ const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === note.id || noteId === appearNote.id) {
isDeleted.value = true;
}
// コンポーサブルの呼び出し
const {
note,
appearNote,
$appearNote,
hideByPlugin,
isRenote,
showContent,
isDeleted,
translating,
translation,
muted,
canRenote,
isMyRenote,
parsed,
urls,
showTicker,
// 関数群
renote,
reply,
react,
reactViaMfmEmoji,
toggleReact,
onContextmenu,
showMenu,
clip,
showRenoteMenu,
blur,
} = useNote(props, {
rootEl,
menuButton,
renoteButton,
renoteTime,
reactButton,
clipButton,
}, {
inChannel,
});
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.id}`,
// provide
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
// MkNoteDetailed固有
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,
params: {
noteId: appearNote.id,
},
}));
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
limit: 10,
computedParams: computed(() => ({
noteId: appearNote.id,
type: reactionTabType.value,
})),
}));
const replies = ref<Misskey.entities.Note[]>([]);
const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
misskeyApi('notes/children', {
noteId: appearNote.id,
limit: 30,
}).then(res => {
replies.value = res;
});
}
const conversation = ref<Misskey.entities.Note[]>([]);
const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
if (appearNote.replyId == null) return;
misskeyApi('notes/conversation', {
noteId: appearNote.replyId,
}).then(res => {
conversation.value = res.reverse();
});
}
// キーボードショートカットマップ
const keymap = {
'r': () => reply(),
'e|a|plus': () => react(),
@@ -378,281 +397,6 @@ const keymap = {
callback: () => blur(),
},
} as const satisfies Keymap;
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,
params: {
noteId: appearNote.id,
},
}));
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
limit: 10,
computedParams: computed(() => ({
noteId: appearNote.id,
type: reactionTabType.value,
})),
}));
useTooltip(renoteButton, async (showing) => {
const anchorElement = renoteButton.value;
if (anchorElement == null) return;
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
anchorElement: anchorElement,
}, {
closed: () => dispose(),
});
});
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.id,
limit: 10,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
if (users.length < 1) return;
const { dispose } = os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: $appearNote.reactionCount,
anchorElement: reactButton.value!,
}, {
closed: () => dispose(),
});
});
}
async function renote() {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
const { menu } = getRenoteMenu({ note: note, renoteButton });
os.popupMenu(menu, renoteButton.value);
// リノート後は反応が来る可能性があるので手動で購読する
subscribeManuallyToNoteCapture();
}
async function reply() {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
os.post({
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
}
async function react() {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
});
if (confirm.canceled) return;
}
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
focus();
});
}
}
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
if (!oldReaction) return;
misskeyApi('notes/reactions/delete', {
noteId: targetNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
});
}
function toggleReact() {
if (appearNote.myReaction == null) {
react();
} else {
undoReact(appearNote);
}
}
function onContextmenu(ev: PointerEvent): void {
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
if (prefer.s.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
}
async function showRenoteMenu() {
if (!isMyRenote) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
const menu: MenuItem[] = [];
if (isMyRenote) {
menu.push({
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
},
});
}
if (
props.note.channelId != null &&
(inChannel == null || props.note.channelId !== inChannel.value)
) {
menu.push({
type: 'link',
text: i18n.ts.viewRenotedChannel,
icon: 'ti ti-device-tv',
to: `/channels/${props.note.channelId}`,
});
}
os.popupMenu(menu, renoteTime.value);
}
function focus() {
rootEl.value?.focus();
}
function blur() {
rootEl.value?.blur();
}
const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
misskeyApi('notes/children', {
noteId: appearNote.id,
limit: 30,
}).then(res => {
replies.value = res;
});
}
const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
if (appearNote.replyId == null) return;
misskeyApi('notes/conversation', {
noteId: appearNote.replyId,
}).then(res => {
conversation.value = res.reverse();
});
}
</script>
<style lang="scss" module>

View File

@@ -0,0 +1,461 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import { pleaseLogin } from '@/utility/please-login.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import * as sound from '@/utility/sound.js';
import * as os from '@/os.js';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, getAbuseNoteMenu, getCopyNoteLinkMenu } from '@/utility/get-note-menu.js';
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { notePage } from '@/filters/note.js';
import type { DI as DIType } from '@/di.js';
import type { ExtractInjectedType } from '@/types/misc.js';
import type { MenuItem } from '@/types/menu.js';
export interface UseNoteProps {
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
withHardMute?: boolean;
}
export interface UseNoteElements {
rootEl?: Ref<HTMLElement | null>;
menuButton?: Ref<HTMLElement | null>;
renoteButton?: Ref<HTMLElement | null>;
renoteTime?: Ref<HTMLElement | null>;
reactButton?: Ref<HTMLElement | null>;
clipButton?: Ref<HTMLElement | null>;
}
export interface UseNoteOptions {
inTimeline?: boolean;
tl_withSensitive?: Ref<boolean>;
inChannel?: ExtractInjectedType<typeof DIType['inChannel']>;
currentClip?: Ref<Misskey.entities.Clip | null> | null;
currentAntenna?: Ref<Misskey.entities.Antenna | null> | null;
}
export function calculateMuteStatus<
CheckOnly extends boolean,
CheckForSensitiveMedia extends boolean,
ReturnTypeA = CheckOnly extends true ? boolean : Array<string | string[]> | false | 'sensitiveMute',
ReturnTypeB = CheckForSensitiveMedia extends true ? ReturnTypeA : Exclude<ReturnTypeA, 'sensitiveMute'>,
>(
noteToCheck: Misskey.entities.Note,
user: typeof $i,
mutedWords: Array<string | string[]> | null,
checkForSensitiveMedia: CheckForSensitiveMedia,
checkOnly: CheckOnly = false as CheckOnly,
): ReturnTypeB {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, user, mutedWords);
if (Array.isArray(result)) return checkOnly ? (result.length > 0) as ReturnTypeB : result as ReturnTypeB;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, user, mutedWords);
if (Array.isArray(replyResult)) return checkOnly ? (replyResult.length > 0) as ReturnTypeB : replyResult as ReturnTypeB;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, user, mutedWords);
if (Array.isArray(renoteResult)) return checkOnly ? (renoteResult.length > 0) as ReturnTypeB : renoteResult as ReturnTypeB;
}
if (checkOnly) return false as ReturnTypeB;
if (checkForSensitiveMedia && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute' as ReturnTypeB;
}
return false as ReturnTypeB;
}
/** MkNote, MkNoteDetailedの共通ロジック */
export function useNote(
props: UseNoteProps,
els: UseNoteElements = {},
options: UseNoteOptions = {},
) {
const inTimeline = options.inTimeline ?? false;
const tl_withSensitive = options.tl_withSensitive ?? ref(true);
const inChannel = options.inChannel ?? null;
const currentClip = options.currentClip ?? null;
const currentAntenna = options.currentAntenna ?? null;
// プラグインの割り込み処理
let rawNote = deepClone(props.note);
const hideByPlugin = ref(false);
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(rawNote);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
if (result == null) {
hideByPlugin.value = true;
} else {
rawNote = result as Misskey.entities.Note;
}
}
// 基本状態
const isRenote = Misskey.note.isPureRenote(rawNote);
const appearNote = getAppearNote(rawNote) ?? rawNote;
// キャプチャ(ストリーム購読)
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
parentNote: rawNote,
mock: props.mock,
});
// 各種フラグ状態
const showContent = ref(false);
const isDeleted = ref(false);
const translating = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
// ミュート判定
const muted = ref($i ? calculateMuteStatus(appearNote, $i, $i.mutedWords, inTimeline && !tl_withSensitive.value) : false);
const hardMuted = ref(props.withHardMute && $i ? calculateMuteStatus(appearNote, $i, $i.hardMutedWords, inTimeline && !tl_withSensitive.value, true) : false);
// 計算プロパティ (Computed)
const isMyRenote = computed(() => $i && ($i.id === rawNote.userId));
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = computed(() => shouldCollapsed(appearNote, urls.value ?? []));
const collapsed = ref(appearNote.cw == null && isLong.value);
const showTicker = computed(() => (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance));
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref(prefer.s.collapseRenotes && isRenote && (($i && ($i.id === rawNote.userId || $i.id === appearNote.userId)) || ($appearNote.myReaction != null)));
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.id}`,
}));
// グローバルイベントの監視
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === rawNote.id || noteId === appearNote.id) {
isDeleted.value = true;
}
});
// ツールチップのセットアップ (Mockでない場合のみ)
if (!props.mock) {
if (els.renoteButton != null) {
useTooltip(els.renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1 || els.renoteButton!.value == null) return;
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
anchorElement: els.renoteButton!.value,
}, {
closed: () => dispose(),
});
});
}
if (appearNote.reactionAcceptance === 'likeOnly' && els.reactButton != null) {
useTooltip(els.reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.id,
limit: 10,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
if (users.length < 1 || els.reactButton!.value == null) return;
const { dispose } = os.popup(MkReactionsViewerDetails, {
showing,
reaction: '❤️',
users,
count: $appearNote.reactionCount,
anchorElement: els.reactButton!.value,
}, {
closed: () => dispose(),
});
});
}
}
// 共通アクション関数群
async function renote() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
if (els.renoteButton == null) return;
const { menu } = getRenoteMenu({
note: rawNote,
renoteButton: els.renoteButton,
mock: props.mock,
});
os.popupMenu(menu, els.renoteButton.value);
subscribeManuallyToNoteCapture();
}
async function reply() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
os.post({
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
}
async function react(customCallback?: (reaction: string) => void) {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
if (props.mock) return;
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: '❤️' });
});
if (els.reactButton != null && els.reactButton.value != null && prefer.s.animation) {
const rect = els.reactButton.value.getBoundingClientRect();
const { dispose } = os.popup(MkRippleEffect, {
x: rect.left + (els.reactButton.value.offsetWidth / 2),
y: rect.top + (els.reactButton.value.offsetHeight / 2),
}, {
end: () => dispose(),
});
}
} else {
blur();
reactionPicker.show(els.reactButton?.value ?? null, rawNote, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
});
if (confirm.canceled) return;
}
sound.playMisskeySfx('reaction');
if (props.mock) {
if (customCallback) customCallback(reaction);
return;
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: reaction });
});
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => { focus(); });
}
}
async function reactViaMfmEmoji(reaction: string) {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
}
function undoReact(): void {
const oldReaction = $appearNote.myReaction;
if (!oldReaction) return;
if (props.mock) return;
misskeyApi('notes/reactions/delete', { noteId: appearNote.id }).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, { userId: $i!.id, reaction: oldReaction });
});
}
function toggleReact(customMockCallback?: (reaction: string) => void) {
if ($appearNote.myReaction == null) {
react(customMockCallback);
} else {
if (props.mock && customMockCallback) {
customMockCallback($appearNote.myReaction);
} else {
undoReact();
}
}
}
function onContextmenu(ev: PointerEvent): void {
if (props.mock) return;
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
if (prefer.s.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
const { menu, cleanup } = getNoteMenu({
note: rawNote,
translating,
translation,
currentClip: currentClip?.value,
currentAntenna: currentAntenna?.value ?? undefined,
});
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
function showMenu(): void {
if (props.mock || els.menuButton == null) return;
const { menu, cleanup } = getNoteMenu({
note: rawNote,
translating,
translation,
currentClip: currentClip?.value,
currentAntenna: currentAntenna?.value ?? undefined,
});
os.popupMenu(menu, els.menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
if (props.mock) return;
os.popupMenu(await getNoteClipMenu({
note: rawNote,
currentClip: currentClip?.value,
}), els.clipButton?.value).then(focus);
}
async function showRenoteMenu() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
const getUnrenote = () => ({
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
danger: true,
action: () => {
misskeyApi('notes/delete', { noteId: rawNote.id }).then(() => { globalEvents.emit('noteDeleted', rawNote.id); });
},
});
const menuItems: MenuItem[] = [{
type: 'link',
text: i18n.ts.renoteDetails,
icon: 'ti ti-info-circle',
to: notePage(rawNote),
}];
if (props.note.channelId != null && (inChannel == null || props.note.channelId !== inChannel.value)) {
menuItems.push({
type: 'link',
text: i18n.ts.viewRenotedChannel,
icon: 'ti ti-device-tv',
to: `/channels/${props.note.channelId}`,
});
}
menuItems.push(getCopyNoteLinkMenu(rawNote, i18n.ts.copyLinkRenote));
menuItems.push({ type: 'divider' });
if (isMyRenote.value) {
menuItems.push(getUnrenote());
os.popupMenu(menuItems, els.renoteTime?.value);
} else {
menuItems.push(getAbuseNoteMenu(rawNote, i18n.ts.reportAbuseRenote));
if ($i?.isModerator || $i?.isAdmin) {
menuItems.push(getUnrenote());
}
os.popupMenu(menuItems, els.renoteTime?.value);
}
}
// フォーカス制御
function focus() { els.rootEl?.value?.focus(); }
function blur() { els.rootEl?.value?.blur(); }
return {
// 状態・データ
note: rawNote,
appearNote,
$appearNote,
hideByPlugin,
isRenote,
showContent,
isDeleted,
translating,
translation,
muted,
hardMuted,
collapsed,
renoteCollapsed,
// 計算プロパティ
isMyRenote,
parsed,
urls,
isLong,
showTicker,
canRenote,
// アクション関数
renote,
reply,
react,
reactViaMfmEmoji,
toggleReact,
onContextmenu,
showMenu,
clip,
showRenoteMenu,
focus,
blur,
};
}

View File

@@ -240,6 +240,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</ul>
</div>
</div>
<SearchMarker :keywords="['deny', 'list']">
<MkTextarea v-model="urlPreviewForm.state.urlPreviewSensitiveList" tall>
<template #label><SearchLabel>{{ i18n.ts.urlPreviewSensitiveList }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewSensitiveList" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.urlPreviewSensitiveListDescription }}</template>
</MkTextarea>
</SearchMarker>
</template>
</div>
</MkFolder>
@@ -465,6 +472,7 @@ const urlPreviewForm = useForm({
urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength,
urlPreviewUserAgent: meta.urlPreviewUserAgent ?? '',
urlPreviewSummaryProxyUrl: meta.urlPreviewSummaryProxyUrl ?? '',
urlPreviewSensitiveList: meta.urlPreviewSensitiveList.join('\n'),
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
urlPreviewEnabled: state.urlPreviewEnabled,
@@ -474,6 +482,7 @@ const urlPreviewForm = useForm({
urlPreviewRequireContentLength: state.urlPreviewRequireContentLength,
urlPreviewUserAgent: state.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: state.urlPreviewSummaryProxyUrl,
urlPreviewSensitiveList: state.urlPreviewSensitiveList.split('\n'),
});
fetchInstance(true);
});

View File

@@ -76,6 +76,12 @@ const note = ref<null | Misskey.entities.Note>(CTX_NOTE);
const clips = ref<Misskey.entities.Clip[]>();
const showPrev = ref<'user' | 'channel' | false>(false);
const showNext = ref<'user' | 'channel' | false>(false);
const initialTab = computed<'reactions' | 'replies' | 'renotes' | undefined>(() => {
if (['reactions', 'replies', 'renotes'].includes(props.initialTab ?? '')) {
return props.initialTab as 'reactions' | 'replies' | 'renotes';
}
return undefined;
});
const error = ref();
const prevUserPaginator = markRaw(new Paginator('users/notes', {

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { apiUrl } from '@@/js/config.js';
import type { App } from 'vue';
import type * as Misskey from 'misskey-js';
export async function initTelemetry(instance: Misskey.entities.MetaDetailed, app: App): Promise<void> {
if (!instance.sentryForFrontend) return;
const Sentry = await import('@sentry/vue');
Sentry.init({
app,
integrations: [
...(instance.sentryForFrontend.vueIntegration !== undefined ? [
Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined),
] : []),
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [
Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined),
] : []),
...(instance.sentryForFrontend.replayIntegration !== undefined ? [
Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined),
] : []),
],
// Set tracesSampleRate to 1.0 to capture 100%
tracesSampleRate: 1.0,
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? {
tracePropagationTargets: [apiUrl],
} : {}),
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
...(instance.sentryForFrontend.replayIntegration !== undefined ? {
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
} : {}),
...instance.sentryForFrontend.options,
});
}

View File

@@ -2,5 +2,8 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { InjectionKey } from 'vue';
export type Awaitable <T> = T | Promise<T>;
export type Awaitable<T> = T | Promise<T>;
export type ExtractInjectedType<T extends InjectionKey<any>> = T extends InjectionKey<infer U> ? U : never;

View File

@@ -33,7 +33,7 @@ const isInBrowserTranslationAvailable = (
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
currentClip?: Misskey.entities.Clip;
currentClip?: Misskey.entities.Clip | null;
}) {
function getClipName(clip: Misskey.entities.Clip) {
if ($i && clip.userId === $i.id && clip.notesCount != null) {
@@ -181,8 +181,8 @@ export function getNoteMenu(props: {
note: Misskey.entities.Note;
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
translating: Ref<boolean>;
currentClip?: Misskey.entities.Clip;
currentAntenna?: Misskey.entities.Antenna;
currentClip?: Misskey.entities.Clip | null;
currentAntenna?: Misskey.entities.Antenna | null;
}) {
const appearNote = getAppearNote(props.note) ?? props.note;
const link = appearNote.url ?? appearNote.uri;

View File

@@ -60,6 +60,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
);
let intervalId: number | null = null;
let rafRequestId: number | null = null;
const ss = ref('');
const ms = ref('');
const showColon = ref(false);
@@ -81,18 +82,34 @@ const tick = () => {
prevSec = ss.value;
};
tick();
watch(() => widgetProps.showMs, () => {
if (intervalId) window.clearInterval(intervalId);
intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000);
}, { immediate: true });
onUnmounted(() => {
const clearTimers = () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
}
if (rafRequestId) {
window.cancelAnimationFrame(rafRequestId);
rafRequestId = null;
}
};
tick();
watch(() => widgetProps.showMs, (to) => {
clearTimers();
if (to) {
rafRequestId = window.requestAnimationFrame(function loop() {
tick();
rafRequestId = window.requestAnimationFrame(loop);
});
} else {
intervalId = window.setInterval(tick, 1000);
}
}, { immediate: true });
onUnmounted(() => {
clearTimers();
});
defineExpose<WidgetComponentExpose>({

View File

@@ -5691,6 +5691,14 @@ export interface Locale extends ILocale {
* 先頭に追加
*/
"prepend": string;
/**
* サムネイルの表示を制限するURL
*/
"urlPreviewSensitiveList": string;
/**
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
*/
"urlPreviewSensitiveListDescription": string;
"_imageEditing": {
"_vars": {
/**

View File

@@ -8,6 +8,7 @@
"strictFunctionTypes": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"esnext",
"dom"

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"outDir": "./built",
"removeComments": true,
"skipLibCheck": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.6.1-alpha.1",
"version": "2026.7.0-alpha.1",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View File

@@ -9557,6 +9557,7 @@ export interface operations {
urlPreviewRequireContentLength: boolean;
urlPreviewUserAgent: string | null;
urlPreviewSummaryProxyUrl: string | null;
urlPreviewSensitiveList: string[];
/** @enum {string} */
federation: 'all' | 'specified' | 'none';
federationHosts: string[];
@@ -13076,6 +13077,7 @@ export interface operations {
urlPreviewRequireContentLength?: boolean;
urlPreviewUserAgent?: string | null;
urlPreviewSummaryProxyUrl?: string | null;
urlPreviewSensitiveList?: string[] | null;
/** @enum {string} */
federation?: 'all' | 'none' | 'specified';
federationHosts?: string[];

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"outDir": "./built",
"removeComments": true,
"skipLibCheck": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"outDir": "./built",
"removeComments": true,
"skipLibCheck": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,

View File

@@ -13,6 +13,7 @@
"module": "nodenext",
"moduleResolution": "nodenext",
"removeComments": false,
"skipLibCheck": true,
"noLib": false,
"strict": true,
"strictNullChecks": true,

198
scripts/check-dts.mjs Normal file
View File

@@ -0,0 +1,198 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const defaultRootDir = path.resolve(scriptDir, '..');
// パス判定は OS 差分を避けるため、必要なところだけ POSIX 形式へ寄せる。
function toPosixPath(filePath) {
return filePath.split(path.sep).join('/');
}
function isInside(parentDir, filePath) {
const relative = path.relative(parentDir, filePath);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
}
// skipLibCheck で隠したくない、リポジトリ管理下の手書き .d.ts だけを検査する。
export function isCheckableDeclarationFile(filePath, rootDir = defaultRootDir) {
const absoluteRootDir = path.resolve(rootDir);
const absoluteFilePath = path.resolve(filePath);
if (!isInside(absoluteRootDir, absoluteFilePath)) return false;
if (!absoluteFilePath.endsWith('.d.ts')) return false;
const relativePath = toPosixPath(path.relative(absoluteRootDir, absoluteFilePath));
const segments = relativePath.split('/');
if (segments.includes('node_modules') || segments.includes('.pnpm')) return false;
if (segments.includes('built') || segments.includes('js-built')) return false;
return true;
}
// root package.json の workspaces を信頼して、検査対象 package を列挙する。
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function getWorkspacePackageDirs(rootDir) {
const rootPackageJson = readJson(path.join(rootDir, 'package.json'));
return rootPackageJson.workspaces
.filter((workspace) => !workspace.includes('*'))
.map((workspace) => path.resolve(rootDir, workspace))
.filter((workspaceDir) => fs.existsSync(path.join(workspaceDir, 'package.json')));
}
function getTsconfigPaths(packageDir) {
return ts.sys.readDirectory(
packageDir,
['.json'],
['node_modules'],
['**/tsconfig.json'],
undefined,
);
}
// tsconfig の include から漏れる package 直下の shim も拾えるよう、補助的に探索する。
function discoverDeclarationFiles(packageDir, rootDir) {
return ts.sys.readDirectory(
packageDir,
['.d.ts'],
undefined,
['**/*.d.ts'],
undefined,
).filter((filePath) => isCheckableDeclarationFile(filePath, rootDir));
}
// 各 package の設定をそのまま使い、検査時だけ skipLibCheck を後で上書きする。
function readTsconfig(tsconfigPath) {
if (!fs.existsSync(tsconfigPath)) return undefined;
const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
if (config.error != null) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'));
}
return ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(tsconfigPath));
}
function createFormatHost(rootDir) {
return {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: () => rootDir,
getNewLine: () => ts.sys.newLine,
};
}
// 通常の tsconfig は include 対象を使い、root tsconfig だけ package 直下の shim を足す。
function isRootTsconfig(packageDir, tsconfigPath) {
return path.resolve(packageDir, 'tsconfig.json') === path.resolve(tsconfigPath);
}
function getDeclarationFilesForTsconfig(packageDir, tsconfigPath, parsedConfig, rootDir) {
const declarationFiles = parsedConfig.fileNames
.filter((filePath) => isCheckableDeclarationFile(filePath, rootDir));
if (!isRootTsconfig(packageDir, tsconfigPath)) {
return declarationFiles;
}
const extraRootConfigDeclarationFiles = discoverDeclarationFiles(packageDir, rootDir)
.filter((filePath) => {
return path.dirname(filePath) === packageDir;
});
return [...new Set([...declarationFiles, ...extraRootConfigDeclarationFiles])];
}
// node_modules 側の診断は出さず、対象 .d.ts 自身に出た診断だけを失敗扱いにする。
function checkTsconfigDeclarations(packageDir, tsconfigPath, rootDir) {
const parsedConfig = readTsconfig(tsconfigPath);
if (parsedConfig == null) {
return { declarationFiles: [], diagnostics: [] };
}
const declarationFiles = getDeclarationFilesForTsconfig(packageDir, tsconfigPath, parsedConfig, rootDir);
if (declarationFiles.length === 0) {
return { declarationFiles, diagnostics: [] };
}
const program = ts.createProgram({
rootNames: declarationFiles,
options: {
...parsedConfig.options,
noEmit: true,
skipLibCheck: false,
},
});
const diagnostics = ts.getPreEmitDiagnostics(program)
.filter((diagnostic) => diagnostic.file != null && isCheckableDeclarationFile(diagnostic.file.fileName, rootDir));
return { declarationFiles, diagnostics };
}
// 複数 tsconfig で同じ宣言を読むことがあるため、同一診断をまとめる。
function deduplicateDiagnostics(diagnostics) {
const seen = new Set();
return diagnostics.filter((diagnostic) => {
const key = [
diagnostic.file?.fileName ?? '',
diagnostic.start ?? '',
diagnostic.code,
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
].join('\0');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function checkPackageDeclarations(packageDir, rootDir) {
const results = getTsconfigPaths(packageDir)
.map((tsconfigPath) => checkTsconfigDeclarations(packageDir, tsconfigPath, rootDir));
return {
declarationFiles: [...new Set(results.flatMap((result) => result.declarationFiles))],
diagnostics: deduplicateDiagnostics(results.flatMap((result) => result.diagnostics)),
};
}
// workspace 全体を走査し、CI が扱いやすい件数と診断リストに集約する。
export function checkDeclarations(rootDir = defaultRootDir) {
const workspacePackageDirs = getWorkspacePackageDirs(rootDir);
const results = workspacePackageDirs.map((packageDir) => ({
packageDir,
...checkPackageDeclarations(packageDir, rootDir),
}));
return {
results,
declarationFileCount: results.reduce((count, result) => count + result.declarationFiles.length, 0),
diagnostics: results.flatMap((result) => result.diagnostics),
};
}
// CLI 実行時は TypeScript 標準の formatter で、通常の tsc に近い形で表示する。
function main() {
const { declarationFileCount, diagnostics } = checkDeclarations(defaultRootDir);
if (diagnostics.length === 0) {
console.log(`Checked ${declarationFileCount} repository declaration files.`);
return;
}
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, createFormatHost(defaultRootDir)));
process.exitCode = 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { isCheckableDeclarationFile } from './check-dts.mjs';
const rootDir = '/repo';
test('detects repository-owned declaration files that should be checked', () => {
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/@types/theme.d.ts`, rootDir), true);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/src/utility/virtual.d.ts`, rootDir), true);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/backend/test/global.d.ts`, rootDir), true);
});
test('ignores declarations outside the repository-owned surface', () => {
assert.equal(isCheckableDeclarationFile(`${rootDir}/node_modules/@types/node/index.d.ts`, rootDir), false);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/node_modules/@types/foo/index.d.ts`, rootDir), false);
assert.equal(isCheckableDeclarationFile(`${rootDir}/node_modules/.pnpm/typescript/lib/lib.dom.d.ts`, rootDir), false);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/misskey-js/built/index.d.ts`, rootDir), false);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend-shared/js-built/i18n.d.ts`, rootDir), false);
assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/src/theme.ts`, rootDir), false);
});