mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-11 20:14:42 +02:00
Compare commits
15 Commits
room
...
2026.7.0-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f066c1c2ef | ||
|
|
1d41cb3d29 | ||
|
|
1858d56918 | ||
|
|
97082efd99 | ||
|
|
187610d516 | ||
|
|
00b2cb2076 | ||
|
|
426f6748e5 | ||
|
|
c423bf92a8 | ||
|
|
f59bf35615 | ||
|
|
0f056c4955 | ||
|
|
eb2c7ff6c6 | ||
|
|
9f614517c0 | ||
|
|
5432984af8 | ||
|
|
c29a3d902b | ||
|
|
721b1b06a0 |
23
.github/workflows/lint.yml
vendored
23
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
append: "末尾に追加"
|
||||
prepend: "先頭に追加"
|
||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}]`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
],
|
||||
})
|
||||
|
||||
28
packages/backend/src/core/telemetry/TelemetryService.ts
Normal file
28
packages/backend/src/core/telemetry/TelemetryService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
39
packages/backend/src/core/telemetry/telemetry-registry.ts
Normal file
39
packages/backend/src/core/telemetry/telemetry-registry.ts
Normal 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()));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"noEmit": true,
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
2
packages/frontend-embed/@types/theme.d.ts
vendored
2
packages/frontend-embed/@types/theme.d.ts
vendored
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/theme.js';
|
||||
import { Theme } from '@@/js/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"removeComments": false,
|
||||
"skipLibCheck": true,
|
||||
"noLib": false,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"outDir": "./js-built",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
2
packages/frontend/@types/theme.d.ts
vendored
2
packages/frontend/@types/theme.d.ts
vendored
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/theme.js';
|
||||
import { Theme } from '@@/js/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
461
packages/frontend/src/composables/use-note.ts
Normal file
461
packages/frontend/src/composables/use-note.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', {
|
||||
|
||||
45
packages/frontend/src/telemetry.ts
Normal file
45
packages/frontend/src/telemetry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -5691,6 +5691,14 @@ export interface Locale extends ILocale {
|
||||
* 先頭に追加
|
||||
*/
|
||||
"prepend": string;
|
||||
/**
|
||||
* サムネイルの表示を制限するURL
|
||||
*/
|
||||
"urlPreviewSensitiveList": string;
|
||||
/**
|
||||
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
|
||||
*/
|
||||
"urlPreviewSensitiveListDescription": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"outDir": "./built",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"outDir": "./built",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"outDir": "./built",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -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
198
scripts/check-dts.mjs
Normal 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();
|
||||
}
|
||||
25
scripts/check-dts.test.mjs
Normal file
25
scripts/check-dts.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user