Merge remote-tracking branch 'misskey/develop' into future-2024-03-23
This commit is contained in:
		
						commit
						bc531ac414
					
				
					 70 changed files with 1770 additions and 838 deletions
				
			
		
							
								
								
									
										19
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										19
									
								
								CHANGELOG.md
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,7 +1,13 @@
 | 
			
		|||
## Unreleased
 | 
			
		||||
 | 
			
		||||
### Note
 | 
			
		||||
- コントロールパネル内にあるサマリープロキシの設定個所がセキュリティから全般へ変更となります。
 | 
			
		||||
 | 
			
		||||
### General
 | 
			
		||||
-
 | 
			
		||||
- Enhance: URLプレビューの有効化・無効化を設定できるように #13569
 | 
			
		||||
- Enhance: アンテナでBotによるノートを除外できるように  
 | 
			
		||||
  (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545)
 | 
			
		||||
- Fix: Play作成時に設定した公開範囲が機能していない問題を修正
 | 
			
		||||
 | 
			
		||||
### Client
 | 
			
		||||
- Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように
 | 
			
		||||
| 
						 | 
				
			
			@ -10,12 +16,23 @@
 | 
			
		|||
- Enhance: リアクション受け入れが「いいねのみ」の場合はリアクション絵文字一覧を表示しないように
 | 
			
		||||
- Enhance: 設定>プラグインのページからプラグインの簡易的なログやエラーを見られるように
 | 
			
		||||
  - 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました
 | 
			
		||||
- Enhance: ページのデザインを変更
 | 
			
		||||
- Enhance: 2要素認証(ワンタイムパスワード)の入力欄を改善
 | 
			
		||||
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
 | 
			
		||||
- Fix: 周年の実績が閏年を考慮しない問題を修正
 | 
			
		||||
- Fix: ローカルURLのプレビューポップアップが左上に表示される
 | 
			
		||||
- Fix: WebGL2をサポートしないブラウザで「季節に応じた画面の演出」が有効になっているとき、Misskeyが起動できなくなる問題を修正  
 | 
			
		||||
  (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/459)
 | 
			
		||||
- Fix: ページタイトルでローカルユーザーとリモートユーザーの区別がつかない問題を修正  
 | 
			
		||||
  (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/528)
 | 
			
		||||
- Fix: コードブロックのシンタックスハイライトで使用される定義ファイルをCDNから取得するように #13177
 | 
			
		||||
  - CDNから取得せずMisskey本体にバンドルする場合は`pacakges/frontend/vite.config.ts`を修正してください。
 | 
			
		||||
 | 
			
		||||
### Server
 | 
			
		||||
- Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに
 | 
			
		||||
- Enhance: misskey-dev/summaly@5.1.0の取り込み(プレビュー生成処理の効率化)
 | 
			
		||||
- Fix: フォローリクエストを作成する際に既存のものは削除するように  
 | 
			
		||||
  (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/440)
 | 
			
		||||
 | 
			
		||||
## 2024.3.1
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,7 +30,7 @@ Cypress.Commands.add('visitHome', () => {
 | 
			
		|||
})
 | 
			
		||||
 | 
			
		||||
Cypress.Commands.add('resetState', () => {
 | 
			
		||||
	cy.window(win => {
 | 
			
		||||
	cy.window().then(win => {
 | 
			
		||||
		win.indexedDB.deleteDatabase('keyval-store');
 | 
			
		||||
	});
 | 
			
		||||
	cy.request('POST', '/api/reset-db', {}).as('reset');
 | 
			
		||||
							
								
								
									
										19
									
								
								cypress/support/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								cypress/support/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
declare global {
 | 
			
		||||
	namespace Cypress {
 | 
			
		||||
		interface Chainable {
 | 
			
		||||
			login(username: string, password: string): Chainable<void>;
 | 
			
		||||
 | 
			
		||||
			registerUser(
 | 
			
		||||
				username: string,
 | 
			
		||||
				password: string,
 | 
			
		||||
				isAdmin?: boolean
 | 
			
		||||
			): Chainable<void>;
 | 
			
		||||
 | 
			
		||||
			resetState(): Chainable<void>;
 | 
			
		||||
 | 
			
		||||
			visitHome(): Chainable<void>;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {}
 | 
			
		||||
							
								
								
									
										8
									
								
								cypress/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								cypress/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
{
 | 
			
		||||
	"compilerOptions": {
 | 
			
		||||
		"lib": ["dom", "es5"],
 | 
			
		||||
		"target": "es5",
 | 
			
		||||
		"types": ["cypress", "node"]
 | 
			
		||||
	},
 | 
			
		||||
	"include": ["./**/*.ts"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										74
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										74
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1660,6 +1660,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
     * 除外キーワード
 | 
			
		||||
     */
 | 
			
		||||
    "antennaExcludeKeywords": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * Botアカウントを除外
 | 
			
		||||
     */
 | 
			
		||||
    "antennaExcludeBots": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * スペースで区切るとAND指定になり、改行で区切るとOR指定になります
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			@ -5109,6 +5113,18 @@ export interface Locale extends ILocale {
 | 
			
		|||
     * リトライ
 | 
			
		||||
     */
 | 
			
		||||
    "gameRetry": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 使用しない場合は空欄にしてください
 | 
			
		||||
     */
 | 
			
		||||
    "notUsePleaseLeaveBlank": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * ワンタイムパスワードを使う
 | 
			
		||||
     */
 | 
			
		||||
    "useTotp": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * バックアップコードを使う
 | 
			
		||||
     */
 | 
			
		||||
    "useBackupCode": string;
 | 
			
		||||
    "_bubbleGame": {
 | 
			
		||||
        /**
 | 
			
		||||
         * 遊び方
 | 
			
		||||
| 
						 | 
				
			
			@ -8888,6 +8904,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
         * 説明
 | 
			
		||||
         */
 | 
			
		||||
        "summary": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 非公開に設定するとプロフィールに表示されなくなりますが、URLを知っている人は引き続きアクセスできます。
 | 
			
		||||
         */
 | 
			
		||||
        "visibilityDescription": string;
 | 
			
		||||
    };
 | 
			
		||||
    "_pages": {
 | 
			
		||||
        /**
 | 
			
		||||
| 
						 | 
				
			
			@ -10059,6 +10079,60 @@ export interface Locale extends ILocale {
 | 
			
		|||
         */
 | 
			
		||||
        "header": string;
 | 
			
		||||
    };
 | 
			
		||||
    "_urlPreviewSetting": {
 | 
			
		||||
        /**
 | 
			
		||||
         * URLプレビューの設定
 | 
			
		||||
         */
 | 
			
		||||
        "title": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * URLプレビューを有効にする
 | 
			
		||||
         */
 | 
			
		||||
        "enable": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * プレビュー取得時のタイムアウト(ms)
 | 
			
		||||
         */
 | 
			
		||||
        "timeout": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * プレビュー取得の所要時間がこの値を超えた場合、プレビューは生成されません。
 | 
			
		||||
         */
 | 
			
		||||
        "timeoutDescription": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * Content-Lengthの最大値(byte)
 | 
			
		||||
         */
 | 
			
		||||
        "maximumContentLength": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * Content-Lengthがこの値を超えた場合、プレビューは生成されません。
 | 
			
		||||
         */
 | 
			
		||||
        "maximumContentLengthDescription": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * Content-Lengthが取得できた場合のみプレビューを生成
 | 
			
		||||
         */
 | 
			
		||||
        "requireContentLength": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 相手サーバがContent-Lengthを返さない場合、プレビューは生成されません。
 | 
			
		||||
         */
 | 
			
		||||
        "requireContentLengthDescription": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * User-Agent
 | 
			
		||||
         */
 | 
			
		||||
        "userAgent": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * プレビュー取得時に使用されるUser-Agentを設定します。空欄の場合、デフォルトのUser-Agentが使用されます。
 | 
			
		||||
         */
 | 
			
		||||
        "userAgentDescription": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * プレビューを生成するプロキシのエンドポイント
 | 
			
		||||
         */
 | 
			
		||||
        "summaryProxy": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。
 | 
			
		||||
         */
 | 
			
		||||
        "summaryProxyDescription": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。
 | 
			
		||||
         */
 | 
			
		||||
        "summaryProxyDescription2": string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
declare const locales: {
 | 
			
		||||
    [lang: string]: Locale;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -411,6 +411,7 @@ name: "名前"
 | 
			
		|||
antennaSource: "受信ソース"
 | 
			
		||||
antennaKeywords: "受信キーワード"
 | 
			
		||||
antennaExcludeKeywords: "除外キーワード"
 | 
			
		||||
antennaExcludeBots: "Botアカウントを除外"
 | 
			
		||||
antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
 | 
			
		||||
notifyAntenna: "新しいノートを通知する"
 | 
			
		||||
withFileAntenna: "ファイルが添付されたノートのみ"
 | 
			
		||||
| 
						 | 
				
			
			@ -1273,6 +1274,9 @@ enableHorizontalSwipe: "スワイプしてタブを切り替える"
 | 
			
		|||
loading: "読み込み中"
 | 
			
		||||
surrender: "やめる"
 | 
			
		||||
gameRetry: "リトライ"
 | 
			
		||||
notUsePleaseLeaveBlank: "使用しない場合は空欄にしてください"
 | 
			
		||||
useTotp: "ワンタイムパスワードを使う"
 | 
			
		||||
useBackupCode: "バックアップコードを使う"
 | 
			
		||||
 | 
			
		||||
_bubbleGame:
 | 
			
		||||
  howToPlay: "遊び方"
 | 
			
		||||
| 
						 | 
				
			
			@ -2343,6 +2347,7 @@ _play:
 | 
			
		|||
  title: "タイトル"
 | 
			
		||||
  script: "スクリプト"
 | 
			
		||||
  summary: "説明"
 | 
			
		||||
  visibilityDescription: "非公開に設定するとプロフィールに表示されなくなりますが、URLを知っている人は引き続きアクセスできます。"
 | 
			
		||||
 | 
			
		||||
_pages:
 | 
			
		||||
  newPage: "ページの作成"
 | 
			
		||||
| 
						 | 
				
			
			@ -2677,3 +2682,18 @@ _reversi:
 | 
			
		|||
_offlineScreen:
 | 
			
		||||
  title: "オフライン - サーバーに接続できません"
 | 
			
		||||
  header: "サーバーに接続できません"
 | 
			
		||||
 | 
			
		||||
_urlPreviewSetting:
 | 
			
		||||
  title: "URLプレビューの設定"
 | 
			
		||||
  enable: "URLプレビューを有効にする"
 | 
			
		||||
  timeout: "プレビュー取得時のタイムアウト(ms)"
 | 
			
		||||
  timeoutDescription: "プレビュー取得の所要時間がこの値を超えた場合、プレビューは生成されません。"
 | 
			
		||||
  maximumContentLength: "Content-Lengthの最大値(byte)"
 | 
			
		||||
  maximumContentLengthDescription: "Content-Lengthがこの値を超えた場合、プレビューは生成されません。"
 | 
			
		||||
  requireContentLength: "Content-Lengthが取得できた場合のみプレビューを生成"
 | 
			
		||||
  requireContentLengthDescription: "相手サーバがContent-Lengthを返さない場合、プレビューは生成されません。"
 | 
			
		||||
  userAgent: "User-Agent"
 | 
			
		||||
  userAgentDescription: "プレビュー取得時に使用されるUser-Agentを設定します。空欄の場合、デフォルトのUser-Agentが使用されます。"
 | 
			
		||||
  summaryProxy: "プレビューを生成するプロキシのエンドポイント"
 | 
			
		||||
  summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
 | 
			
		||||
  summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -59,6 +59,7 @@
 | 
			
		|||
		"typescript": "5.3.3"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/node": "^20.11.28",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "7.1.0",
 | 
			
		||||
		"@typescript-eslint/parser": "7.1.0",
 | 
			
		||||
		"cross-env": "7.0.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										42
									
								
								packages/backend/migration/1710512074000-url-preview-meta.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								packages/backend/migration/1710512074000-url-preview-meta.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export class UrlPreviewMeta1710512074000 {
 | 
			
		||||
    name = 'UrlPreviewMeta1710512074000'
 | 
			
		||||
 | 
			
		||||
    async up(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`
 | 
			
		||||
					alter table meta
 | 
			
		||||
						rename column "summalyProxy" to "urlPreviewSummaryProxyUrl";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						add "urlPreviewEnabled" boolean default true not null;
 | 
			
		||||
					alter table meta
 | 
			
		||||
						add "urlPreviewTimeout" integer default 10000 not null;
 | 
			
		||||
					alter table meta
 | 
			
		||||
						add "urlPreviewMaximumContentLength" bigint default 10485760 not null;
 | 
			
		||||
					alter table meta
 | 
			
		||||
						add "urlPreviewRequireContentLength" boolean default false not null;
 | 
			
		||||
					alter table meta
 | 
			
		||||
						add "urlPreviewUserAgent" varchar(1024) default null;
 | 
			
		||||
				`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async down(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`
 | 
			
		||||
					alter table meta
 | 
			
		||||
						rename column "urlPreviewSummaryProxyUrl" to "summalyProxy";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						drop column "urlPreviewEnabled";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						drop column "urlPreviewTimeout";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						drop column "urlPreviewMaximumContentLength";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						drop column "urlPreviewRequireContentLength";
 | 
			
		||||
					alter table meta
 | 
			
		||||
						drop column "urlPreviewUserAgent";
 | 
			
		||||
				`);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export class AntennaExcludeBots1710919614510 {
 | 
			
		||||
    name = 'AntennaExcludeBots1710919614510'
 | 
			
		||||
 | 
			
		||||
    async up(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "antenna" ADD "excludeBots" boolean NOT NULL DEFAULT false`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async down(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "excludeBots"`);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -78,7 +78,7 @@
 | 
			
		|||
		"@fastify/static": "6.12.0",
 | 
			
		||||
		"@fastify/view": "8.2.0",
 | 
			
		||||
		"@misskey-dev/sharp-read-bmp": "1.2.0",
 | 
			
		||||
		"@misskey-dev/summaly": "5.0.3",
 | 
			
		||||
		"@misskey-dev/summaly": "5.1.0",
 | 
			
		||||
		"@nestjs/common": "10.3.3",
 | 
			
		||||
		"@nestjs/core": "10.3.3",
 | 
			
		||||
		"@nestjs/testing": "10.3.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ export class AntennaService implements OnApplicationShutdown {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> {
 | 
			
		||||
	public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
 | 
			
		||||
		const antennas = await this.getAntennas();
 | 
			
		||||
		const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
 | 
			
		||||
		const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
 | 
			
		||||
| 
						 | 
				
			
			@ -110,10 +110,12 @@ export class AntennaService implements OnApplicationShutdown {
 | 
			
		|||
	// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> {
 | 
			
		||||
	public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
 | 
			
		||||
		if (note.visibility === 'specified') return false;
 | 
			
		||||
		if (note.visibility === 'followers') return false;
 | 
			
		||||
 | 
			
		||||
		if (antenna.excludeBots && noteUser.isBot) return false;
 | 
			
		||||
 | 
			
		||||
		if (antenna.localOnly && noteUser.host != null) return false;
 | 
			
		||||
 | 
			
		||||
		if (!antenna.withReplies && note.replyId != null) return false;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -459,13 +459,15 @@ export default abstract class Chart<T extends Schema> {
 | 
			
		|||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// bake unique count
 | 
			
		||||
			// bake cardinality
 | 
			
		||||
			for (const [k, v] of Object.entries(finalDiffs)) {
 | 
			
		||||
				if (this.schema[k].uniqueIncrement) {
 | 
			
		||||
					const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>;
 | 
			
		||||
					const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
 | 
			
		||||
					queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size;
 | 
			
		||||
					queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size;
 | 
			
		||||
					const cardinalityOfHour = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size;
 | 
			
		||||
					const cardinalityOfDay = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size;
 | 
			
		||||
					queryForHour[name] = cardinalityOfHour;
 | 
			
		||||
					queryForDay[name] = cardinalityOfDay;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -637,7 +639,7 @@ export default abstract class Chart<T extends Schema> {
 | 
			
		|||
		// 要求された範囲にログがひとつもなかったら
 | 
			
		||||
		if (logs.length === 0) {
 | 
			
		||||
			// もっとも新しいログを持ってくる
 | 
			
		||||
			// (すくなくともひとつログが無いと隙間埋めできないため)
 | 
			
		||||
			// (すくなくともひとつログが無いと補間できないため)
 | 
			
		||||
			const recentLog = await repository.findOne({
 | 
			
		||||
				where: group ? {
 | 
			
		||||
					group: group,
 | 
			
		||||
| 
						 | 
				
			
			@ -654,7 +656,7 @@ export default abstract class Chart<T extends Schema> {
 | 
			
		|||
		// 要求された範囲の最も古い箇所に位置するログが存在しなかったら
 | 
			
		||||
		} else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) {
 | 
			
		||||
			// 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する
 | 
			
		||||
			// (隙間埋めできないため)
 | 
			
		||||
			// (補間できないため)
 | 
			
		||||
			const outdatedLog = await repository.findOne({
 | 
			
		||||
				where: {
 | 
			
		||||
					date: LessThan(Chart.dateToTimestamp(gt)),
 | 
			
		||||
| 
						 | 
				
			
			@ -683,7 +685,7 @@ export default abstract class Chart<T extends Schema> {
 | 
			
		|||
			if (log) {
 | 
			
		||||
				chart.unshift(this.convertRawRecord(log));
 | 
			
		||||
			} else {
 | 
			
		||||
				// 隙間埋め
 | 
			
		||||
				// 補間
 | 
			
		||||
				const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current));
 | 
			
		||||
				const data = latest ? this.convertRawRecord(latest) : null;
 | 
			
		||||
				chart.unshift(this.getNewLog(data));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,6 +39,7 @@ export class AntennaEntityService {
 | 
			
		|||
			caseSensitive: antenna.caseSensitive,
 | 
			
		||||
			localOnly: antenna.localOnly,
 | 
			
		||||
			notify: antenna.notify,
 | 
			
		||||
			excludeBots: antenna.excludeBots,
 | 
			
		||||
			withReplies: antenna.withReplies,
 | 
			
		||||
			withFile: antenna.withFile,
 | 
			
		||||
			isActive: antenna.isActive,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -114,6 +114,7 @@ export class MetaEntityService {
 | 
			
		|||
			policies: { ...DEFAULT_POLICIES, ...instance.policies },
 | 
			
		||||
 | 
			
		||||
			mediaProxy: this.config.mediaProxy,
 | 
			
		||||
			enableUrlPreview: instance.urlPreviewEnabled,
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		return packed;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,6 +72,11 @@ export class MiAntenna {
 | 
			
		|||
	})
 | 
			
		||||
	public caseSensitive: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
	public excludeBots: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -287,12 +287,6 @@ export class MiMeta {
 | 
			
		|||
	})
 | 
			
		||||
	public enableBotTrending: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('varchar', {
 | 
			
		||||
		length: 1024,
 | 
			
		||||
		nullable: true,
 | 
			
		||||
	})
 | 
			
		||||
	public summalyProxy: string | null;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
| 
						 | 
				
			
			@ -631,4 +625,36 @@ export class MiMeta {
 | 
			
		|||
		length: 256, array: true, default: '{}',
 | 
			
		||||
	})
 | 
			
		||||
	public bubbleInstances: string[];
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: true,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('integer', {
 | 
			
		||||
		default: 10000,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewTimeout: number;
 | 
			
		||||
 | 
			
		||||
	@Column('bigint', {
 | 
			
		||||
		default: 1024 * 1024 * 10,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewMaximumContentLength: number;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: true,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewRequireContentLength: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('varchar', {
 | 
			
		||||
		length: 1024,
 | 
			
		||||
		nullable: true,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewSummaryProxyUrl: string | null;
 | 
			
		||||
 | 
			
		||||
	@Column('varchar', {
 | 
			
		||||
		length: 1024,
 | 
			
		||||
		nullable: true,
 | 
			
		||||
	})
 | 
			
		||||
	public urlPreviewUserAgent: string | null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,6 +76,11 @@ export const packedAntennaSchema = {
 | 
			
		|||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		excludeBots: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		withReplies: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -223,6 +223,10 @@ export const packedMetaLiteSchema = {
 | 
			
		|||
			type: 'string',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		enableUrlPreview: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		backgroundImageUrl: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			optional: false, nullable: true,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -81,6 +81,7 @@ export class ExportAntennasProcessorService {
 | 
			
		|||
					}) : null,
 | 
			
		||||
					caseSensitive: antenna.caseSensitive,
 | 
			
		||||
					localOnly: antenna.localOnly,
 | 
			
		||||
					excludeBots: antenna.excludeBots,
 | 
			
		||||
					withReplies: antenna.withReplies,
 | 
			
		||||
					withFile: antenna.withFile,
 | 
			
		||||
					notify: antenna.notify,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,6 +44,7 @@ const validate = new Ajv().compile({
 | 
			
		|||
		} },
 | 
			
		||||
		caseSensitive: { type: 'boolean' },
 | 
			
		||||
		localOnly: { type: 'boolean' },
 | 
			
		||||
		excludeBots: { type: 'boolean' },
 | 
			
		||||
		withReplies: { type: 'boolean' },
 | 
			
		||||
		withFile: { type: 'boolean' },
 | 
			
		||||
		notify: { type: 'boolean' },
 | 
			
		||||
| 
						 | 
				
			
			@ -88,6 +89,7 @@ export class ImportAntennasProcessorService {
 | 
			
		|||
					users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean),
 | 
			
		||||
					caseSensitive: antenna.caseSensitive,
 | 
			
		||||
					localOnly: antenna.localOnly,
 | 
			
		||||
					excludeBots: antenna.excludeBots,
 | 
			
		||||
					withReplies: antenna.withReplies,
 | 
			
		||||
					withFile: antenna.withFile,
 | 
			
		||||
					notify: antenna.notify,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -465,6 +465,8 @@ export const meta = {
 | 
			
		|||
			summalyProxy: {
 | 
			
		||||
				type: 'string',
 | 
			
		||||
				optional: false, nullable: true,
 | 
			
		||||
				deprecated: true,
 | 
			
		||||
				description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
 | 
			
		||||
			},
 | 
			
		||||
			themeColor: {
 | 
			
		||||
				type: 'string',
 | 
			
		||||
| 
						 | 
				
			
			@ -482,6 +484,30 @@ export const meta = {
 | 
			
		|||
				type: 'string',
 | 
			
		||||
				optional: false, nullable: false,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewEnabled: {
 | 
			
		||||
				type: 'boolean',
 | 
			
		||||
				optional: false, nullable: false,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewTimeout: {
 | 
			
		||||
				type: 'number',
 | 
			
		||||
				optional: false, nullable: false,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewMaximumContentLength: {
 | 
			
		||||
				type: 'number',
 | 
			
		||||
				optional: false, nullable: false,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewRequireContentLength: {
 | 
			
		||||
				type: 'boolean',
 | 
			
		||||
				optional: false, nullable: false,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewUserAgent: {
 | 
			
		||||
				type: 'string',
 | 
			
		||||
				optional: false, nullable: true,
 | 
			
		||||
			},
 | 
			
		||||
			urlPreviewSummaryProxyUrl: {
 | 
			
		||||
				type: 'string',
 | 
			
		||||
				optional: false, nullable: true,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -569,7 +595,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
 | 
			
		||||
				enableBotTrending: instance.enableBotTrending,
 | 
			
		||||
				proxyAccountId: instance.proxyAccountId,
 | 
			
		||||
				summalyProxy: instance.summalyProxy,
 | 
			
		||||
				email: instance.email,
 | 
			
		||||
				smtpSecure: instance.smtpSecure,
 | 
			
		||||
				smtpHost: instance.smtpHost,
 | 
			
		||||
| 
						 | 
				
			
			@ -616,6 +641,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
 | 
			
		||||
				perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
 | 
			
		||||
				notesPerOneAd: instance.notesPerOneAd,
 | 
			
		||||
				summalyProxy: instance.urlPreviewSummaryProxyUrl,
 | 
			
		||||
				urlPreviewEnabled: instance.urlPreviewEnabled,
 | 
			
		||||
				urlPreviewTimeout: instance.urlPreviewTimeout,
 | 
			
		||||
				urlPreviewMaximumContentLength: instance.urlPreviewMaximumContentLength,
 | 
			
		||||
				urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
 | 
			
		||||
				urlPreviewUserAgent: instance.urlPreviewUserAgent,
 | 
			
		||||
				urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -93,7 +93,6 @@ export const paramDef = {
 | 
			
		|||
				type: 'string',
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		summalyProxy: { type: 'string', nullable: true },
 | 
			
		||||
		deeplAuthKey: { type: 'string', nullable: true },
 | 
			
		||||
		deeplIsPro: { type: 'boolean' },
 | 
			
		||||
		deeplFreeMode: { type: 'boolean' },
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +157,16 @@ export const paramDef = {
 | 
			
		|||
				type: 'string',
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		summalyProxy: {
 | 
			
		||||
			type: 'string', nullable: true,
 | 
			
		||||
			description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
 | 
			
		||||
		},
 | 
			
		||||
		urlPreviewEnabled: { type: 'boolean' },
 | 
			
		||||
		urlPreviewTimeout: { type: 'integer' },
 | 
			
		||||
		urlPreviewMaximumContentLength: { type: 'integer' },
 | 
			
		||||
		urlPreviewRequireContentLength: { type: 'boolean' },
 | 
			
		||||
		urlPreviewUserAgent: { type: 'string', nullable: true },
 | 
			
		||||
		urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
 | 
			
		||||
	},
 | 
			
		||||
	required: [],
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -357,10 +366,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				set.langs = ps.langs.filter(Boolean);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.summalyProxy !== undefined) {
 | 
			
		||||
				set.summalyProxy = ps.summalyProxy;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.enableEmail !== undefined) {
 | 
			
		||||
				set.enableEmail = ps.enableEmail;
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -609,6 +614,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				set.bannedEmailDomains = ps.bannedEmailDomains;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.urlPreviewEnabled !== undefined) {
 | 
			
		||||
				set.urlPreviewEnabled = ps.urlPreviewEnabled;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.urlPreviewTimeout !== undefined) {
 | 
			
		||||
				set.urlPreviewTimeout = ps.urlPreviewTimeout;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.urlPreviewMaximumContentLength !== undefined) {
 | 
			
		||||
				set.urlPreviewMaximumContentLength = ps.urlPreviewMaximumContentLength;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.urlPreviewRequireContentLength !== undefined) {
 | 
			
		||||
				set.urlPreviewRequireContentLength = ps.urlPreviewRequireContentLength;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.urlPreviewUserAgent !== undefined) {
 | 
			
		||||
				const value = (ps.urlPreviewUserAgent ?? '').trim();
 | 
			
		||||
				set.urlPreviewUserAgent = value === '' ? null : ps.urlPreviewUserAgent;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.summalyProxy !== undefined || ps.urlPreviewSummaryProxyUrl !== undefined) {
 | 
			
		||||
				const value = ((ps.urlPreviewSummaryProxyUrl ?? ps.summalyProxy) ?? '').trim();
 | 
			
		||||
				set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const before = await this.metaService.fetch(true);
 | 
			
		||||
 | 
			
		||||
			await this.metaService.update(set);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,6 +64,7 @@ export const paramDef = {
 | 
			
		|||
		} },
 | 
			
		||||
		caseSensitive: { type: 'boolean' },
 | 
			
		||||
		localOnly: { type: 'boolean' },
 | 
			
		||||
		excludeBots: { type: 'boolean' },
 | 
			
		||||
		withReplies: { type: 'boolean' },
 | 
			
		||||
		withFile: { type: 'boolean' },
 | 
			
		||||
		notify: { type: 'boolean' },
 | 
			
		||||
| 
						 | 
				
			
			@ -124,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				users: ps.users,
 | 
			
		||||
				caseSensitive: ps.caseSensitive,
 | 
			
		||||
				localOnly: ps.localOnly,
 | 
			
		||||
				excludeBots: ps.excludeBots,
 | 
			
		||||
				withReplies: ps.withReplies,
 | 
			
		||||
				withFile: ps.withFile,
 | 
			
		||||
				notify: ps.notify,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -63,6 +63,7 @@ export const paramDef = {
 | 
			
		|||
		} },
 | 
			
		||||
		caseSensitive: { type: 'boolean' },
 | 
			
		||||
		localOnly: { type: 'boolean' },
 | 
			
		||||
		excludeBots: { type: 'boolean' },
 | 
			
		||||
		withReplies: { type: 'boolean' },
 | 
			
		||||
		withFile: { type: 'boolean' },
 | 
			
		||||
		notify: { type: 'boolean' },
 | 
			
		||||
| 
						 | 
				
			
			@ -120,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				users: ps.users,
 | 
			
		||||
				caseSensitive: ps.caseSensitive,
 | 
			
		||||
				localOnly: ps.localOnly,
 | 
			
		||||
				excludeBots: ps.excludeBots,
 | 
			
		||||
				withReplies: ps.withReplies,
 | 
			
		||||
				withFile: ps.withFile,
 | 
			
		||||
				notify: ps.notify,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,6 +44,7 @@ export const paramDef = {
 | 
			
		|||
		permissions: { type: 'array', items: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
		} },
 | 
			
		||||
		visibility: { type: 'string', enum: ['public', 'private'], default: 'public' },
 | 
			
		||||
	},
 | 
			
		||||
	required: ['title', 'summary', 'script', 'permissions'],
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				summary: ps.summary,
 | 
			
		||||
				script: ps.script,
 | 
			
		||||
				permissions: ps.permissions,
 | 
			
		||||
				visibility: ps.visibility,
 | 
			
		||||
			}).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0]));
 | 
			
		||||
 | 
			
		||||
			return await this.flashEntityService.pack(flash);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -93,7 +93,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
 | 
			
		|||
		const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
 | 
			
		||||
 | 
			
		||||
		const info = {
 | 
			
		||||
			operationId: endpoint.name,
 | 
			
		||||
			operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない
 | 
			
		||||
			summary: endpoint.name,
 | 
			
		||||
			description: desc,
 | 
			
		||||
			externalDocs: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
 | 
			
		||||
import { Inject, Injectable } from '@nestjs/common';
 | 
			
		||||
import { summaly } from '@misskey-dev/summaly';
 | 
			
		||||
import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
 | 
			
		||||
import { DI } from '@/di-symbols.js';
 | 
			
		||||
import type { Config } from '@/config.js';
 | 
			
		||||
import { MetaService } from '@/core/MetaService.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +15,7 @@ import { query } from '@/misc/prelude/url.js';
 | 
			
		|||
import { LoggerService } from '@/core/LoggerService.js';
 | 
			
		||||
import { bindThis } from '@/decorators.js';
 | 
			
		||||
import { ApiError } from '@/server/api/error.js';
 | 
			
		||||
import { MiMeta } from '@/models/Meta.js';
 | 
			
		||||
import type { FastifyRequest, FastifyReply } from 'fastify';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
| 
						 | 
				
			
			@ -62,24 +64,25 @@ export class UrlPreviewService {
 | 
			
		|||
 | 
			
		||||
		const meta = await this.metaService.fetch();
 | 
			
		||||
 | 
			
		||||
		this.logger.info(meta.summalyProxy
 | 
			
		||||
		if (!meta.urlPreviewEnabled) {
 | 
			
		||||
			reply.code(403);
 | 
			
		||||
			return {
 | 
			
		||||
				error: new ApiError({
 | 
			
		||||
					message: 'URL preview is disabled',
 | 
			
		||||
					code: 'URL_PREVIEW_DISABLED',
 | 
			
		||||
					id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
 | 
			
		||||
				}),
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.logger.info(meta.urlPreviewSummaryProxyUrl
 | 
			
		||||
			? `(Proxy) Getting preview of ${url}@${lang} ...`
 | 
			
		||||
			: `Getting preview of ${url}@${lang} ...`);
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const summary = meta.summalyProxy ?
 | 
			
		||||
				await this.httpRequestService.getJson<ReturnType<typeof summaly>>(`${meta.summalyProxy}?${query({
 | 
			
		||||
					url: url,
 | 
			
		||||
					lang: lang ?? 'ja-JP',
 | 
			
		||||
				})}`)
 | 
			
		||||
				:
 | 
			
		||||
				await summaly(url, {
 | 
			
		||||
					followRedirects: false,
 | 
			
		||||
					lang: lang ?? 'ja-JP',
 | 
			
		||||
					agent: this.config.proxy ? {
 | 
			
		||||
						http: this.httpRequestService.httpAgent,
 | 
			
		||||
						https: this.httpRequestService.httpsAgent,
 | 
			
		||||
					} : undefined,
 | 
			
		||||
				});
 | 
			
		||||
			const summary = meta.urlPreviewSummaryProxyUrl
 | 
			
		||||
				? await this.fetchSummaryFromProxy(url, meta, lang)
 | 
			
		||||
				: await this.fetchSummary(url, meta, lang);
 | 
			
		||||
 | 
			
		||||
			this.logger.succ(`Got preview of ${url}: ${summary.title}`);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -100,6 +103,7 @@ export class UrlPreviewService {
 | 
			
		|||
			return summary;
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			this.logger.warn(`Failed to get preview of ${url}: ${err}`);
 | 
			
		||||
 | 
			
		||||
			reply.code(422);
 | 
			
		||||
			reply.header('Cache-Control', 'max-age=86400, immutable');
 | 
			
		||||
			return {
 | 
			
		||||
| 
						 | 
				
			
			@ -111,4 +115,37 @@ export class UrlPreviewService {
 | 
			
		|||
			};
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
 | 
			
		||||
		const agent = this.config.proxy
 | 
			
		||||
			? {
 | 
			
		||||
				http: this.httpRequestService.httpAgent,
 | 
			
		||||
				https: this.httpRequestService.httpsAgent,
 | 
			
		||||
			}
 | 
			
		||||
			: undefined;
 | 
			
		||||
 | 
			
		||||
		return summaly(url, {
 | 
			
		||||
			followRedirects: false,
 | 
			
		||||
			lang: lang ?? 'ja-JP',
 | 
			
		||||
			agent: agent,
 | 
			
		||||
			userAgent: meta.urlPreviewUserAgent ?? undefined,
 | 
			
		||||
			operationTimeout: meta.urlPreviewTimeout,
 | 
			
		||||
			contentLengthLimit: meta.urlPreviewMaximumContentLength,
 | 
			
		||||
			contentLengthRequired: meta.urlPreviewRequireContentLength,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
 | 
			
		||||
		const proxy = meta.urlPreviewSummaryProxyUrl!;
 | 
			
		||||
		const queryStr = query({
 | 
			
		||||
			url: url,
 | 
			
		||||
			lang: lang ?? 'ja-JP',
 | 
			
		||||
			userAgent: meta.urlPreviewUserAgent ?? undefined,
 | 
			
		||||
			operationTimeout: meta.urlPreviewTimeout,
 | 
			
		||||
			contentLengthLimit: meta.urlPreviewMaximumContentLength,
 | 
			
		||||
			contentLengthRequired: meta.urlPreviewRequireContentLength,
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ extends ./base
 | 
			
		|||
 | 
			
		||||
block vars
 | 
			
		||||
	- const user = note.user;
 | 
			
		||||
	- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
 | 
			
		||||
	- const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`;
 | 
			
		||||
	- const url = `${config.url}/notes/${note.id}`;
 | 
			
		||||
	- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
 | 
			
		||||
	- const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +28,7 @@ block og
 | 
			
		|||
			// FIXME: add embed player for Twitter
 | 
			
		||||
	if images.length
 | 
			
		||||
		meta(property='twitter:card' content='summary_large_image')
 | 
			
		||||
		each image in images	
 | 
			
		||||
		each image in images
 | 
			
		||||
			meta(property='og:image'     content= image.url)
 | 
			
		||||
	else
 | 
			
		||||
		meta(property='twitter:card' content='summary')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
extends ./base
 | 
			
		||||
 | 
			
		||||
block vars
 | 
			
		||||
	- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
 | 
			
		||||
	- const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`;
 | 
			
		||||
	- const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`;
 | 
			
		||||
 | 
			
		||||
block title
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,6 +44,7 @@ describe('アンテナ', () => {
 | 
			
		|||
		users: [''],
 | 
			
		||||
		withFile: false,
 | 
			
		||||
		withReplies: false,
 | 
			
		||||
		excludeBots: false,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	let root: User;
 | 
			
		||||
| 
						 | 
				
			
			@ -156,6 +157,7 @@ describe('アンテナ', () => {
 | 
			
		|||
			users: [''],
 | 
			
		||||
			withFile: false,
 | 
			
		||||
			withReplies: false,
 | 
			
		||||
			excludeBots: false,
 | 
			
		||||
			localOnly: false,
 | 
			
		||||
		};
 | 
			
		||||
		assert.deepStrictEqual(response, expected);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -158,19 +158,17 @@ describe('Streaming', () => {
 | 
			
		|||
				assert.strictEqual(fired, true);
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			/* なんか失敗する
 | 
			
		||||
			test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => {
 | 
			
		||||
				const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko);
 | 
			
		||||
				const note = await post(kyoko, { text: 'foo', visibility: 'followers' });
 | 
			
		||||
 | 
			
		||||
				const fired = await waitFire(
 | 
			
		||||
					ayano, 'homeTimeline',		// ayano:home
 | 
			
		||||
					() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko),	// kyoko posts
 | 
			
		||||
					() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko),	// kyoko posts
 | 
			
		||||
					msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo',
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
				assert.strictEqual(fired, true);
 | 
			
		||||
			});
 | 
			
		||||
			*/
 | 
			
		||||
 | 
			
		||||
			test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
 | 
			
		||||
				const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,7 +61,7 @@
 | 
			
		|||
		"rollup": "4.12.0",
 | 
			
		||||
		"sanitize-html": "2.12.1",
 | 
			
		||||
		"sass": "1.71.1",
 | 
			
		||||
		"shiki": "1.1.7",
 | 
			
		||||
		"shiki": "1.2.0",
 | 
			
		||||
		"strict-event-emitter-types": "2.0.0",
 | 
			
		||||
		"textarea-caret": "3.1.0",
 | 
			
		||||
		"three": "0.162.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) {
 | 
			
		|||
	// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
 | 
			
		||||
	watch(defaultStore.reactiveState.darkMode, (darkMode) => {
 | 
			
		||||
		applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
 | 
			
		||||
		document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
 | 
			
		||||
	}, { immediate: miLocalStorage.getItem('theme') == null });
 | 
			
		||||
 | 
			
		||||
	document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
 | 
			
		||||
 | 
			
		||||
	const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
 | 
			
		||||
	const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed, watch } from 'vue';
 | 
			
		||||
import { bundledLanguagesInfo } from 'shiki';
 | 
			
		||||
import type { BuiltinLanguage } from 'shiki';
 | 
			
		||||
import { computed, ref, watch } from 'vue';
 | 
			
		||||
import { bundledLanguagesInfo } from 'shiki/langs';
 | 
			
		||||
import type { BundledLanguage } from 'shiki/langs';
 | 
			
		||||
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
 | 
			
		||||
import { defaultStore } from '@/store.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ const props = defineProps<{
 | 
			
		|||
 | 
			
		||||
const highlighter = await getHighlighter();
 | 
			
		||||
const darkMode = defaultStore.reactiveState.darkMode;
 | 
			
		||||
const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
 | 
			
		||||
const codeLang = ref<BundledLanguage | 'aiscript'>('js');
 | 
			
		||||
 | 
			
		||||
const [lightThemeName, darkThemeName] = await Promise.all([
 | 
			
		||||
	getTheme('light', true),
 | 
			
		||||
| 
						 | 
				
			
			@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, {
 | 
			
		|||
}));
 | 
			
		||||
 | 
			
		||||
async function fetchLanguage(to: string): Promise<void> {
 | 
			
		||||
	const language = to as BuiltinLanguage;
 | 
			
		||||
	const language = to as BundledLanguage;
 | 
			
		||||
 | 
			
		||||
	// Check for the loaded languages, and load the language if it's not loaded yet.
 | 
			
		||||
	if (!highlighter.getLoadedLanguages().includes(language)) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			:autocomplete="autocomplete"
 | 
			
		||||
			:autocapitalize="autocapitalize"
 | 
			
		||||
			:spellcheck="spellcheck"
 | 
			
		||||
			:inputmode="inputmode"
 | 
			
		||||
			:step="step"
 | 
			
		||||
			:list="id"
 | 
			
		||||
			:min="min"
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +64,7 @@ const props = defineProps<{
 | 
			
		|||
	mfmAutocomplete?: boolean | SuggestionType[],
 | 
			
		||||
	autocapitalize?: string;
 | 
			
		||||
	spellcheck?: boolean;
 | 
			
		||||
	inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal';
 | 
			
		||||
	step?: any;
 | 
			
		||||
	datalist?: string[];
 | 
			
		||||
	min?: number;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,6 +19,7 @@ import { defineAsyncComponent, ref } from 'vue';
 | 
			
		|||
import { url as local } from '@/config.js';
 | 
			
		||||
import { useTooltip } from '@/scripts/use-tooltip.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	url: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -32,13 +33,15 @@ const target = self ? null : '_blank';
 | 
			
		|||
 | 
			
		||||
const el = ref<HTMLElement | { $el: HTMLElement }>();
 | 
			
		||||
 | 
			
		||||
useTooltip(el, (showing) => {
 | 
			
		||||
	os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
 | 
			
		||||
		showing,
 | 
			
		||||
		url: props.url,
 | 
			
		||||
		source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
 | 
			
		||||
	}, {}, 'closed');
 | 
			
		||||
});
 | 
			
		||||
if (isEnabledUrlPreview.value) {
 | 
			
		||||
	useTooltip(el, (showing) => {
 | 
			
		||||
		os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
 | 
			
		||||
			showing,
 | 
			
		||||
			url: props.url,
 | 
			
		||||
			source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
 | 
			
		||||
		}, {}, 'closed');
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -86,7 +86,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
						<MkMediaList :mediaList="appearNote.files" @click.stop/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/>
 | 
			
		||||
					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
 | 
			
		||||
					<div v-if="isEnabledUrlPreview">
 | 
			
		||||
						<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
 | 
			
		||||
					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
 | 
			
		||||
						<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
 | 
			
		||||
| 
						 | 
				
			
			@ -184,6 +186,7 @@ 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';
 | 
			
		||||
| 
						 | 
				
			
			@ -198,7 +201,7 @@ import { userPage } from '@/filters/user.js';
 | 
			
		|||
import number from '@/filters/number.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import * as sound from '@/scripts/sound.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { defaultStore, noteViewInterruptors } from '@/store.js';
 | 
			
		||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
 | 
			
		||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -218,6 +221,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 | 
			
		|||
import { shouldCollapsed } from '@/scripts/collapsed.js';
 | 
			
		||||
import { useRouter } from '@/router/supplier.js';
 | 
			
		||||
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	note: Misskey.entities.Note;
 | 
			
		||||
| 
						 | 
				
			
			@ -306,7 +310,7 @@ const renoteCollapsed = ref(
 | 
			
		|||
	defaultStore.state.collapseRenotes && isRenote && (
 | 
			
		||||
		($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
 | 
			
		||||
		(appearNote.value.myReaction != null)
 | 
			
		||||
	)
 | 
			
		||||
	),
 | 
			
		||||
);
 | 
			
		||||
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
 | 
			
		||||
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
 | 
			
		||||
| 
						 | 
				
			
			@ -407,6 +411,28 @@ if (!props.mock) {
 | 
			
		|||
			renoted.value = res.length > 0;
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
		useTooltip(reactButton, async (showing) => {
 | 
			
		||||
			const reactions = await misskeyApiGet('notes/reactions', {
 | 
			
		||||
				noteId: appearNote.value.id,
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				_cacheKey_: appearNote.value.reactionCount,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const users = reactions.map(x => x.user);
 | 
			
		||||
 | 
			
		||||
			if (users.length < 1) return;
 | 
			
		||||
 | 
			
		||||
			os.popup(MkReactionsViewerDetails, {
 | 
			
		||||
				showing,
 | 
			
		||||
				reaction: '❤️',
 | 
			
		||||
				users,
 | 
			
		||||
				count: appearNote.value.reactionCount,
 | 
			
		||||
				targetElement: reactButton.value!,
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function boostVisibility() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -99,7 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
					<MkMediaList :mediaList="appearNote.files"/>
 | 
			
		||||
				</div>
 | 
			
		||||
				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
 | 
			
		||||
				<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
 | 
			
		||||
				<div v-if="isEnabledUrlPreview">
 | 
			
		||||
					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
 | 
			
		||||
| 
						 | 
				
			
			@ -226,6 +228,7 @@ import * as Misskey from 'misskey-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';
 | 
			
		||||
| 
						 | 
				
			
			@ -238,7 +241,7 @@ 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 } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
 | 
			
		||||
import * as sound from '@/scripts/sound.js';
 | 
			
		||||
import { defaultStore, noteViewInterruptors } from '@/store.js';
 | 
			
		||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -259,6 +262,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
 | 
			
		|||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	note: Misskey.entities.Note;
 | 
			
		||||
| 
						 | 
				
			
			@ -439,6 +443,28 @@ function boostVisibility() {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
	useTooltip(reactButton, async (showing) => {
 | 
			
		||||
		const reactions = await misskeyApiGet('notes/reactions', {
 | 
			
		||||
			noteId: appearNote.value.id,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			_cacheKey_: appearNote.value.reactionCount,
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		const users = reactions.map(x => x.user);
 | 
			
		||||
 | 
			
		||||
		if (users.length < 1) return;
 | 
			
		||||
 | 
			
		||||
		os.popup(MkReactionsViewerDetails, {
 | 
			
		||||
			showing,
 | 
			
		||||
			reaction: '❤️',
 | 
			
		||||
			users,
 | 
			
		||||
			count: appearNote.value.reactionCount,
 | 
			
		||||
			targetElement: reactButton.value!,
 | 
			
		||||
		}, {}, 'closed');
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renote(visibility: Visibility, localOnly: boolean = false) {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
		<div class="_gaps">
 | 
			
		||||
			<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
 | 
			
		||||
				<template #prefix><i class="ph-password ph-bold ph-lg"></i></template>
 | 
			
		||||
			</MkInput>
 | 
			
		||||
		<form @submit.prevent="done">
 | 
			
		||||
			<div class="_gaps">
 | 
			
		||||
				<MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true">
 | 
			
		||||
					<template #prefix><i class="ph-password ph-bold ph-lg"></i></template>
 | 
			
		||||
				</MkInput>
 | 
			
		||||
 | 
			
		||||
			<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
 | 
			
		||||
				<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
 | 
			
		||||
				<template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template>
 | 
			
		||||
			</MkInput>
 | 
			
		||||
				<MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
 | 
			
		||||
					<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
 | 
			
		||||
					<template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-math-operations ph-bold ph-lg"></i></template>
 | 
			
		||||
					<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
 | 
			
		||||
				</MkInput>
 | 
			
		||||
 | 
			
		||||
			<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton>
 | 
			
		||||
		</div>
 | 
			
		||||
				<MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton>
 | 
			
		||||
			</div>
 | 
			
		||||
		</form>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
</MkModalWindow>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +57,7 @@ const emit = defineEmits<{
 | 
			
		|||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
 | 
			
		||||
const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
 | 
			
		||||
const password = ref('');
 | 
			
		||||
const isBackupCode = ref(false);
 | 
			
		||||
const token = ref<string | null>(null);
 | 
			
		||||
 | 
			
		||||
function onClose() {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,7 +65,7 @@ function onClose() {
 | 
			
		|||
	if (dialog.value) dialog.value.close();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function done(res) {
 | 
			
		||||
function done() {
 | 
			
		||||
	emit('done', { password: password.value, token: token.value });
 | 
			
		||||
	if (dialog.value) dialog.value.close();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<div v-if="user && user.securityKeys" class="or-hr">
 | 
			
		||||
				<p class="or-msg">{{ i18n.ts.or }}</p>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="twofa-group totp-group">
 | 
			
		||||
				<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
 | 
			
		||||
			<div class="twofa-group totp-group _gaps">
 | 
			
		||||
				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
 | 
			
		||||
					<template #label>{{ i18n.ts.password }}</template>
 | 
			
		||||
					<template #prefix><i class="ph-lock ph-bold ph-lg"></i></template>
 | 
			
		||||
				</MkInput>
 | 
			
		||||
				<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
 | 
			
		||||
					<template #label>{{ i18n.ts.token }}</template>
 | 
			
		||||
					<template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template>
 | 
			
		||||
				<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
 | 
			
		||||
					<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
 | 
			
		||||
					<template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-math-operations ph-bold ph-lg"></i></template>
 | 
			
		||||
					<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
 | 
			
		||||
				</MkInput>
 | 
			
		||||
				<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
 | 
			
		||||
			</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +70,7 @@ const password = ref('');
 | 
			
		|||
const token = ref('');
 | 
			
		||||
const host = ref(toUnicode(configHost));
 | 
			
		||||
const totpLogin = ref(false);
 | 
			
		||||
const isBackupCode = ref(false);
 | 
			
		||||
const queryingKey = ref(false);
 | 
			
		||||
const credentialRequest = ref<CredentialRequestOptions | null>(null);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -152,15 +152,16 @@ requestUrl.hash = '';
 | 
			
		|||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
 | 
			
		||||
	.then(res => {
 | 
			
		||||
		if (!res.ok) {
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
			unknownUrl.value = true;
 | 
			
		||||
			return;
 | 
			
		||||
			if (_DEV_) {
 | 
			
		||||
				console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
 | 
			
		||||
			}
 | 
			
		||||
			return null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return res.json();
 | 
			
		||||
	})
 | 
			
		||||
	.then((info: SummalyResult) => {
 | 
			
		||||
		if (info.url == null) {
 | 
			
		||||
	.then((info: SummalyResult | null) => {
 | 
			
		||||
		if (!info || info.url == null) {
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
			unknownUrl.value = true;
 | 
			
		||||
			return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ import { url as local } from '@/config.js';
 | 
			
		|||
import * as os from '@/os.js';
 | 
			
		||||
import { useTooltip } from '@/scripts/use-tooltip.js';
 | 
			
		||||
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	url: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +46,7 @@ const url = new URL(props.url);
 | 
			
		|||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
 | 
			
		||||
const el = ref();
 | 
			
		||||
 | 
			
		||||
if (props.showUrlPreview) {
 | 
			
		||||
if (props.showUrlPreview && isEnabledUrlPreview.value) {
 | 
			
		||||
	useTooltip(el, (showing) => {
 | 
			
		||||
		os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
 | 
			
		||||
			showing,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,19 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
	<MediaImage
 | 
			
		||||
		v-if="image"
 | 
			
		||||
		:image="image"
 | 
			
		||||
		:disableImageLink="true"
 | 
			
		||||
	/>
 | 
			
		||||
<div :class="$style.root">
 | 
			
		||||
	<MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, ref } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import MediaImage from '@/components/MkMediaImage.vue';
 | 
			
		||||
import MkMediaList from '@/components/MkMediaList.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	block: Misskey.entities.PageBlock,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,5 +24,17 @@ const image = ref<Misskey.entities.DriveFile | null>(null);
 | 
			
		|||
onMounted(() => {
 | 
			
		||||
	image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.root {
 | 
			
		||||
	border: 1px solid var(--divider);
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
.mediaList {
 | 
			
		||||
	// MkMediaList 内の上部マージン 4px
 | 
			
		||||
	margin-top: -4px;
 | 
			
		||||
	height: calc(100% + 4px);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div style="margin: 1em 0;">
 | 
			
		||||
	<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
 | 
			
		||||
	<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
 | 
			
		||||
<div :class="$style.root">
 | 
			
		||||
	<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/>
 | 
			
		||||
	<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -32,3 +32,10 @@ onMounted(() => {
 | 
			
		|||
		});
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.root {
 | 
			
		||||
	border: 1px solid var(--divider);
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,9 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div class="_gaps">
 | 
			
		||||
<div class="_gaps" :class="$style.textRoot">
 | 
			
		||||
	<Mfm :text="block.text ?? ''" :isNote="false"/>
 | 
			
		||||
	<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
 | 
			
		||||
	<div v-if="isEnabledUrlPreview">
 | 
			
		||||
		<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +17,7 @@ import { defineAsyncComponent } from 'vue';
 | 
			
		|||
import * as mfm from '@transfem-org/sfm-js';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
 | 
			
		||||
const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -25,3 +28,9 @@ const props = defineProps<{
 | 
			
		|||
 | 
			
		||||
const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : [];
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.textRoot {
 | 
			
		||||
	font-size: 1.1rem;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps_s">
 | 
			
		||||
<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps">
 | 
			
		||||
	<XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,7 +18,7 @@
 | 
			
		|||
		http-equiv="Content-Security-Policy"
 | 
			
		||||
		content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
 | 
			
		||||
			worker-src 'self';
 | 
			
		||||
			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com;
 | 
			
		||||
			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
 | 
			
		||||
			style-src 'self' 'unsafe-inline';
 | 
			
		||||
			img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
 | 
			
		||||
			media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,6 +36,8 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
 | 
			
		|||
 | 
			
		||||
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
 | 
			
		||||
 | 
			
		||||
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
 | 
			
		||||
 | 
			
		||||
export async function fetchInstance(force = false): Promise<void> {
 | 
			
		||||
	if (!force) {
 | 
			
		||||
		const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,19 +75,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
						</MkSwitch>
 | 
			
		||||
					</div>
 | 
			
		||||
				</MkFolder>
 | 
			
		||||
 | 
			
		||||
				<MkFolder>
 | 
			
		||||
					<template #label>Summaly Proxy</template>
 | 
			
		||||
 | 
			
		||||
					<div class="_gaps_m">
 | 
			
		||||
						<MkInput v-model="summalyProxy">
 | 
			
		||||
							<template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
 | 
			
		||||
							<template #label>Summaly Proxy URL</template>
 | 
			
		||||
						</MkInput>
 | 
			
		||||
 | 
			
		||||
						<MkButton primary @click="save"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
 | 
			
		||||
					</div>
 | 
			
		||||
				</MkFolder>
 | 
			
		||||
			</div>
 | 
			
		||||
		</FormSuspense>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
| 
						 | 
				
			
			@ -112,7 +99,6 @@ import { fetchInstance } from '@/instance.js';
 | 
			
		|||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
			
		||||
 | 
			
		||||
const summalyProxy = ref<string>('');
 | 
			
		||||
const enableHcaptcha = ref<boolean>(false);
 | 
			
		||||
const enableMcaptcha = ref<boolean>(false);
 | 
			
		||||
const enableRecaptcha = ref<boolean>(false);
 | 
			
		||||
| 
						 | 
				
			
			@ -128,7 +114,6 @@ const bannedEmailDomains = ref<string>('');
 | 
			
		|||
 | 
			
		||||
async function init() {
 | 
			
		||||
	const meta = await misskeyApi('admin/meta');
 | 
			
		||||
	summalyProxy.value = meta.summalyProxy;
 | 
			
		||||
	enableHcaptcha.value = meta.enableHcaptcha;
 | 
			
		||||
	enableMcaptcha.value = meta.enableMcaptcha;
 | 
			
		||||
	enableRecaptcha.value = meta.enableRecaptcha;
 | 
			
		||||
| 
						 | 
				
			
			@ -145,7 +130,6 @@ async function init() {
 | 
			
		|||
 | 
			
		||||
function save() {
 | 
			
		||||
	os.apiWithDialog('admin/update-meta', {
 | 
			
		||||
		summalyProxy: summalyProxy.value,
 | 
			
		||||
		enableIpLogging: enableIpLogging.value,
 | 
			
		||||
		enableActiveEmailValidation: enableActiveEmailValidation.value,
 | 
			
		||||
		enableVerifymailApi: enableVerifymailApi.value,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -148,6 +148,53 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</FormSection>
 | 
			
		||||
 | 
			
		||||
					<FormSection>
 | 
			
		||||
						<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
 | 
			
		||||
 | 
			
		||||
						<div class="_gaps_m">
 | 
			
		||||
							<MkSwitch v-model="urlPreviewEnabled">
 | 
			
		||||
								<template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
 | 
			
		||||
							</MkSwitch>
 | 
			
		||||
 | 
			
		||||
							<MkSwitch v-model="urlPreviewRequireContentLength">
 | 
			
		||||
								<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
 | 
			
		||||
								<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
 | 
			
		||||
							</MkSwitch>
 | 
			
		||||
 | 
			
		||||
							<MkInput v-model="urlPreviewMaximumContentLength" type="number">
 | 
			
		||||
								<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
 | 
			
		||||
								<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
 | 
			
		||||
							</MkInput>
 | 
			
		||||
 | 
			
		||||
							<MkInput v-model="urlPreviewTimeout" type="number">
 | 
			
		||||
								<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
 | 
			
		||||
								<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
 | 
			
		||||
							</MkInput>
 | 
			
		||||
 | 
			
		||||
							<MkInput v-model="urlPreviewUserAgent" type="text">
 | 
			
		||||
								<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
 | 
			
		||||
								<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
 | 
			
		||||
							</MkInput>
 | 
			
		||||
 | 
			
		||||
							<div>
 | 
			
		||||
								<MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
 | 
			
		||||
									<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
 | 
			
		||||
									<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
 | 
			
		||||
								</MkInput>
 | 
			
		||||
 | 
			
		||||
								<div :class="$style.subCaption">
 | 
			
		||||
									{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
 | 
			
		||||
									<ul style="padding-left: 20px; margin: 4px 0">
 | 
			
		||||
										<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
 | 
			
		||||
										<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
 | 
			
		||||
										<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
 | 
			
		||||
										<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
 | 
			
		||||
									</ul>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</FormSection>
 | 
			
		||||
				</div>
 | 
			
		||||
			</FormSuspense>
 | 
			
		||||
		</MkSpacer>
 | 
			
		||||
| 
						 | 
				
			
			@ -178,6 +225,8 @@ import { fetchInstance, instance } from '@/instance.js';
 | 
			
		|||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
import MkFolder from '@/components/MkFolder.vue';
 | 
			
		||||
import MkSelect from '@/components/MkSelect.vue';
 | 
			
		||||
 | 
			
		||||
const name = ref<string | null>(null);
 | 
			
		||||
const shortName = ref<string | null>(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -200,6 +249,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0);
 | 
			
		|||
const perUserHomeTimelineCacheMax = ref<number>(0);
 | 
			
		||||
const perUserListTimelineCacheMax = ref<number>(0);
 | 
			
		||||
const notesPerOneAd = ref<number>(0);
 | 
			
		||||
const urlPreviewEnabled = ref<boolean>(true);
 | 
			
		||||
const urlPreviewTimeout = ref<number>(10000);
 | 
			
		||||
const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
 | 
			
		||||
const urlPreviewRequireContentLength = ref<boolean>(true);
 | 
			
		||||
const urlPreviewUserAgent = ref<string | null>(null);
 | 
			
		||||
const urlPreviewSummaryProxyUrl = ref<string | null>(null);
 | 
			
		||||
 | 
			
		||||
async function init(): Promise<void> {
 | 
			
		||||
	const meta = await misskeyApi('admin/meta');
 | 
			
		||||
| 
						 | 
				
			
			@ -224,9 +279,15 @@ async function init(): Promise<void> {
 | 
			
		|||
	perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
 | 
			
		||||
	perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
 | 
			
		||||
	notesPerOneAd.value = meta.notesPerOneAd;
 | 
			
		||||
	urlPreviewEnabled.value = meta.urlPreviewEnabled;
 | 
			
		||||
	urlPreviewTimeout.value = meta.urlPreviewTimeout;
 | 
			
		||||
	urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
 | 
			
		||||
	urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
 | 
			
		||||
	urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
 | 
			
		||||
	urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function save(): void {
 | 
			
		||||
async function save() {
 | 
			
		||||
	await os.apiWithDialog('admin/update-meta', {
 | 
			
		||||
		name: name.value,
 | 
			
		||||
		shortName: shortName.value === '' ? null : shortName.value,
 | 
			
		||||
| 
						 | 
				
			
			@ -249,6 +310,12 @@ async function save(): void {
 | 
			
		|||
		perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
 | 
			
		||||
		perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
 | 
			
		||||
		notesPerOneAd: notesPerOneAd.value,
 | 
			
		||||
		urlPreviewEnabled: urlPreviewEnabled.value,
 | 
			
		||||
		urlPreviewTimeout: urlPreviewTimeout.value,
 | 
			
		||||
		urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
 | 
			
		||||
		urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
 | 
			
		||||
		urlPreviewUserAgent: urlPreviewUserAgent.value,
 | 
			
		||||
		urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	fetchInstance(true);
 | 
			
		||||
| 
						 | 
				
			
			@ -267,4 +334,9 @@ definePageMetadata(() => ({
 | 
			
		|||
	-webkit-backdrop-filter: var(--blur, blur(15px));
 | 
			
		||||
	backdrop-filter: var(--blur, blur(15px));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.subCaption {
 | 
			
		||||
	font-size: 0.85em;
 | 
			
		||||
	color: var(--fgTransparentWeak);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<MkCodeEditor v-model="script" lang="is">
 | 
			
		||||
				<template #label>{{ i18n.ts._play.script }}</template>
 | 
			
		||||
			</MkCodeEditor>
 | 
			
		||||
			<MkSelect v-model="visibility">
 | 
			
		||||
				<template #label>{{ i18n.ts.visibility }}</template>
 | 
			
		||||
				<template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
 | 
			
		||||
				<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
 | 
			
		||||
				<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
 | 
			
		||||
			</MkSelect>
 | 
			
		||||
			<div class="_buttons">
 | 
			
		||||
				<MkButton primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
 | 
			
		||||
				<MkButton @click="show"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.show }}</MkButton>
 | 
			
		||||
				<MkButton v-if="flash" danger @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
 | 
			
		||||
			</div>
 | 
			
		||||
			<MkSelect v-model="visibility">
 | 
			
		||||
				<template #label>{{ i18n.ts.visibility }}</template>
 | 
			
		||||
				<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
 | 
			
		||||
				<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
 | 
			
		||||
			</MkSelect>
 | 
			
		||||
		</div>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
</MkStickyContainer>
 | 
			
		||||
| 
						 | 
				
			
			@ -367,7 +368,7 @@ const props = defineProps<{
 | 
			
		|||
}>();
 | 
			
		||||
 | 
			
		||||
const flash = ref<Misskey.entities.Flash | null>(null);
 | 
			
		||||
const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public');
 | 
			
		||||
const visibility = ref<'private' | 'public'>('public');
 | 
			
		||||
 | 
			
		||||
if (props.id) {
 | 
			
		||||
	flash.value = await misskeyApi('flash/show', {
 | 
			
		||||
| 
						 | 
				
			
			@ -420,6 +421,7 @@ async function save() {
 | 
			
		|||
			summary: summary.value,
 | 
			
		||||
			permissions: permissions.value,
 | 
			
		||||
			script: script.value,
 | 
			
		||||
			visibility: visibility.value,
 | 
			
		||||
		});
 | 
			
		||||
		router.push('/play/' + created.id + '/edit');
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,6 +26,7 @@ const draft = ref({
 | 
			
		|||
	users: [],
 | 
			
		||||
	keywords: [],
 | 
			
		||||
	excludeKeywords: [],
 | 
			
		||||
	excludeBots: false,
 | 
			
		||||
	withReplies: false,
 | 
			
		||||
	caseSensitive: false,
 | 
			
		||||
	localOnly: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
				<template #label>{{ i18n.ts.users }}</template>
 | 
			
		||||
				<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
 | 
			
		||||
			</MkTextarea>
 | 
			
		||||
			<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
 | 
			
		||||
			<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
 | 
			
		||||
			<MkTextarea v-model="keywords">
 | 
			
		||||
				<template #label>{{ i18n.ts.antennaKeywords }}</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +79,7 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('
 | 
			
		|||
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
 | 
			
		||||
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
 | 
			
		||||
const localOnly = ref<boolean>(props.antenna.localOnly);
 | 
			
		||||
const excludeBots = ref<boolean>(props.antenna.excludeBots);
 | 
			
		||||
const withReplies = ref<boolean>(props.antenna.withReplies);
 | 
			
		||||
const withFile = ref<boolean>(props.antenna.withFile);
 | 
			
		||||
const notify = ref<boolean>(props.antenna.notify);
 | 
			
		||||
| 
						 | 
				
			
			@ -94,6 +96,7 @@ async function saveAntenna() {
 | 
			
		|||
		name: name.value,
 | 
			
		||||
		src: src.value,
 | 
			
		||||
		userListId: userListId.value,
 | 
			
		||||
		excludeBots: excludeBots.value,
 | 
			
		||||
		withReplies: withReplies.value,
 | 
			
		||||
		withFile: withFile.value,
 | 
			
		||||
		notify: notify.value,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
<XContainer :draggable="true" @remove="() => $emit('remove')">
 | 
			
		||||
	<template #header><i class="ph-note ph-bold ph-lg"></i> {{ i18n.ts._pages.blocks.note }}</template>
 | 
			
		||||
 | 
			
		||||
	<section style="padding: 0 16px 0 16px;">
 | 
			
		||||
	<section style="padding: 16px;" class="_gaps_s">
 | 
			
		||||
		<MkInput v-model="id">
 | 
			
		||||
			<template #label>{{ i18n.ts._pages.blocks._note.id }}</template>
 | 
			
		||||
			<template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,48 +6,73 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
<template>
 | 
			
		||||
<MkStickyContainer>
 | 
			
		||||
	<template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template>
 | 
			
		||||
	<MkSpacer :contentMax="700">
 | 
			
		||||
		<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
 | 
			
		||||
			<div v-if="page" :key="page.id" class="xcukqgmh">
 | 
			
		||||
				<div class="main">
 | 
			
		||||
					<!--
 | 
			
		||||
				<div class="header">
 | 
			
		||||
					<h1>{{ page.title }}</h1>
 | 
			
		||||
				</div>
 | 
			
		||||
				-->
 | 
			
		||||
					<div class="banner">
 | 
			
		||||
						<MkMediaImage
 | 
			
		||||
							v-if="page.eyeCatchingImageId"
 | 
			
		||||
							:image="page.eyeCatchingImage"
 | 
			
		||||
							:cover="true"
 | 
			
		||||
							:disableImageLink="true"
 | 
			
		||||
							class="thumbnail"
 | 
			
		||||
						/>
 | 
			
		||||
	<MkSpacer :contentMax="800">
 | 
			
		||||
		<Transition
 | 
			
		||||
			:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
 | 
			
		||||
			:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
 | 
			
		||||
			:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
 | 
			
		||||
			:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
 | 
			
		||||
		>
 | 
			
		||||
			<div v-if="page" :key="page.id" class="_gaps">
 | 
			
		||||
				<div :class="$style.pageMain">
 | 
			
		||||
					<div :class="$style.pageBanner">
 | 
			
		||||
						<div :class="$style.pageBannerBgRoot">
 | 
			
		||||
							<MkImgWithBlurhash
 | 
			
		||||
								v-if="page.eyeCatchingImageId"
 | 
			
		||||
								:class="$style.pageBannerBg"
 | 
			
		||||
								:hash="page.eyeCatchingImage?.blurhash"
 | 
			
		||||
								:cover="true"
 | 
			
		||||
								:forceBlurhash="true"
 | 
			
		||||
							/>
 | 
			
		||||
							<img
 | 
			
		||||
								v-else-if="instance.backgroundImageUrl || instance.bannerUrl"
 | 
			
		||||
								:class="[$style.pageBannerBg, $style.pageBannerBgFallback1]"
 | 
			
		||||
								:src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)"
 | 
			
		||||
							/>
 | 
			
		||||
							<div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage">
 | 
			
		||||
							<MkMediaImage
 | 
			
		||||
								:image="page.eyeCatchingImage!"
 | 
			
		||||
								:cover="true"
 | 
			
		||||
								:disableImageLink="true"
 | 
			
		||||
								:class="$style.thumbnail"
 | 
			
		||||
							/>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div :class="$style.pageBannerTitle" class="_gaps_s">
 | 
			
		||||
							<h1>{{ page.title || page.name }}</h1>
 | 
			
		||||
							<div v-if="page.user" :class="$style.pageBannerTitleUser">
 | 
			
		||||
								<MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
					<div :class="$style.pageContent">
 | 
			
		||||
						<XPage :page="page"/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="actions">
 | 
			
		||||
						<div class="like">
 | 
			
		||||
					<div :class="$style.pageActions">
 | 
			
		||||
						<div>
 | 
			
		||||
							<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ph-heart-break ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
 | 
			
		||||
							<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div class="other">
 | 
			
		||||
							<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-rocket-launch ph-bold ph-lg ti-fw"></i></button>
 | 
			
		||||
							<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
 | 
			
		||||
							<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
 | 
			
		||||
						<div :class="$style.other">
 | 
			
		||||
							<button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ph-link ph-bold ph-lg ti-fw"></i></button>
 | 
			
		||||
							<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="user">
 | 
			
		||||
						<MkAvatar :user="page.user" class="avatar" link preview/>
 | 
			
		||||
						<div class="name">
 | 
			
		||||
							<MkUserName :user="page.user" style="display: block;"/>
 | 
			
		||||
							<MkAcct :user="page.user"/>
 | 
			
		||||
						</div>
 | 
			
		||||
						<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
 | 
			
		||||
					<div :class="$style.pageUser">
 | 
			
		||||
						<MkAvatar :user="page.user" :class="$style.avatar" link preview/>
 | 
			
		||||
						<MkA :to="`/@${username}`">
 | 
			
		||||
							<MkUserName :user="page.user" :class="$style.name"/>
 | 
			
		||||
							<MkAcct :user="page.user" :class="$style.acct"/>
 | 
			
		||||
						</MkA>
 | 
			
		||||
						<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="links">
 | 
			
		||||
						<MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
 | 
			
		||||
					<div :class="$style.pageDate">
 | 
			
		||||
						<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
 | 
			
		||||
						<div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div :class="$style.pageLinks">
 | 
			
		||||
						<MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
 | 
			
		||||
						<template v-if="$i && $i.id === page.userId">
 | 
			
		||||
							<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
 | 
			
		||||
							<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
 | 
			
		||||
| 
						 | 
				
			
			@ -55,10 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
						</template>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="footer">
 | 
			
		||||
					<div><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
 | 
			
		||||
					<div v-if="page.createdAt != page.updatedAt"><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<MkAd :prefer="['horizontal', 'horizontal-big']"/>
 | 
			
		||||
				<MkContainer :max-height="300" :foldable="true" class="other">
 | 
			
		||||
					<template #icon><i class="ph-clock ph-bold ph-lg"></i></template>
 | 
			
		||||
| 
						 | 
				
			
			@ -84,6 +105,7 @@ import * as os from '@/os.js';
 | 
			
		|||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { url } from '@/config.js';
 | 
			
		||||
import MkMediaImage from '@/components/MkMediaImage.vue';
 | 
			
		||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
 | 
			
		||||
import MkFollowButton from '@/components/MkFollowButton.vue';
 | 
			
		||||
import MkContainer from '@/components/MkContainer.vue';
 | 
			
		||||
import MkPagination from '@/components/MkPagination.vue';
 | 
			
		||||
| 
						 | 
				
			
			@ -94,6 +116,8 @@ import { pageViewInterruptors, defaultStore } from '@/store.js';
 | 
			
		|||
import { deepClone } from '@/scripts/clone.js';
 | 
			
		||||
import { $i } from '@/account.js';
 | 
			
		||||
import { isSupportShare } from '@/scripts/navigator.js';
 | 
			
		||||
import { instance } from '@/instance.js';
 | 
			
		||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
| 
						 | 
				
			
			@ -133,35 +157,63 @@ function fetchPage() {
 | 
			
		|||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function share() {
 | 
			
		||||
	navigator.share({
 | 
			
		||||
		title: page.value.title ?? page.value.name,
 | 
			
		||||
		text: page.value.summary,
 | 
			
		||||
		url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
 | 
			
		||||
	});
 | 
			
		||||
function share(ev: MouseEvent) {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	os.popupMenu([
 | 
			
		||||
		{
 | 
			
		||||
			text: i18n.ts.shareWithNote,
 | 
			
		||||
			icon: 'ti ti-pencil',
 | 
			
		||||
			action: shareWithNote,
 | 
			
		||||
		},
 | 
			
		||||
		...(isSupportShare() ? [{
 | 
			
		||||
			text: i18n.ts.share,
 | 
			
		||||
			icon: 'ti ti-share',
 | 
			
		||||
			action: shareWithNavigator,
 | 
			
		||||
		}] : []),
 | 
			
		||||
	], ev.currentTarget ?? ev.target);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function copyLink() {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`);
 | 
			
		||||
	os.success();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function shareWithNote() {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	os.post({
 | 
			
		||||
		initialText: `${page.value.title || page.value.name} ${url}/@${page.value.user.username}/pages/${page.value.name}`,
 | 
			
		||||
		initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`,
 | 
			
		||||
		instant: true,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function shareWithNavigator() {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	navigator.share({
 | 
			
		||||
		title: page.value.title ?? page.value.name,
 | 
			
		||||
		text: page.value.summary ?? undefined,
 | 
			
		||||
		url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function like() {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	os.apiWithDialog('pages/like', {
 | 
			
		||||
		pageId: page.value.id,
 | 
			
		||||
	}).then(() => {
 | 
			
		||||
		page.value.isLiked = true;
 | 
			
		||||
		page.value.likedCount++;
 | 
			
		||||
		page.value!.isLiked = true;
 | 
			
		||||
		page.value!.likedCount++;
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function unlike() {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	const confirm = await os.confirm({
 | 
			
		||||
		type: 'warning',
 | 
			
		||||
		text: i18n.ts.unlikeConfirm,
 | 
			
		||||
| 
						 | 
				
			
			@ -170,12 +222,14 @@ async function unlike() {
 | 
			
		|||
	os.apiWithDialog('pages/unlike', {
 | 
			
		||||
		pageId: page.value.id,
 | 
			
		||||
	}).then(() => {
 | 
			
		||||
		page.value.isLiked = false;
 | 
			
		||||
		page.value.likedCount--;
 | 
			
		||||
		page.value!.isLiked = false;
 | 
			
		||||
		page.value!.likedCount--;
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function pin(pin) {
 | 
			
		||||
	if (!page.value) return;
 | 
			
		||||
 | 
			
		||||
	os.apiWithDialog('i/update', {
 | 
			
		||||
		pinnedPageId: pin ? page.value.id : null,
 | 
			
		||||
	});
 | 
			
		||||
| 
						 | 
				
			
			@ -200,109 +254,185 @@ definePageMetadata(() => ({
 | 
			
		|||
}));
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.fade-enter-active,
 | 
			
		||||
.fade-leave-active {
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.fadeEnterActive,
 | 
			
		||||
.fadeLeaveActive {
 | 
			
		||||
	transition: opacity 0.125s ease;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter-from,
 | 
			
		||||
.fade-leave-to {
 | 
			
		||||
.fadeEnterFrom,
 | 
			
		||||
.fadeLeaveTo {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.xcukqgmh {
 | 
			
		||||
	> .main {
 | 
			
		||||
		padding: 32px;
 | 
			
		||||
.generalActionButton {
 | 
			
		||||
	height: 2.5rem;
 | 
			
		||||
	width: 2.5rem;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	border-radius: 99rem;
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
 | 
			
		||||
			> h1 {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .banner {
 | 
			
		||||
			> .thumbnail {
 | 
			
		||||
				// TODO: 良い感じのアスペクト比で表示
 | 
			
		||||
				display: block;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
				height: auto;
 | 
			
		||||
				aspect-ratio: 3/1;
 | 
			
		||||
				border-radius: var(--radius);
 | 
			
		||||
				overflow: hidden;
 | 
			
		||||
				object-fit: cover;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .content {
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
			padding: 16px 0 0 0;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .actions {
 | 
			
		||||
			display: flex;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
			padding: 16px 0 0 0;
 | 
			
		||||
			border-top: solid 0.5px var(--divider);
 | 
			
		||||
 | 
			
		||||
			> .other {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
 | 
			
		||||
				> button {
 | 
			
		||||
					padding: 8px;
 | 
			
		||||
					margin: 0 8px;
 | 
			
		||||
 | 
			
		||||
					&:hover {
 | 
			
		||||
						color: var(--fgHighlighted);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .user {
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
			padding: 16px 0 0 0;
 | 
			
		||||
			border-top: solid 0.5px var(--divider);
 | 
			
		||||
			display: flex;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
 | 
			
		||||
			> .avatar {
 | 
			
		||||
				width: 52px;
 | 
			
		||||
				height: 52px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .name {
 | 
			
		||||
				margin: 0 0 0 12px;
 | 
			
		||||
				font-size: 90%;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .koudoku {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .links {
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
			padding: 24px 0 0 0;
 | 
			
		||||
			border-top: solid 0.5px var(--divider);
 | 
			
		||||
 | 
			
		||||
			> .link {
 | 
			
		||||
				margin-right: 0.75em;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	& :global(.ti) {
 | 
			
		||||
		line-height: 2.5rem;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .footer {
 | 
			
		||||
		margin: var(--margin) 0 var(--margin) 0;
 | 
			
		||||
		font-size: 85%;
 | 
			
		||||
		opacity: 0.75;
 | 
			
		||||
	&:hover,
 | 
			
		||||
	&:focus-visible {
 | 
			
		||||
		background-color: var(--accentedBg);
 | 
			
		||||
		color: var(--accent);
 | 
			
		||||
		text-decoration: none;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style module>
 | 
			
		||||
.pageMain {
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
	padding: 2rem;
 | 
			
		||||
	background: var(--panel);
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageBanner {
 | 
			
		||||
	width: calc(100% + 4rem);
 | 
			
		||||
	margin: -2rem -2rem 1.5rem;
 | 
			
		||||
	border-radius: var(--radius) var(--radius) 0 0;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	position: relative;
 | 
			
		||||
 | 
			
		||||
	> .pageBannerBgRoot {
 | 
			
		||||
		position: absolute;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		left: 0;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		height: 100%;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
 | 
			
		||||
		.pageBannerBg {
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			height: 100%;
 | 
			
		||||
			object-fit: cover;
 | 
			
		||||
			opacity: .2;
 | 
			
		||||
			filter: brightness(1.2);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.pageBannerBgFallback1 {
 | 
			
		||||
			filter: blur(20px);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.pageBannerBgFallback2 {
 | 
			
		||||
			background-color: var(--accentedBg);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&::after {
 | 
			
		||||
			content: '';
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			bottom: 0;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			height: 100px;
 | 
			
		||||
			background: linear-gradient(0deg, var(--panel), transparent);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .pageBannerImage {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding-top: 56.25%;
 | 
			
		||||
 | 
			
		||||
		> .thumbnail {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			height: 100%;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .pageBannerTitle {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding: 1.5rem 2rem;
 | 
			
		||||
 | 
			
		||||
		h1 {
 | 
			
		||||
			font-size: 2rem;
 | 
			
		||||
			font-weight: 700;
 | 
			
		||||
			color: var(--fg);
 | 
			
		||||
			margin: 0;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.pageBannerTitleUser {
 | 
			
		||||
			--height: 32px;
 | 
			
		||||
 | 
			
		||||
			.avatar {
 | 
			
		||||
				height: var(--height);
 | 
			
		||||
				width: var(--height);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			line-height: var(--height);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageContent {
 | 
			
		||||
	margin-bottom: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageActions {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
 | 
			
		||||
	border-top: 1px solid var(--divider);
 | 
			
		||||
	padding-top: 1.5rem;
 | 
			
		||||
	margin-bottom: 1.5rem;
 | 
			
		||||
 | 
			
		||||
	> .other {
 | 
			
		||||
		margin-left: auto;
 | 
			
		||||
		display: flex;
 | 
			
		||||
		gap: var(--marginHalf);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageUser {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
 | 
			
		||||
	border-top: 1px solid var(--divider);
 | 
			
		||||
	padding-top: 1.5rem;
 | 
			
		||||
	margin-bottom: 1.5rem;
 | 
			
		||||
 | 
			
		||||
	.avatar,
 | 
			
		||||
	.name,
 | 
			
		||||
	.acct {
 | 
			
		||||
		display: block;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	.avatar {
 | 
			
		||||
		width: 4rem;
 | 
			
		||||
		height: 4rem;
 | 
			
		||||
		margin-right: 1rem;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	.name {
 | 
			
		||||
		font-size: 110%;
 | 
			
		||||
		font-weight: 700;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	.acct {
 | 
			
		||||
		font-size: 90%;
 | 
			
		||||
		opacity: 0.7;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	.follow {
 | 
			
		||||
		margin-left: auto;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageDate {
 | 
			
		||||
	margin-bottom: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pageLinks {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	flex-wrap: wrap;
 | 
			
		||||
	gap: var(--marginHalf);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.relatedPagesRoot {
 | 
			
		||||
	padding: var(--margin);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
					<MkSpacer :marginMin="20" :marginMax="28">
 | 
			
		||||
						<div class="_gaps">
 | 
			
		||||
							<div>{{ i18n.ts._2fa.step3Title }}</div>
 | 
			
		||||
							<MkInput v-model="token" autocomplete="one-time-code"></MkInput>
 | 
			
		||||
							<MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput>
 | 
			
		||||
							<div>{{ i18n.ts._2fa.step3 }}</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div class="_buttonsCenter" style="margin-top: 16px;">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -80,7 +80,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 | 
			
		|||
import FormSection from '@/components/form/section.vue';
 | 
			
		||||
import MkFolder from '@/components/MkFolder.vue';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { signinRequired } from '@/account.js';
 | 
			
		||||
import { signinRequired, updateAccount } from '@/account.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
 | 
			
		||||
const $i = signinRequired();
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +116,10 @@ async function unregisterTOTP(): Promise<void> {
 | 
			
		|||
	os.apiWithDialog('i/2fa/unregister', {
 | 
			
		||||
		password: auth.result.password,
 | 
			
		||||
		token: auth.result.token,
 | 
			
		||||
	}).then(res => {
 | 
			
		||||
		updateAccount({
 | 
			
		||||
			twoFactorEnabled: false,
 | 
			
		||||
		});
 | 
			
		||||
	}).catch(error => {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,18 +3,19 @@
 | 
			
		|||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { bundledThemesInfo } from 'shiki';
 | 
			
		||||
import { getHighlighterCore, loadWasm } from 'shiki/core';
 | 
			
		||||
import darkPlus from 'shiki/themes/dark-plus.mjs';
 | 
			
		||||
import { bundledThemesInfo } from 'shiki/themes';
 | 
			
		||||
import { bundledLanguagesInfo } from 'shiki/langs';
 | 
			
		||||
import { unique } from './array.js';
 | 
			
		||||
import { deepClone } from './clone.js';
 | 
			
		||||
import { deepMerge } from './merge.js';
 | 
			
		||||
import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
 | 
			
		||||
import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
 | 
			
		||||
import { ColdDeviceStorage } from '@/store.js';
 | 
			
		||||
import lightTheme from '@/themes/_light.json5';
 | 
			
		||||
import darkTheme from '@/themes/_dark.json5';
 | 
			
		||||
 | 
			
		||||
let _highlighter: Highlighter | null = null;
 | 
			
		||||
let _highlighter: HighlighterCore | null = null;
 | 
			
		||||
 | 
			
		||||
export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
 | 
			
		||||
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
 | 
			
		||||
| 
						 | 
				
			
			@ -51,16 +52,14 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise
 | 
			
		|||
	return darkPlus;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getHighlighter(): Promise<Highlighter> {
 | 
			
		||||
export async function getHighlighter(): Promise<HighlighterCore> {
 | 
			
		||||
	if (!_highlighter) {
 | 
			
		||||
		return await initHighlighter();
 | 
			
		||||
	}
 | 
			
		||||
	return _highlighter;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function initHighlighter() {
 | 
			
		||||
	const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');
 | 
			
		||||
 | 
			
		||||
async function initHighlighter() {
 | 
			
		||||
	await loadWasm(import('shiki/onig.wasm?init'));
 | 
			
		||||
 | 
			
		||||
	// テーマの重複を消す
 | 
			
		||||
| 
						 | 
				
			
			@ -69,11 +68,12 @@ export async function initHighlighter() {
 | 
			
		|||
		...(await Promise.all([getTheme('light'), getTheme('dark')])),
 | 
			
		||||
	]);
 | 
			
		||||
 | 
			
		||||
	const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
 | 
			
		||||
	const highlighter = await getHighlighterCore({
 | 
			
		||||
		themes,
 | 
			
		||||
		langs: [
 | 
			
		||||
			import('shiki/langs/javascript.mjs'),
 | 
			
		||||
			aiScriptGrammar.default as unknown as LanguageRegistration,
 | 
			
		||||
			...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
 | 
			
		||||
			async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
 | 
			
		||||
		],
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@
 | 
			
		|||
import { ref } from 'vue';
 | 
			
		||||
import tinycolor from 'tinycolor2';
 | 
			
		||||
import { deepClone } from './clone.js';
 | 
			
		||||
import type { BuiltinTheme } from 'shiki';
 | 
			
		||||
import type { BundledTheme } from 'shiki/themes';
 | 
			
		||||
import { globalEvents } from '@/events.js';
 | 
			
		||||
import lightTheme from '@/themes/_light.json5';
 | 
			
		||||
import darkTheme from '@/themes/_dark.json5';
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +20,7 @@ export type Theme = {
 | 
			
		|||
	base?: 'dark' | 'light';
 | 
			
		||||
	props: Record<string, string>;
 | 
			
		||||
	codeHighlighter?: {
 | 
			
		||||
		base: BuiltinTheme;
 | 
			
		||||
		base: BundledTheme;
 | 
			
		||||
		overrides?: Record<string, any>;
 | 
			
		||||
	} | {
 | 
			
		||||
		base: '_none_';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,6 @@ import { markRaw, ref } from 'vue';
 | 
			
		|||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import { miLocalStorage } from './local-storage.js';
 | 
			
		||||
import type { SoundType } from '@/scripts/sound.js';
 | 
			
		||||
import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki';
 | 
			
		||||
import { Storage } from '@/pizzax.js';
 | 
			
		||||
import { hemisphere } from '@/scripts/intl-const.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,12 @@ import { markRaw } from 'vue';
 | 
			
		|||
import { $i } from '@/account.js';
 | 
			
		||||
import { wsOrigin } from '@/config.js';
 | 
			
		||||
 | 
			
		||||
// heart beat interval in ms
 | 
			
		||||
const HEART_BEAT_INTERVAL = 1000 * 60;
 | 
			
		||||
 | 
			
		||||
let stream: Misskey.Stream | null = null;
 | 
			
		||||
let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null;
 | 
			
		||||
let lastHeartbeatCall = 0;
 | 
			
		||||
 | 
			
		||||
export function useStream(): Misskey.Stream {
 | 
			
		||||
	if (stream) return stream;
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +22,18 @@ export function useStream(): Misskey.Stream {
 | 
			
		|||
		token: $i.token,
 | 
			
		||||
	} : null));
 | 
			
		||||
 | 
			
		||||
	window.setTimeout(heartbeat, 1000 * 60);
 | 
			
		||||
	if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
 | 
			
		||||
	timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
 | 
			
		||||
 | 
			
		||||
	// send heartbeat right now when last send time is over HEART_BEAT_INTERVAL
 | 
			
		||||
	document.addEventListener('visibilitychange', () => {
 | 
			
		||||
		if (
 | 
			
		||||
			!stream
 | 
			
		||||
			|| document.visibilityState !== 'visible'
 | 
			
		||||
			|| Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL
 | 
			
		||||
		) return;
 | 
			
		||||
		heartbeat();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return stream;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -26,5 +42,7 @@ function heartbeat(): void {
 | 
			
		|||
	if (stream != null && document.visibilityState === 'visible') {
 | 
			
		||||
		stream.heartbeat();
 | 
			
		||||
	}
 | 
			
		||||
	window.setTimeout(heartbeat, 1000 * 60);
 | 
			
		||||
	lastHeartbeatCall = Date.now();
 | 
			
		||||
	if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
 | 
			
		||||
	timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -465,12 +465,13 @@ rt {
 | 
			
		|||
	border-radius: 10px;
 | 
			
		||||
 | 
			
		||||
	--bg: #F1E8DC;
 | 
			
		||||
	--panel: #fff;
 | 
			
		||||
	--fg: #693410;
 | 
			
		||||
	--switchOffBg: rgba(0, 0, 0, 0.1);
 | 
			
		||||
	--switchOffFg: rgb(255, 255, 255);
 | 
			
		||||
	--switchOnBg: var(--accent);
 | 
			
		||||
	--switchOnFg: rgb(255, 255, 255);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html[data-color-mode=dark] ._woodenFrame {
 | 
			
		||||
	--bg: #1d0c02;
 | 
			
		||||
	--fg: #F1E8DC;
 | 
			
		||||
	--panel: #192320;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
._woodenFrameH {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,11 +5,30 @@ import { type UserConfig, defineConfig } from 'vite';
 | 
			
		|||
 | 
			
		||||
import locales from '../../locales/index.js';
 | 
			
		||||
import meta from '../../package.json';
 | 
			
		||||
import packageInfo from './package.json' assert { type: 'json' };
 | 
			
		||||
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
 | 
			
		||||
import pluginJson5 from './vite.json5.js';
 | 
			
		||||
 | 
			
		||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。
 | 
			
		||||
 * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK
 | 
			
		||||
 */
 | 
			
		||||
const externalPackages = [
 | 
			
		||||
	// shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む
 | 
			
		||||
	{
 | 
			
		||||
		name: 'shiki',
 | 
			
		||||
		match: /^shiki\/(?<subPkg>(langs|themes))$/,
 | 
			
		||||
		path(id: string, pattern: RegExp): string {
 | 
			
		||||
			const match = pattern.exec(id)?.groups;
 | 
			
		||||
			return match
 | 
			
		||||
				? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}`
 | 
			
		||||
				: id;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const hash = (str: string, seed = 0): number => {
 | 
			
		||||
	let h1 = 0xdeadbeef ^ seed,
 | 
			
		||||
		h2 = 0x41c6ce57 ^ seed;
 | 
			
		||||
| 
						 | 
				
			
			@ -110,6 +129,7 @@ export function getConfig(): UserConfig {
 | 
			
		|||
				input: {
 | 
			
		||||
					app: './src/_boot_.ts',
 | 
			
		||||
				},
 | 
			
		||||
				external: externalPackages.map(p => p.match),
 | 
			
		||||
				output: {
 | 
			
		||||
					manualChunks: {
 | 
			
		||||
						vue: ['vue'],
 | 
			
		||||
| 
						 | 
				
			
			@ -117,6 +137,15 @@ export function getConfig(): UserConfig {
 | 
			
		|||
					},
 | 
			
		||||
					chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
 | 
			
		||||
					assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
 | 
			
		||||
					paths(id) {
 | 
			
		||||
						for (const p of externalPackages) {
 | 
			
		||||
							if (p.match.test(id)) {
 | 
			
		||||
								return p.path(id, p.match);
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						return id;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			cssCodeSplit: true,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -98,6 +98,8 @@ async function generateEndpoints(
 | 
			
		|||
 | 
			
		||||
	const entitiesOutputLine: string[] = [];
 | 
			
		||||
 | 
			
		||||
	entitiesOutputLine.push('/* eslint @typescript-eslint/naming-convention: 0 */');
 | 
			
		||||
 | 
			
		||||
	entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`);
 | 
			
		||||
	entitiesOutputLine.push('');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4577,6 +4577,8 @@ export type components = {
 | 
			
		|||
      localOnly: boolean;
 | 
			
		||||
      notify: boolean;
 | 
			
		||||
      /** @default false */
 | 
			
		||||
      excludeBots: boolean;
 | 
			
		||||
      /** @default false */
 | 
			
		||||
      withReplies: boolean;
 | 
			
		||||
      withFile: boolean;
 | 
			
		||||
      isActive: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -4957,6 +4959,7 @@ export type components = {
 | 
			
		|||
      enableServiceWorker: boolean;
 | 
			
		||||
      translatorAvailable: boolean;
 | 
			
		||||
      mediaProxy: string;
 | 
			
		||||
      enableUrlPreview: boolean;
 | 
			
		||||
      backgroundImageUrl: string | null;
 | 
			
		||||
      impressumUrl: string | null;
 | 
			
		||||
      logoImageUrl: string | null;
 | 
			
		||||
| 
						 | 
				
			
			@ -5116,11 +5119,21 @@ export type operations = {
 | 
			
		|||
            objectStorageS3ForcePathStyle: boolean;
 | 
			
		||||
            privacyPolicyUrl: string | null;
 | 
			
		||||
            repositoryUrl: string | null;
 | 
			
		||||
            /**
 | 
			
		||||
             * @deprecated
 | 
			
		||||
             * @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead.
 | 
			
		||||
             */
 | 
			
		||||
            summalyProxy: string | null;
 | 
			
		||||
            themeColor: string | null;
 | 
			
		||||
            tosUrl: string | null;
 | 
			
		||||
            uri: string;
 | 
			
		||||
            version: string;
 | 
			
		||||
            urlPreviewEnabled: boolean;
 | 
			
		||||
            urlPreviewTimeout: number;
 | 
			
		||||
            urlPreviewMaximumContentLength: number;
 | 
			
		||||
            urlPreviewRequireContentLength: boolean;
 | 
			
		||||
            urlPreviewUserAgent: string | null;
 | 
			
		||||
            urlPreviewSummaryProxyUrl: string | null;
 | 
			
		||||
          };
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			@ -9280,7 +9293,6 @@ export type operations = {
 | 
			
		|||
          maintainerName?: string | null;
 | 
			
		||||
          maintainerEmail?: string | null;
 | 
			
		||||
          langs?: string[];
 | 
			
		||||
          summalyProxy?: string | null;
 | 
			
		||||
          deeplAuthKey?: string | null;
 | 
			
		||||
          deeplIsPro?: boolean;
 | 
			
		||||
          deeplFreeMode?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -9339,6 +9351,14 @@ export type operations = {
 | 
			
		|||
          perUserListTimelineCacheMax?: number;
 | 
			
		||||
          notesPerOneAd?: number;
 | 
			
		||||
          silencedHosts?: string[] | null;
 | 
			
		||||
          /** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */
 | 
			
		||||
          summalyProxy?: string | null;
 | 
			
		||||
          urlPreviewEnabled?: boolean;
 | 
			
		||||
          urlPreviewTimeout?: number;
 | 
			
		||||
          urlPreviewMaximumContentLength?: number;
 | 
			
		||||
          urlPreviewRequireContentLength?: boolean;
 | 
			
		||||
          urlPreviewUserAgent?: string | null;
 | 
			
		||||
          urlPreviewSummaryProxyUrl?: string | null;
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
| 
						 | 
				
			
			@ -10079,6 +10099,7 @@ export type operations = {
 | 
			
		|||
          users: string[];
 | 
			
		||||
          caseSensitive: boolean;
 | 
			
		||||
          localOnly?: boolean;
 | 
			
		||||
          excludeBots?: boolean;
 | 
			
		||||
          withReplies: boolean;
 | 
			
		||||
          withFile: boolean;
 | 
			
		||||
          notify: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -10360,6 +10381,7 @@ export type operations = {
 | 
			
		|||
          users?: string[];
 | 
			
		||||
          caseSensitive?: boolean;
 | 
			
		||||
          localOnly?: boolean;
 | 
			
		||||
          excludeBots?: boolean;
 | 
			
		||||
          withReplies?: boolean;
 | 
			
		||||
          withFile?: boolean;
 | 
			
		||||
          notify?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -23570,6 +23592,11 @@ export type operations = {
 | 
			
		|||
          summary: string;
 | 
			
		||||
          script: string;
 | 
			
		||||
          permissions: string[];
 | 
			
		||||
          /**
 | 
			
		||||
           * @default public
 | 
			
		||||
           * @enum {string}
 | 
			
		||||
           */
 | 
			
		||||
          visibility?: 'public' | 'private';
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1317
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										1317
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue