Merge branch 'develop' into provide-feed-of-subset-of-user-timeline
This commit is contained in:
		
						commit
						3cabbd5ac3
					
				|  | @ -8,7 +8,7 @@ | |||
| 			"version": "8.9.2" | ||||
| 		}, | ||||
| 		"ghcr.io/devcontainers/features/node:1": { | ||||
| 			"version": "20.5.1" | ||||
| 			"version": "20.10.0" | ||||
| 		} | ||||
| 	}, | ||||
| 	"forwardPorts": [3000], | ||||
|  |  | |||
|  | @ -6,37 +6,33 @@ on: | |||
|     branches: | ||||
|       - master | ||||
|       - develop | ||||
|     paths: | ||||
|       - packages/backend/** | ||||
|       - .github/workflows/get-api-diff.yml | ||||
| 
 | ||||
| jobs: | ||||
|   get-base: | ||||
|   get-from-misskey: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
| 
 | ||||
|     services: | ||||
|       db: | ||||
|         image: postgres:13 | ||||
|         ports: | ||||
|           - 5432:5432 | ||||
|         env: | ||||
|           POSTGRES_DB: misskey | ||||
|           POSTGRES_HOST_AUTH_METHOD: trust | ||||
|           POSTGRES_USER: example-misskey-user | ||||
|           POSTGRESS_PASS: example-misskey-pass | ||||
|       redis: | ||||
|         image: redis:7 | ||||
|         ports: | ||||
|           - 6379:6379 | ||||
|         node-version: [20.10.0] | ||||
|         api-json-name: [api-base.json, api-head.json] | ||||
|         include: | ||||
|           - api-json-name: api-base.json | ||||
|             repo-name: ${{ github.event.pull_request.base.repo.full_name }} | ||||
|             ref: ${{ github.base_ref }} | ||||
|           - api-json-name: api-head.json | ||||
|             repo-name: ${{ github.event.pull_request.head.repo.full_name }} | ||||
|             ref: ${{ github.head_ref }} | ||||
| 
 | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
|       with: | ||||
|         repository: ${{ github.event.pull_request.base.repo.full_name }} | ||||
|         ref: ${{ github.base_ref }} | ||||
|         repository: ${{ matrix.repo-name }} | ||||
|         ref: ${{ matrix.ref }} | ||||
|         submodules: true | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v2 | ||||
|  | @ -56,121 +52,15 @@ jobs: | |||
|       run: cp .config/example.yml .config/default.yml | ||||
|     - name: Build | ||||
|       run: pnpm build | ||||
|     - name : Migrate | ||||
|       run: pnpm migrate | ||||
|     - name: Launch misskey | ||||
|       run: | | ||||
|         screen -S misskey -dm pnpm run dev | ||||
|         sleep 30s | ||||
|     - name: Wait for Misskey to be ready | ||||
|       run: | | ||||
|         MAX_RETRIES=12 | ||||
|         RETRY_DELAY=5 | ||||
|         count=0 | ||||
|         until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do | ||||
|           printf '.' | ||||
|           sleep $RETRY_DELAY | ||||
|           count=$((count + 1)) | ||||
|         done | ||||
| 
 | ||||
|         if [[ $count -eq $MAX_RETRIES ]]; then | ||||
|           echo "Failed to connect to Misskey after $MAX_RETRIES attempts." | ||||
|           exit 1 | ||||
|         fi | ||||
|     - id: fetch | ||||
|       name: Get api.json from Misskey | ||||
|       run: | | ||||
|         RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json) | ||||
|         echo $RESULT > api-base.json | ||||
|     - name: Generate API JSON | ||||
|       run: pnpm --filter backend generate-api-json | ||||
|     - name: Copy API.json | ||||
|       run: cp packages/backend/built/api.json ${{ matrix.api-json-name }} | ||||
|     - name: Upload Artifact | ||||
|       uses: actions/upload-artifact@v3 | ||||
|       with: | ||||
|         name: api-artifact | ||||
|         path: api-base.json | ||||
|     - name: Kill Misskey Job | ||||
|       run: screen -S misskey -X quit | ||||
| 
 | ||||
|   get-head: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
| 
 | ||||
|     services: | ||||
|       db: | ||||
|         image: postgres:13 | ||||
|         ports: | ||||
|           - 5432:5432 | ||||
|         env: | ||||
|           POSTGRES_DB: misskey | ||||
|           POSTGRES_HOST_AUTH_METHOD: trust | ||||
|           POSTGRES_USER: example-misskey-user | ||||
|           POSTGRESS_PASS: example-misskey-pass | ||||
|       redis: | ||||
|         image: redis:7 | ||||
|         ports: | ||||
|           - 6379:6379 | ||||
| 
 | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
|       with: | ||||
|         repository: ${{ github.event.pull_request.head.repo.full_name }} | ||||
|         ref: ${{ github.head_ref }} | ||||
|         submodules: true | ||||
|     - name: Install pnpm | ||||
|       uses: pnpm/action-setup@v2 | ||||
|       with: | ||||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|     - run: corepack enable | ||||
|     - run: pnpm i --frozen-lockfile | ||||
|     - name: Check pnpm-lock.yaml | ||||
|       run: git diff --exit-code pnpm-lock.yaml | ||||
|     - name: Copy Configure | ||||
|       run: cp .config/example.yml .config/default.yml | ||||
|     - name: Build | ||||
|       run: pnpm build | ||||
|     - name : Migrate | ||||
|       run: pnpm migrate | ||||
|     - name: Launch misskey | ||||
|       run: | | ||||
|         screen -S misskey -dm pnpm run dev | ||||
|         sleep 30s | ||||
|     - name: Wait for Misskey to be ready | ||||
|       run: | | ||||
|         MAX_RETRIES=12 | ||||
|         RETRY_DELAY=5 | ||||
|         count=0 | ||||
|         until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do | ||||
|           printf '.' | ||||
|           sleep $RETRY_DELAY | ||||
|           count=$((count + 1)) | ||||
|         done | ||||
| 
 | ||||
|         if [[ $count -eq $MAX_RETRIES ]]; then | ||||
|           echo "Failed to connect to Misskey after $MAX_RETRIES attempts." | ||||
|           exit 1 | ||||
|         fi | ||||
|     - id: fetch | ||||
|       name: Get api.json from Misskey | ||||
|       run: | | ||||
|         RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json) | ||||
|         echo $RESULT > api-head.json | ||||
|     - name: Upload Artifact | ||||
|       uses: actions/upload-artifact@v3 | ||||
|       with: | ||||
|         name: api-artifact | ||||
|         path: api-head.json | ||||
|     - name: Kill Misskey Job | ||||
|       run: screen -S misskey -X quit | ||||
|         path: ${{ matrix.api-json-name }} | ||||
| 
 | ||||
|   save-pr-number: | ||||
|     runs-on: ubuntu-latest | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ jobs: | |||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
|         node-version: [20.10.0] | ||||
| 
 | ||||
|     services: | ||||
|       postgres: | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ jobs: | |||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
|         node-version: [20.10.0] | ||||
| 
 | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
|  | @ -51,7 +51,7 @@ jobs: | |||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
|         node-version: [20.10.0] | ||||
|         browser: [chrome] | ||||
| 
 | ||||
|     services: | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ jobs: | |||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
|         node-version: [20.10.0] | ||||
|         # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ | ||||
| 
 | ||||
|     steps: | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ jobs: | |||
| 
 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [20.5.1] | ||||
|         node-version: [20.10.0] | ||||
| 
 | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| 20.5.1 | ||||
| 20.10.0 | ||||
|  |  | |||
							
								
								
									
										11
									
								
								CHANGELOG.md
								
								
								
								
							
							
						
						
									
										11
									
								
								CHANGELOG.md
								
								
								
								
							|  | @ -17,13 +17,24 @@ | |||
| ### General | ||||
| - Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) | ||||
| - Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) | ||||
| - Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加 | ||||
| - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 | ||||
| 
 | ||||
| ### Client | ||||
| - Enhance: 絵文字のオートコンプリート機能強化 #12364 | ||||
| - Enhance: ユーザーのRawデータを表示するページが復活 | ||||
| - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 | ||||
| - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 | ||||
| - Fix: コードエディタが正しく表示されない問題を修正 | ||||
| - Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 | ||||
| 
 | ||||
| ### Server | ||||
| - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように | ||||
| - Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 | ||||
| - Fix: ロールタイムラインが保存されない問題を修正 | ||||
| - Fix: api.jsonの生成ロジックを改善 #12402 | ||||
| - Fix: 招待コードが使い回せる問題を修正 | ||||
| - Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 | ||||
| 
 | ||||
| ## 2023.11.1 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| # syntax = docker/dockerfile:1.4 | ||||
| 
 | ||||
| ARG NODE_VERSION=20.5.1-bullseye | ||||
| ARG NODE_VERSION=20.10.0-bullseye | ||||
| 
 | ||||
| # build assets & compile TypeScript | ||||
| 
 | ||||
|  |  | |||
|  | @ -642,6 +642,7 @@ export interface Locale { | |||
|     "smtpSecureInfo": string; | ||||
|     "testEmail": string; | ||||
|     "wordMute": string; | ||||
|     "hardWordMute": string; | ||||
|     "regexpError": string; | ||||
|     "regexpErrorDescription": string; | ||||
|     "instanceMute": string; | ||||
|  | @ -1039,6 +1040,7 @@ export interface Locale { | |||
|     "enableChartsForFederatedInstances": string; | ||||
|     "showClipButtonInNoteFooter": string; | ||||
|     "reactionsDisplaySize": string; | ||||
|     "limitWidthOfReaction": string; | ||||
|     "noteIdOrUrl": string; | ||||
|     "video": string; | ||||
|     "videos": string; | ||||
|  | @ -1641,7 +1643,9 @@ export interface Locale { | |||
|         "assignTarget": string; | ||||
|         "descriptionOfAssignTarget": string; | ||||
|         "manual": string; | ||||
|         "manualRoles": string; | ||||
|         "conditional": string; | ||||
|         "conditionalRoles": string; | ||||
|         "condition": string; | ||||
|         "isConditionalRole": string; | ||||
|         "isPublic": string; | ||||
|  |  | |||
|  | @ -639,6 +639,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" | |||
| smtpSecureInfo: "STARTTLS使用時はオフにします。" | ||||
| testEmail: "配信テスト" | ||||
| wordMute: "ワードミュート" | ||||
| hardWordMute: "ハードワードミュート" | ||||
| regexpError: "正規表現エラー" | ||||
| regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" | ||||
| instanceMute: "サーバーミュート" | ||||
|  | @ -1036,6 +1037,7 @@ enableChartsForRemoteUser: "リモートユーザーのチャートを生成" | |||
| enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" | ||||
| showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" | ||||
| reactionsDisplaySize: "リアクションの表示サイズ" | ||||
| limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" | ||||
| noteIdOrUrl: "ノートIDまたはURL" | ||||
| video: "動画" | ||||
| videos: "動画" | ||||
|  | @ -1551,7 +1553,9 @@ _role: | |||
|   assignTarget: "アサイン" | ||||
|   descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。" | ||||
|   manual: "マニュアル" | ||||
|   manualRoles: "マニュアルロール" | ||||
|   conditional: "コンディショナル" | ||||
|   conditionalRoles: "コンディショナルロール" | ||||
|   condition: "条件" | ||||
|   isConditionalRole: "これはコンディショナルロールです。" | ||||
|   isPublic: "公開ロール" | ||||
|  | @ -1855,7 +1859,7 @@ _ago: | |||
|   weeksAgo: "{n}週間前" | ||||
|   monthsAgo: "{n}ヶ月前" | ||||
|   yearsAgo: "{n}年前" | ||||
|   invalid: "ありません" | ||||
|   invalid: "日時の解析に失敗" | ||||
| 
 | ||||
| _timeIn: | ||||
|   seconds: "{n}秒後" | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| import { loadConfig } from './built/config.js' | ||||
| import { genOpenapiSpec } from './built/server/api/openapi/gen-spec.js' | ||||
| import { writeFileSync } from "node:fs"; | ||||
| 
 | ||||
| const config = loadConfig(); | ||||
| const spec = genOpenapiSpec(config); | ||||
| 
 | ||||
| writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8'); | ||||
|  | @ -0,0 +1,11 @@ | |||
| export class HardMute1700383825690 { | ||||
|     name = 'HardMute1700383825690' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "user_profile" ADD "hardMutedWords" jsonb NOT NULL DEFAULT '[]'`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hardMutedWords"`); | ||||
|     } | ||||
| } | ||||
|  | @ -23,7 +23,8 @@ | |||
| 		"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", | ||||
| 		"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", | ||||
| 		"test": "pnpm jest", | ||||
| 		"test-and-coverage": "pnpm jest-and-coverage" | ||||
| 		"test-and-coverage": "pnpm jest-and-coverage", | ||||
| 		"generate-api-json": "node ./generate_api_json.js" | ||||
| 	}, | ||||
| 	"optionalDependencies": { | ||||
| 		"@swc/core-android-arm64": "1.3.11", | ||||
|  |  | |||
|  | @ -250,6 +250,12 @@ export class MfmService { | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		function fnDefault(node: mfm.MfmFn) { | ||||
| 			const el = doc.createElement('i'); | ||||
| 			appendChildren(node.children, el); | ||||
| 			return el; | ||||
| 		} | ||||
| 
 | ||||
| 		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = { | ||||
| 			bold: (node) => { | ||||
| 				const el = doc.createElement('b'); | ||||
|  | @ -276,17 +282,68 @@ export class MfmService { | |||
| 			}, | ||||
| 
 | ||||
| 			fn: (node) => { | ||||
| 				if (node.props.name === 'unixtime') { | ||||
| 					const text = node.children[0]!.type === 'text' ? node.children[0].props.text : ''; | ||||
| 					const date = new Date(parseInt(text, 10) * 1000); | ||||
| 					const el = doc.createElement('time'); | ||||
| 					el.setAttribute('datetime', date.toISOString()); | ||||
| 					el.textContent = date.toISOString(); | ||||
| 					return el; | ||||
| 				} else { | ||||
| 					const el = doc.createElement('i'); | ||||
| 					appendChildren(node.children, el); | ||||
| 					return el; | ||||
| 				switch (node.props.name) { | ||||
| 					case 'unixtime': { | ||||
| 						const text = node.children[0].type === 'text' ? node.children[0].props.text : ''; | ||||
| 						try { | ||||
| 							const date = new Date(parseInt(text, 10) * 1000); | ||||
| 							const el = doc.createElement('time'); | ||||
| 							el.setAttribute('datetime', date.toISOString()); | ||||
| 							el.textContent = date.toISOString(); | ||||
| 							return el; | ||||
| 						} catch (err) { | ||||
| 							return fnDefault(node); | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					case 'ruby': { | ||||
| 						if (node.children.length === 1) { | ||||
| 							const child = node.children[0]; | ||||
| 							const text = child.type === 'text' ? child.props.text : ''; | ||||
| 							const rubyEl = doc.createElement('ruby'); | ||||
| 							const rtEl = doc.createElement('rt'); | ||||
| 
 | ||||
| 							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
 | ||||
| 							const rpStartEl = doc.createElement('rp'); | ||||
| 							rpStartEl.appendChild(doc.createTextNode('(')); | ||||
| 							const rpEndEl = doc.createElement('rp'); | ||||
| 							rpEndEl.appendChild(doc.createTextNode(')')); | ||||
| 
 | ||||
| 							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0])); | ||||
| 							rtEl.appendChild(doc.createTextNode(text.split(' ')[1])); | ||||
| 							rubyEl.appendChild(rpStartEl); | ||||
| 							rubyEl.appendChild(rtEl); | ||||
| 							rubyEl.appendChild(rpEndEl); | ||||
| 							return rubyEl; | ||||
| 						} else { | ||||
| 							const rt = node.children.at(-1); | ||||
| 
 | ||||
| 							if (!rt) { | ||||
| 								return fnDefault(node); | ||||
| 							} | ||||
| 
 | ||||
| 							const text = rt.type === 'text' ? rt.props.text : ''; | ||||
| 							const rubyEl = doc.createElement('ruby'); | ||||
| 							const rtEl = doc.createElement('rt'); | ||||
| 
 | ||||
| 							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
 | ||||
| 							const rpStartEl = doc.createElement('rp'); | ||||
| 							rpStartEl.appendChild(doc.createTextNode('(')); | ||||
| 							const rpEndEl = doc.createElement('rp'); | ||||
| 							rpEndEl.appendChild(doc.createTextNode(')')); | ||||
| 
 | ||||
| 							appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); | ||||
| 							rtEl.appendChild(doc.createTextNode(text.trim())); | ||||
| 							rubyEl.appendChild(rpStartEl); | ||||
| 							rubyEl.appendChild(rtEl); | ||||
| 							rubyEl.appendChild(rpEndEl); | ||||
| 							return rubyEl; | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					default: { | ||||
| 						return fnDefault(node); | ||||
| 					} | ||||
| 				} | ||||
| 			}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -473,6 +473,7 @@ export class UserEntityService implements OnModuleInit { | |||
| 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), | ||||
| 				unreadNotificationsCount: notificationsInfo?.unreadCount, | ||||
| 				mutedWords: profile!.mutedWords, | ||||
| 				hardMutedWords: profile!.hardMutedWords, | ||||
| 				mutedInstances: profile!.mutedInstances, | ||||
| 				mutingNotificationTypes: [], // 後方互換性のため
 | ||||
| 				notificationRecieveConfig: profile!.notificationRecieveConfig, | ||||
|  |  | |||
|  | @ -215,7 +215,12 @@ export class MiUserProfile { | |||
| 	@Column('jsonb', { | ||||
| 		default: [], | ||||
| 	}) | ||||
| 	public mutedWords: string[][]; | ||||
| 	public mutedWords: (string[] | string)[]; | ||||
| 
 | ||||
| 	@Column('jsonb', { | ||||
| 		default: [], | ||||
| 	}) | ||||
| 	public hardMutedWords: (string[] | string)[]; | ||||
| 
 | ||||
| 	@Column('jsonb', { | ||||
| 		default: [], | ||||
|  |  | |||
|  | @ -530,6 +530,18 @@ export const packedMeDetailedOnlySchema = { | |||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		hardMutedWords: { | ||||
| 			type: 'array', | ||||
| 			nullable: false, optional: false, | ||||
| 			items: { | ||||
| 				type: 'array', | ||||
| 				nullable: false, optional: false, | ||||
| 				items: { | ||||
| 					type: 'string', | ||||
| 					nullable: false, optional: false, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		mutedInstances: { | ||||
| 			type: 'array', | ||||
| 			nullable: true, optional: false, | ||||
|  |  | |||
|  | @ -126,7 +126,7 @@ export class SignupApiService { | |||
| 				code: invitationCode, | ||||
| 			}); | ||||
| 
 | ||||
| 			if (ticket == null) { | ||||
| 			if (ticket == null || ticket.usedById != null) { | ||||
| 				reply.code(400); | ||||
| 				return; | ||||
| 			} | ||||
|  |  | |||
|  | @ -22,7 +22,7 @@ export const paramDef = { | |||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		publishing: { type: 'boolean', default: false }, | ||||
| 		publishing: { type: 'boolean', default: null, nullable: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
|  | @ -37,8 +37,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); | ||||
| 			if (ps.publishing) { | ||||
| 			if (ps.publishing === true) { | ||||
| 				query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() }); | ||||
| 			} else if (ps.publishing === false) { | ||||
| 				query.andWhere('ad.expiresAt <= :now', { now: new Date() }).orWhere('ad.startsAt > :now', { now: new Date() }); | ||||
| 			} | ||||
| 			const ads = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -267,6 +267,14 @@ export const meta = { | |||
| 				type: 'boolean', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			enableVerifymailApi: { | ||||
| 				type: 'boolean', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 			verifymailAuthKey: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 			enableChartsForRemoteUser: { | ||||
| 				type: 'boolean', | ||||
| 				optional: false, nullable: false, | ||||
|  | @ -421,6 +429,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				deeplIsPro: instance.deeplIsPro, | ||||
| 				enableIpLogging: instance.enableIpLogging, | ||||
| 				enableActiveEmailValidation: instance.enableActiveEmailValidation, | ||||
| 				enableVerifymailApi: instance.enableVerifymailApi, | ||||
| 				verifymailAuthKey: instance.verifymailAuthKey, | ||||
| 				enableChartsForRemoteUser: instance.enableChartsForRemoteUser, | ||||
| 				enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, | ||||
| 				enableServerMachineStats: instance.enableServerMachineStats, | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; | |||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
|  | @ -72,12 +73,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private funoutTimelineService: FunoutTimelineService, | ||||
| 		private cacheService: CacheService, | ||||
| 		private activeUsersChart: ActiveUsersChart, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); | ||||
| 			const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); | ||||
| 			const isRangeSpecified = untilId != null && sinceId != null; | ||||
| 
 | ||||
| 			const serverSettings = await this.metaService.fetch(); | ||||
| 
 | ||||
| 			const channel = await this.channelsRepository.findOneBy({ | ||||
| 				id: ps.channelId, | ||||
| 			}); | ||||
|  | @ -88,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 
 | ||||
| 			if (me) this.activeUsersChart.read(me); | ||||
| 
 | ||||
| 			if (isRangeSpecified || sinceId == null) { | ||||
| 			if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { | ||||
| 				const [ | ||||
| 					userIdsWhoMeMuting, | ||||
| 				] = me ? await Promise.all([ | ||||
|  |  | |||
|  | @ -16,12 +16,9 @@ export const meta = { | |||
| 	requireCredential: false, | ||||
| 
 | ||||
| 	res: { | ||||
| 		oneOf: [{ | ||||
| 			type: 'object', | ||||
| 			ref: 'FederationInstance', | ||||
| 		}, { | ||||
| 			type: 'null', | ||||
| 		}], | ||||
| 		type: 'object', | ||||
| 		optional: false, nullable: true, | ||||
| 		ref: 'FederationInstance', | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
|  |  | |||
|  | @ -123,6 +123,11 @@ export const meta = { | |||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| const muteWords = { type: 'array', items: { oneOf: [ | ||||
| 	{ type: 'array', items: { type: 'string' } }, | ||||
| 	{ type: 'string' } | ||||
| ] } } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
|  | @ -171,7 +176,8 @@ export const paramDef = { | |||
| 		autoSensitive: { type: 'boolean' }, | ||||
| 		ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, | ||||
| 		pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 		mutedWords: { type: 'array' }, | ||||
| 		mutedWords: muteWords, | ||||
| 		hardMutedWords: muteWords, | ||||
| 		mutedInstances: { type: 'array', items: { | ||||
| 			type: 'string', | ||||
| 		} }, | ||||
|  | @ -234,16 +240,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 			if (ps.location !== undefined) profileUpdates.location = ps.location; | ||||
| 			if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; | ||||
| 			if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; | ||||
| 			if (ps.mutedWords !== undefined) { | ||||
| 
 | ||||
| 			function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { | ||||
| 				// TODO: ちゃんと数える
 | ||||
| 				const length = JSON.stringify(ps.mutedWords).length; | ||||
| 				if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) { | ||||
| 				const length = JSON.stringify(mutedWords).length; | ||||
| 				if (length > limit) { | ||||
| 					throw new ApiError(meta.errors.tooManyMutedWords); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 				// validate regular expression syntax
 | ||||
| 				ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { | ||||
| 					const regexp = x.match(/^\/(.+)\/(.*)$/); | ||||
| 			function validateMuteWordRegex(mutedWords: (string[] | string)[]) { | ||||
| 				for (const mutedWord of mutedWords) { | ||||
| 					if (typeof mutedWord !== "string") continue; | ||||
| 
 | ||||
| 					const regexp = mutedWord.match(/^\/(.+)\/(.*)$/); | ||||
| 					if (!regexp) throw new ApiError(meta.errors.invalidRegexp); | ||||
| 
 | ||||
| 					try { | ||||
|  | @ -251,11 +261,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 					} catch (err) { | ||||
| 						throw new ApiError(meta.errors.invalidRegexp); | ||||
| 					} | ||||
| 				}); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if (ps.mutedWords !== undefined) { | ||||
| 				checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); | ||||
| 				validateMuteWordRegex(ps.mutedWords); | ||||
| 
 | ||||
| 				profileUpdates.mutedWords = ps.mutedWords; | ||||
| 				profileUpdates.enableWordMute = ps.mutedWords.length > 0; | ||||
| 			} | ||||
| 			if (ps.hardMutedWords !== undefined) { | ||||
| 				checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); | ||||
| 				validateMuteWordRegex(ps.hardMutedWords); | ||||
| 				profileUpdates.hardMutedWords = ps.hardMutedWords; | ||||
| 			} | ||||
| 			if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; | ||||
| 			if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; | ||||
| 			if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; | ||||
|  |  | |||
|  | @ -262,7 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				if (renote.channelId && renote.channelId !== ps.channelId) { | ||||
| 					// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
 | ||||
| 					// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
 | ||||
| 					const renoteChannel = await this.channelsRepository.findOneById(renote.channelId); | ||||
| 					const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); | ||||
| 					if (renoteChannel == null) { | ||||
| 						// リノートしたいノートが書き込まれているチャンネルが無い
 | ||||
| 						throw new ApiError(meta.errors.noSuchChannel); | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; | |||
| import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
|  | @ -71,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		private cacheService: CacheService, | ||||
| 		private idService: IdService, | ||||
| 		private funoutTimelineService: FunoutTimelineService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); | ||||
|  | @ -78,7 +80,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 			const isRangeSpecified = untilId != null && sinceId != null; | ||||
| 			const isSelf = me && (me.id === ps.userId); | ||||
| 
 | ||||
| 			if (isRangeSpecified || sinceId == null) { | ||||
| 			const serverSettings = await this.metaService.fetch(); | ||||
| 
 | ||||
| 			if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { | ||||
| 				const [ | ||||
| 					userIdsWhoMeMuting, | ||||
| 				] = me ? await Promise.all([ | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ | |||
|  */ | ||||
| 
 | ||||
| import type { Config } from '@/config.js'; | ||||
| import endpoints from '../endpoints.js'; | ||||
| import endpoints, { IEndpoint } from '../endpoints.js'; | ||||
| import { errors as basicErrors } from './errors.js'; | ||||
| import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; | ||||
| 
 | ||||
|  | @ -33,16 +33,17 @@ export function genOpenapiSpec(config: Config) { | |||
| 			schemas: schemas, | ||||
| 
 | ||||
| 			securitySchemes: { | ||||
| 				ApiKeyAuth: { | ||||
| 					type: 'apiKey', | ||||
| 					in: 'body', | ||||
| 					name: 'i', | ||||
| 				bearerAuth: { | ||||
| 					type: 'http', | ||||
| 					scheme: 'bearer', | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}; | ||||
| 
 | ||||
| 	for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { | ||||
| 	// 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する
 | ||||
| 	const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[]; | ||||
| 	for (const endpoint of copiedEndpoints.filter(ep => !ep.meta.secure)) { | ||||
| 		const errors = {} as any; | ||||
| 
 | ||||
| 		if (endpoint.meta.errors) { | ||||
|  | @ -79,6 +80,13 @@ export function genOpenapiSpec(config: Config) { | |||
| 			schema.required = [...schema.required ?? [], 'file']; | ||||
| 		} | ||||
| 
 | ||||
| 		if (schema.required && schema.required.length <= 0) { | ||||
| 			// 空配列は許可されない
 | ||||
| 			schema.required = undefined; | ||||
| 		} | ||||
| 
 | ||||
| 		const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); | ||||
| 
 | ||||
| 		const info = { | ||||
| 			operationId: endpoint.name, | ||||
| 			summary: endpoint.name, | ||||
|  | @ -92,17 +100,19 @@ export function genOpenapiSpec(config: Config) { | |||
| 			} : {}), | ||||
| 			...(endpoint.meta.requireCredential ? { | ||||
| 				security: [{ | ||||
| 					ApiKeyAuth: [], | ||||
| 					bearerAuth: [], | ||||
| 				}], | ||||
| 			} : {}), | ||||
| 			requestBody: { | ||||
| 				required: true, | ||||
| 				content: { | ||||
| 					[requestType]: { | ||||
| 						schema, | ||||
| 			...(hasBody ? { | ||||
| 				requestBody: { | ||||
| 					required: true, | ||||
| 					content: { | ||||
| 						[requestType]: { | ||||
| 							schema, | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			} : {}), | ||||
| 			responses: { | ||||
| 				...(endpoint.meta.res ? { | ||||
| 					'200': { | ||||
|  | @ -118,6 +128,11 @@ export function genOpenapiSpec(config: Config) { | |||
| 						description: 'OK (without any results)', | ||||
| 					}, | ||||
| 				}), | ||||
| 				...(endpoint.meta.res?.optional === true || endpoint.meta.res?.nullable === true ? { | ||||
| 					'204': { | ||||
| 						description: 'OK (without any results)', | ||||
| 					}, | ||||
| 				} : {}), | ||||
| 				'400': { | ||||
| 					description: 'Client error', | ||||
| 					content: { | ||||
|  | @ -190,6 +205,7 @@ export function genOpenapiSpec(config: Config) { | |||
| 		}; | ||||
| 
 | ||||
| 		spec.paths['/' + endpoint.name] = { | ||||
| 			...(endpoint.meta.allowGet ? { get: info } : {}), | ||||
| 			post: info, | ||||
| 		}; | ||||
| 	} | ||||
|  |  | |||
|  | @ -7,10 +7,16 @@ import type { Schema } from '@/misc/json-schema.js'; | |||
| import { refs } from '@/misc/json-schema.js'; | ||||
| 
 | ||||
| export function convertSchemaToOpenApiSchema(schema: Schema) { | ||||
| 	const res: any = schema; | ||||
| 	// optional, refはスキーマ定義に含まれないので分離しておく
 | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
| 	const { optional, ref, ...res }: any = schema; | ||||
| 
 | ||||
| 	if (schema.type === 'object' && schema.properties) { | ||||
| 		res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); | ||||
| 		const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); | ||||
| 		if (required.length > 0) { | ||||
| 			// 空配列は許可されない
 | ||||
| 			res.required = required; | ||||
| 		} | ||||
| 
 | ||||
| 		for (const k of Object.keys(schema.properties)) { | ||||
| 			res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); | ||||
|  |  | |||
|  | @ -168,6 +168,7 @@ describe('ユーザー', () => { | |||
| 			hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, | ||||
| 			unreadAnnouncements: user.unreadAnnouncements, | ||||
| 			mutedWords: user.mutedWords, | ||||
| 			hardMutedWords: user.hardMutedWords, | ||||
| 			mutedInstances: user.mutedInstances, | ||||
| 			mutingNotificationTypes: user.mutingNotificationTypes, | ||||
| 			notificationRecieveConfig: user.notificationRecieveConfig, | ||||
|  |  | |||
|  | @ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			<Mfm :text="report.comment"/> | ||||
| 		</div> | ||||
| 		<hr/> | ||||
| 		<div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div> | ||||
| 		<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div> | ||||
| 		<div v-if="report.assignee"> | ||||
| 			{{ i18n.ts.moderator }}: | ||||
| 			<MkAcct :user="report.assignee"/> | ||||
|  |  | |||
|  | @ -242,29 +242,7 @@ function exec() { | |||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const matched: EmojiDef[] = []; | ||||
| 		const max = 30; | ||||
| 
 | ||||
| 		emojiDb.value.some(x => { | ||||
| 			if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x); | ||||
| 			return matched.length === max; | ||||
| 		}); | ||||
| 
 | ||||
| 		if (matched.length < max) { | ||||
| 			emojiDb.value.some(x => { | ||||
| 				if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); | ||||
| 				return matched.length === max; | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		if (matched.length < max) { | ||||
| 			emojiDb.value.some(x => { | ||||
| 				if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x); | ||||
| 				return matched.length === max; | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		emojis.value = matched; | ||||
| 		emojis.value = emojiAutoComplete(props.q, emojiDb.value); | ||||
| 	} else if (props.type === 'mfmTag') { | ||||
| 		if (!props.q || props.q === '') { | ||||
| 			mfmTags.value = MFM_TAGS; | ||||
|  | @ -275,6 +253,78 @@ function exec() { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| type EmojiScore = { emoji: EmojiDef, score: number }; | ||||
| 
 | ||||
| function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { | ||||
| 	if (!query) { | ||||
| 		return []; | ||||
| 	} | ||||
| 
 | ||||
| 	const matched = new Map<string, EmojiScore>(); | ||||
| 
 | ||||
| 	// 前方一致(エイリアスなし) | ||||
| 	emojiDb.some(x => { | ||||
| 		if (x.name.startsWith(query) && !x.aliasOf) { | ||||
| 			matched.set(x.name, { emoji: x, score: query.length + 1 }); | ||||
| 		} | ||||
| 		return matched.size === max; | ||||
| 	}); | ||||
| 
 | ||||
| 	// 前方一致(エイリアス込み) | ||||
| 	if (matched.size < max) { | ||||
| 		emojiDb.some(x => { | ||||
| 			if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) { | ||||
| 				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length }); | ||||
| 			} | ||||
| 			return matched.size === max; | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	// 部分一致(エイリアス込み) | ||||
| 	if (matched.size < max) { | ||||
| 		emojiDb.some(x => { | ||||
| 			if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) { | ||||
| 				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 }); | ||||
| 			} | ||||
| 			return matched.size === max; | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	// 簡易あいまい検索(3文字以上) | ||||
| 	if (matched.size < max && query.length > 3) { | ||||
| 		const queryChars = [...query]; | ||||
| 		const hitEmojis = new Map<string, EmojiScore>(); | ||||
| 
 | ||||
| 		for (const x of emojiDb) { | ||||
| 			// 文字列の位置を進めながら、クエリの文字を順番に探す | ||||
| 
 | ||||
| 			let pos = 0; | ||||
| 			let hit = 0; | ||||
| 			for (const c of queryChars) { | ||||
| 				pos = x.name.indexOf(c, pos); | ||||
| 				if (pos <= -1) break; | ||||
| 				hit++; | ||||
| 			} | ||||
| 
 | ||||
| 			// 半分以上の文字が含まれていればヒットとする | ||||
| 			if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) { | ||||
| 				hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 }); | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) | ||||
| 		[...hitEmojis.values()] | ||||
| 			.sort((x, y) => y.score - x.score) | ||||
| 			.slice(0, 6) | ||||
| 			.forEach(it => matched.set(it.emoji.name, it)); | ||||
| 	} | ||||
| 
 | ||||
| 	return [...matched.values()] | ||||
| 		.sort((x, y) => y.score - x.score) | ||||
| 		.slice(0, max) | ||||
| 		.map(it => it.emoji); | ||||
| } | ||||
| 
 | ||||
| function onMousedown(event: Event) { | ||||
| 	if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); | ||||
| } | ||||
|  |  | |||
|  | @ -139,6 +139,10 @@ watch(v, () => { | |||
| 	height: 100%; | ||||
| } | ||||
| 
 | ||||
| .textarea, .codeEditorHighlighter { | ||||
| 	margin: 0; | ||||
| } | ||||
| 
 | ||||
| .textarea { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
|  | @ -154,6 +158,8 @@ watch(v, () => { | |||
| 	background-color: transparent; | ||||
| 	border: 0; | ||||
| 	outline: 0; | ||||
| 	min-width: calc(100% - 24px); | ||||
| 	height: calc(100% - 24px); | ||||
| 	padding: 12px; | ||||
| 	line-height: 1.5em; | ||||
| 	font-size: 1em; | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <template> | ||||
| <div | ||||
| 	v-if="!muted" | ||||
| 	v-if="!hardMuted && !muted" | ||||
| 	v-show="!isDeleted" | ||||
| 	ref="el" | ||||
| 	v-hotkey="keymap" | ||||
|  | @ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		</div> | ||||
| 	</article> | ||||
| </div> | ||||
| <div v-else :class="$style.muted" @click="muted = false"> | ||||
| <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> | ||||
| 	<I18n :src="i18n.ts.userSaysSomething" tag="small"> | ||||
| 		<template #name> | ||||
| 			<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> | ||||
|  | @ -183,6 +183,7 @@ const props = withDefaults(defineProps<{ | |||
| 	note: Misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| 	mock?: boolean; | ||||
| 	withHardMute?: boolean; | ||||
| }>(), { | ||||
| 	mock: false, | ||||
| }); | ||||
|  | @ -239,13 +240,23 @@ const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null); | |||
| const isLong = shouldCollapsed(appearNote, urls ?? []); | ||||
| const collapsed = ref(appearNote.cw == null && isLong); | ||||
| const isDeleted = ref(false); | ||||
| const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); | ||||
| const muted = ref(checkMute(appearNote, $i?.mutedWords)); | ||||
| const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords)); | ||||
| const translation = ref<any>(null); | ||||
| const translating = ref(false); | ||||
| const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); | ||||
| const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); | ||||
| let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); | ||||
| 
 | ||||
| function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { | ||||
| 	if (mutedWords == null) return false; | ||||
| 
 | ||||
| 	if (checkWordMute(note, $i, mutedWords)) return true; | ||||
| 	if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; | ||||
| 	if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; | ||||
| 	return false; | ||||
| } | ||||
| 
 | ||||
| const keymap = { | ||||
| 	'r': () => reply(true), | ||||
| 	'e|a|plus': () => react(true), | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				:ad="true" | ||||
| 				:class="$style.notes" | ||||
| 			> | ||||
| 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | ||||
| 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> | ||||
| 			</MkDateSeparatedList> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| 		<template #default="{ items: notifications }"> | ||||
| 			<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> | ||||
| 				<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||
| 				<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> | ||||
| 				<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> | ||||
| 			</MkDateSeparatedList> | ||||
| 		</template> | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 	:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" | ||||
| 	@click="toggleReaction()" | ||||
| > | ||||
| 	<MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> | ||||
| 	<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> | ||||
| 	<span :class="$style.count">{{ count }}</span> | ||||
| </button> | ||||
| </template> | ||||
|  | @ -188,7 +188,7 @@ if (!mock) { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| .icon { | ||||
| .limitWidth { | ||||
| 	max-width: 150px; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<iframe | ||||
| 			ref="tweet" | ||||
| 			allow="fullscreen;web-share" | ||||
| 			sandbox="allow-popups allow-scripts allow-same-origin" | ||||
| 			sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin" | ||||
| 			scrolling="no" | ||||
| 			:style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }" | ||||
| 			:src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`" | ||||
|  |  | |||
|  | @ -28,12 +28,25 @@ const props = withDefaults(defineProps<{ | |||
| 	mode: 'relative', | ||||
| }); | ||||
| 
 | ||||
| const _time = props.time == null ? NaN : | ||||
| 	typeof props.time === 'number' ? props.time : | ||||
| 	(props.time instanceof Date ? props.time : new Date(props.time)).getTime(); | ||||
| function getDateSafe(n: Date | string | number) { | ||||
| 	try { | ||||
| 		if (n instanceof Date) { | ||||
| 			return n; | ||||
| 		} | ||||
| 		return new Date(n); | ||||
| 	} catch (err) { | ||||
| 		return { | ||||
| 			getTime: () => NaN, | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // eslint-disable-next-line vue/no-setup-props-destructure | ||||
| const _time = props.time == null ? NaN : getDateSafe(props.time).getTime(); | ||||
| const invalid = Number.isNaN(_time); | ||||
| const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; | ||||
| 
 | ||||
| // eslint-disable-next-line vue/no-setup-props-destructure | ||||
| let now = $ref((props.origin ?? new Date()).getTime()); | ||||
| const ago = $computed(() => (now - _time) / 1000/*ms*/); | ||||
| 
 | ||||
|  |  | |||
|  | @ -65,9 +65,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 							<img src="https://avatars.githubusercontent.com/u/67428053?v=4" :class="$style.contributorAvatar"> | ||||
| 							<span :class="$style.contributorUsername">@kakkokari-gtyih</span> | ||||
| 						</a> | ||||
| 						<a href="https://github.com/taichanNE30" target="_blank" :class="$style.contributor"> | ||||
| 						<a href="https://github.com/tai-cha" target="_blank" :class="$style.contributor"> | ||||
| 							<img src="https://avatars.githubusercontent.com/u/40626578?v=4" :class="$style.contributorAvatar"> | ||||
| 							<span :class="$style.contributorUsername">@taichanNE30</span> | ||||
| 							<span :class="$style.contributorUsername">@tai-cha</span> | ||||
| 						</a> | ||||
| 					</div> | ||||
| 				</FormSection> | ||||
|  |  | |||
|  | @ -9,12 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<XHeader :actions="headerActions" :tabs="headerTabs"/> | ||||
| 	</template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<MkSwitch :modelValue="publishing" @update:modelValue="onChangePublishing"> | ||||
| 			{{ i18n.ts.publishing }} | ||||
| 		</MkSwitch> | ||||
| 		<MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems"> | ||||
| 			<template #label>{{ i18n.ts.state }}</template> | ||||
| 			<option value="all">{{ i18n.ts.all }}</option> | ||||
| 			<option value="publishing">{{ i18n.ts.publishing }}</option> | ||||
| 			<option value="expired">{{ i18n.ts.expired }}</option> | ||||
| 		</MkSelect> | ||||
| 		<div> | ||||
| 			<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad"> | ||||
| 				<MkAd v-if="ad.url" :specify="ad"/> | ||||
| 				<MkAd v-if="ad.url" :key="ad.id" :specify="ad"/> | ||||
| 				<MkInput v-model="ad.url" type="url"> | ||||
| 					<template #label>URL</template> | ||||
| 				</MkInput> | ||||
|  | @ -82,14 +85,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import { ref } from 'vue'; | ||||
| import XHeader from './_header_.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkSelect from '@/components/MkSelect.vue'; | ||||
| import FormSplit from '@/components/form/split.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | @ -101,24 +104,34 @@ let ads: any[] = $ref([]); | |||
| const localTime = new Date(); | ||||
| const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; | ||||
| const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; | ||||
| let publishing = false; | ||||
| const filterType = ref('all'); | ||||
| let publishing: boolean | null = null; | ||||
| 
 | ||||
| os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => { | ||||
| 	ads = adsResponse.map(r => { | ||||
| 		const exdate = new Date(r.expiresAt); | ||||
| 		const stdate = new Date(r.startsAt); | ||||
| 		exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); | ||||
| 		stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); | ||||
| 		return { | ||||
| 			...r, | ||||
| 			expiresAt: exdate.toISOString().slice(0, 16), | ||||
| 			startsAt: stdate.toISOString().slice(0, 16), | ||||
| 		}; | ||||
| 	}); | ||||
| 	if (adsResponse != null) { | ||||
| 		ads = adsResponse.map(r => { | ||||
| 			const exdate = new Date(r.expiresAt); | ||||
| 			const stdate = new Date(r.startsAt); | ||||
| 			exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); | ||||
| 			stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); | ||||
| 			return { | ||||
| 				...r, | ||||
| 				expiresAt: exdate.toISOString().slice(0, 16), | ||||
| 				startsAt: stdate.toISOString().slice(0, 16), | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| const onChangePublishing = (v) => { | ||||
| 	publishing = v; | ||||
| const filterItems = (v) => { | ||||
| 	if (v === 'publishing') { | ||||
| 		publishing = true; | ||||
| 	} else if (v === 'expired') { | ||||
| 		publishing = false; | ||||
| 	} else { | ||||
| 		publishing = null; | ||||
| 	} | ||||
| 
 | ||||
| 	refresh(); | ||||
| }; | ||||
| 
 | ||||
|  | @ -197,6 +210,7 @@ function save(ad) { | |||
| 
 | ||||
| function more() { | ||||
| 	os.api('admin/ad/list', { untilId: ads.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => { | ||||
| 		if (adsResponse == null) return; | ||||
| 		ads = ads.concat(adsResponse.map(r => { | ||||
| 			const exdate = new Date(r.expiresAt); | ||||
| 			const stdate = new Date(r.startsAt); | ||||
|  | @ -213,6 +227,7 @@ function more() { | |||
| 
 | ||||
| function refresh() { | ||||
| 	os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => { | ||||
| 		if (adsResponse == null) return; | ||||
| 		ads = adsResponse.map(r => { | ||||
| 			const exdate = new Date(r.expiresAt); | ||||
| 			const stdate = new Date(r.startsAt); | ||||
|  | @ -252,4 +267,7 @@ definePageMetadata({ | |||
| 		margin-bottom: var(--margin); | ||||
| 	} | ||||
| } | ||||
| .input { | ||||
| 	margin-bottom: 32px; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -198,13 +198,13 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> | ||||
| 				<div class="_gaps_s"> | ||||
| 					<MkFoldableSection> | ||||
| 						<template #header>Manual roles</template> | ||||
| 						<template #header>{{ i18n.ts._role.manualRoles }}</template> | ||||
| 						<div class="_gaps_s"> | ||||
| 							<MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/> | ||||
| 						</div> | ||||
| 					</MkFoldableSection> | ||||
| 					<MkFoldableSection> | ||||
| 						<template #header>Conditional roles</template> | ||||
| 						<template #header>{{ i18n.ts._role.conditionalRoles }}</template> | ||||
| 						<div class="_gaps_s"> | ||||
| 							<MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/> | ||||
| 						</div> | ||||
|  |  | |||
|  | @ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 					<option value="medium">{{ i18n.ts.medium }}</option> | ||||
| 					<option value="large">{{ i18n.ts.large }}</option> | ||||
| 				</MkRadios> | ||||
| 				<MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch> | ||||
| 			</div> | ||||
| 
 | ||||
| 			<MkSelect v-model="instanceTicker"> | ||||
|  | @ -226,6 +227,7 @@ const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serve | |||
| const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); | ||||
| const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); | ||||
| const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize')); | ||||
| const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction')); | ||||
| const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); | ||||
| const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); | ||||
| const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); | ||||
|  | @ -290,6 +292,7 @@ watch([ | |||
| 	overridedDeviceKind, | ||||
| 	mediaListWithOneImageAppearance, | ||||
| 	reactionsDisplaySize, | ||||
| 	limitWidthOfReaction, | ||||
| 	highlightSensitiveMedia, | ||||
| 	keepScreenOn, | ||||
| 	disableStreamingTimeline, | ||||
|  |  | |||
|  | @ -9,7 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<template #icon><i class="ti ti-message-off"></i></template> | ||||
| 		<template #label>{{ i18n.ts.wordMute }}</template> | ||||
| 
 | ||||
| 		<XWordMute/> | ||||
| 		<XWordMute :muted="$i!.mutedWords" @save="saveMutedWords"/> | ||||
| 	</MkFolder> | ||||
| 
 | ||||
| 	<MkFolder> | ||||
| 		<template #icon><i class="ti ti-message-off"></i></template> | ||||
| 		<template #label>{{ i18n.ts.hardWordMute }}</template> | ||||
| 
 | ||||
| 		<XWordMute :muted="$i!.hardMutedWords" @save="saveHardMutedWords"/> | ||||
| 	</MkFolder> | ||||
| 
 | ||||
| 	<MkFolder> | ||||
|  | @ -129,6 +136,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; | |||
| import MkUserCardMini from '@/components/MkUserCardMini.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { infoImageUrl } from '@/instance.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| 
 | ||||
| const renoteMutingPagination = { | ||||
|  | @ -207,6 +215,14 @@ async function toggleBlockItem(item) { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| async function saveMutedWords(mutedWords: (string | string[])[]) { | ||||
| 	await os.api('i/update', { mutedWords }); | ||||
| } | ||||
| 
 | ||||
| async function saveHardMutedWords(hardMutedWords: (string | string[])[]) { | ||||
| 	await os.api('i/update', { hardMutedWords }); | ||||
| } | ||||
| 
 | ||||
| const headerActions = $computed(() => []); | ||||
| 
 | ||||
| const headerTabs = $computed(() => []); | ||||
|  |  | |||
|  | @ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| <script lang="ts" setup> | ||||
| import { ref, watch } from 'vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkKeyValue from '@/components/MkKeyValue.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import MkTab from '@/components/MkTab.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import number from '@/filters/number.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	muted: (string[] | string)[]; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'save', value: (string[] | string)[]): void; | ||||
| }>(); | ||||
| 
 | ||||
| const render = (mutedWords) => mutedWords.map(x => { | ||||
| 	if (Array.isArray(x)) { | ||||
|  | @ -37,8 +38,7 @@ const render = (mutedWords) => mutedWords.map(x => { | |||
| 	} | ||||
| }).join('\n'); | ||||
| 
 | ||||
| const tab = ref('soft'); | ||||
| const mutedWords = ref(render($i!.mutedWords)); | ||||
| const mutedWords = ref(render(props.muted)); | ||||
| const changed = ref(false); | ||||
| 
 | ||||
| watch(mutedWords, () => { | ||||
|  | @ -85,9 +85,7 @@ async function save() { | |||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	await os.api('i/update', { | ||||
| 		mutedWords: parsed, | ||||
| 	}); | ||||
| 	emit('save', parsed); | ||||
| 
 | ||||
| 	changed.value = false; | ||||
| } | ||||
|  |  | |||
|  | @ -11,10 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<MkLoading v-if="fetching"/> | ||||
| 		<div v-if="!fetching && files.length > 0" :class="$style.stream"> | ||||
| 			<template v-for="file in files" :key="file.note.id + file.file.id"> | ||||
| 				<div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.sensitive" @click="showingFiles.push(file.file.id)"> | ||||
| 					<div> | ||||
| 						<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div> | ||||
| 						<div>{{ i18n.ts.clickToShow }}</div> | ||||
| 				<div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.img" @click="showingFiles.push(file.file.id)"> | ||||
| 					<!-- TODO: 画像以外のファイルに対応 --> | ||||
| 					<ImgWithBlurhash :class="$style.sensitiveImg" :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name" :forceBlurhash="true"/> | ||||
| 					<div :class="$style.sensitive"> | ||||
| 						<div> | ||||
| 							<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div> | ||||
| 							<div>{{ i18n.ts.clickToShow }}</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<MkA v-else :class="$style.img" :to="notePage(file.note)"> | ||||
|  | @ -88,6 +92,7 @@ onMounted(() => { | |||
| } | ||||
| 
 | ||||
| .img { | ||||
| 	position: relative; | ||||
| 	height: 128px; | ||||
| 	border-radius: 6px; | ||||
| 	overflow: clip; | ||||
|  | @ -99,8 +104,24 @@ onMounted(() => { | |||
| 	text-align: center; | ||||
| } | ||||
| 
 | ||||
| .sensitiveImg { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	filter: brightness(0.7); | ||||
| } | ||||
| .sensitive { | ||||
| 	position: absolute; | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	display: grid; | ||||
|   place-items: center; | ||||
| 	font-size: 0.8em; | ||||
| 	color: #fff; | ||||
| 	cursor: pointer; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			<XPages v-else-if="tab === 'pages'" :user="user"/> | ||||
| 			<XFlashs v-else-if="tab === 'flashs'" :user="user"/> | ||||
| 			<XGallery v-else-if="tab === 'gallery'" :user="user"/> | ||||
| 			<XRaw v-else-if="tab === 'raw'" :user="user"/> | ||||
| 		</div> | ||||
| 		<MkError v-else-if="error" @retry="fetchUser()"/> | ||||
| 		<MkLoading v-else/> | ||||
|  | @ -44,6 +45,7 @@ const XLists = defineAsyncComponent(() => import('./lists.vue')); | |||
| const XPages = defineAsyncComponent(() => import('./pages.vue')); | ||||
| const XFlashs = defineAsyncComponent(() => import('./flashs.vue')); | ||||
| const XGallery = defineAsyncComponent(() => import('./gallery.vue')); | ||||
| const XRaw = defineAsyncComponent(() => import('./raw.vue')); | ||||
| 
 | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	acct: string; | ||||
|  | @ -112,6 +114,10 @@ const headerTabs = $computed(() => user ? [{ | |||
| 	key: 'gallery', | ||||
| 	title: i18n.ts.gallery, | ||||
| 	icon: 'ti ti-icons', | ||||
| }, { | ||||
| 	key: 'raw', | ||||
| 	title: 'Raw', | ||||
| 	icon: 'ti ti-code', | ||||
| }] : []); | ||||
| 
 | ||||
| definePageMetadata(computed(() => user ? { | ||||
|  |  | |||
|  | @ -0,0 +1,130 @@ | |||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> | ||||
| 	<div class="_gaps_m"> | ||||
| 		<div :class="$style.userMInfoRoot"> | ||||
| 			<MkAvatar :class="$style.userMInfoAvatar" :user="user" indicator link preview/> | ||||
| 			<div :class="$style.userMInfoMetaRoot"> | ||||
| 				<span :class="$style.userMInfoMetaName"><MkUserName :class="$style.userMInfoMetaName" :user="user"/></span> | ||||
| 				<span :class="$style.userMInfoMetaSub"><span class="acct _monospace">@{{ acct(user) }}</span></span> | ||||
| 				<span :class="$style.userMInfoMetaState"> | ||||
| 					<span v-if="suspended" :class="$style.suspended">Suspended</span> | ||||
| 					<span v-if="silenced" :class="$style.silenced">Silenced</span> | ||||
| 					<span v-if="moderator" :class="$style.moderator">Moderator</span> | ||||
| 				</span> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div style="display: flex; flex-direction: column; gap: 1em;"> | ||||
| 			<MkKeyValue :copy="user.id" oneline> | ||||
| 				<template #key>ID</template> | ||||
| 				<template #value><span class="_monospace">{{ user.id }}</span></template> | ||||
| 			</MkKeyValue> | ||||
| 			<MkKeyValue oneline> | ||||
| 				<template #key>{{ i18n.ts.createdAt }}</template> | ||||
| 				<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> | ||||
| 			</MkKeyValue> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<FormSection> | ||||
| 			<template #label>Raw</template> | ||||
| 			<MkObjectView tall :value="user"></MkObjectView> | ||||
| 		</FormSection> | ||||
| 	</div> | ||||
| </MkSpacer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { acct } from '@/filters/user.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import MkKeyValue from '@/components/MkKeyValue.vue'; | ||||
| import FormSection from '@/components/form/section.vue'; | ||||
| import MkObjectView from '@/components/MkObjectView.vue'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	user: Misskey.entities.User; | ||||
| }>(); | ||||
| 
 | ||||
| const moderator = computed(() => props.user.isModerator ?? false); | ||||
| const silenced = computed(() => props.user.isSilenced ?? false); | ||||
| const suspended = computed(() => props.user.isSuspended ?? false); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
| .userMInfoRoot { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| } | ||||
| 
 | ||||
| .userMInfoAvatar { | ||||
| 	display: block; | ||||
| 	width: 64px; | ||||
| 	height: 64px; | ||||
| 	margin-right: 16px; | ||||
| } | ||||
| 
 | ||||
| .userMInfoMetaRoot { | ||||
| 	flex: 1; | ||||
| 	overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .userMInfoMetaName { | ||||
| 	display: block; | ||||
| 	width: 100%; | ||||
| 	white-space: nowrap; | ||||
| 	overflow: hidden; | ||||
| 	text-overflow: ellipsis; | ||||
| } | ||||
| 
 | ||||
| .userMInfoMetaSub { | ||||
| 	display: block; | ||||
| 	width: 100%; | ||||
| 	font-size: 85%; | ||||
| 	opacity: 0.7; | ||||
| 	white-space: nowrap; | ||||
| 	overflow: hidden; | ||||
| 	text-overflow: ellipsis; | ||||
| } | ||||
| 
 | ||||
| .userMInfoMetaState { | ||||
| 	display: flex; | ||||
| 	gap: 8px; | ||||
| 	flex-wrap: wrap; | ||||
| 	margin-top: 4px; | ||||
| 
 | ||||
| 	&:empty { | ||||
| 		display: none; | ||||
| 	} | ||||
| 
 | ||||
| 	> .suspended, | ||||
| 	> .silenced, | ||||
| 	> .moderator { | ||||
| 		display: inline-block; | ||||
| 		border: solid 1px; | ||||
| 		border-radius: 6px; | ||||
| 		padding: 2px 6px; | ||||
| 		font-size: 85%; | ||||
| 	} | ||||
| 
 | ||||
| 	> .suspended { | ||||
| 		color: var(--error); | ||||
| 		border-color: var(--error); | ||||
| 	} | ||||
| 
 | ||||
| 	> .silenced { | ||||
| 		color: var(--warn); | ||||
| 		border-color: var(--warn); | ||||
| 	} | ||||
| 
 | ||||
| 	> .moderator { | ||||
| 		color: var(--success); | ||||
| 		border-color: var(--success); | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -61,7 +61,7 @@ export const soundsTypes = [ | |||
| 	'noizenecio/kick_gaba7', | ||||
| ] as const; | ||||
| 
 | ||||
| export async function getAudio(file: string, useCache = true) { | ||||
| export async function loadAudio(file: string, useCache = true) { | ||||
| 	if (useCache && cache.has(file)) { | ||||
| 		return cache.get(file)!; | ||||
| 	} | ||||
|  | @ -77,12 +77,6 @@ export async function getAudio(file: string, useCache = true) { | |||
| 	return audioBuffer; | ||||
| } | ||||
| 
 | ||||
| export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { | ||||
| 	const masterVolume = defaultStore.state.sound_masterVolume; | ||||
| 	audio.volume = masterVolume - ((1 - volume) * masterVolume); | ||||
| 	return audio; | ||||
| } | ||||
| 
 | ||||
| export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') { | ||||
| 	const sound = defaultStore.state[`sound_${type}`]; | ||||
| 	if (_DEV_) console.log('play', type, sound); | ||||
|  | @ -91,16 +85,22 @@ export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notifica | |||
| } | ||||
| 
 | ||||
| export async function playFile(file: string, volume: number) { | ||||
| 	const buffer = await loadAudio(file); | ||||
| 	createSourceNode(buffer, volume)?.start(); | ||||
| } | ||||
| 
 | ||||
| export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { | ||||
| 	const masterVolume = defaultStore.state.sound_masterVolume; | ||||
| 	if (masterVolume === 0 || volume === 0) { | ||||
| 		return; | ||||
| 		return null; | ||||
| 	} | ||||
| 
 | ||||
| 	const gainNode = ctx.createGain(); | ||||
| 	gainNode.gain.value = masterVolume * volume; | ||||
| 
 | ||||
| 	const soundSource = ctx.createBufferSource(); | ||||
| 	soundSource.buffer = await getAudio(file); | ||||
| 	soundSource.buffer = buffer; | ||||
| 	soundSource.connect(gainNode).connect(ctx.destination); | ||||
| 	soundSource.start(); | ||||
| 
 | ||||
| 	return soundSource; | ||||
| } | ||||
|  |  | |||
|  | @ -330,6 +330,10 @@ export const defaultStore = markRaw(new Storage('base', { | |||
| 		where: 'device', | ||||
| 		default: 'medium' as 'small' | 'medium' | 'large', | ||||
| 	}, | ||||
| 	limitWidthOfReaction: { | ||||
| 		where: 'device', | ||||
| 		default: true, | ||||
| 	}, | ||||
| 	forceShowAds: { | ||||
| 		where: 'device', | ||||
| 		default: false, | ||||
|  |  | |||
|  | @ -99,7 +99,10 @@ const current = reactive({ | |||
| 	}, | ||||
| }); | ||||
| const prev = reactive({} as typeof current); | ||||
| const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1); | ||||
| let jammedAudioBuffer: AudioBuffer | null = $ref(null); | ||||
| let jammedSoundNodePlaying: boolean = $ref(false); | ||||
| 
 | ||||
| sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf); | ||||
| 
 | ||||
| for (const domain of ['inbox', 'deliver']) { | ||||
| 	prev[domain] = deepClone(current[domain]); | ||||
|  | @ -113,8 +116,13 @@ const onStats = (stats) => { | |||
| 		current[domain].waiting = stats[domain].waiting; | ||||
| 		current[domain].delayed = stats[domain].delayed; | ||||
| 
 | ||||
| 		if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) { | ||||
| 			jammedSound.play(); | ||||
| 		if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer && !jammedSoundNodePlaying) { | ||||
| 			const soundNode = sound.createSourceNode(jammedAudioBuffer, 1); | ||||
| 			if (soundNode) { | ||||
| 				jammedSoundNodePlaying = true; | ||||
| 				soundNode.onended = () => jammedSoundNodePlaying = false; | ||||
| 				soundNode.start(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  |  | |||
|  | @ -150,7 +150,7 @@ describe('MkUrlPreview', () => { | |||
| 		}); | ||||
| 		assert.exists(iframe, 'iframe should exist'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('allow'), 'fullscreen;web-share'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('sandbox'), 'allow-popups allow-scripts allow-same-origin'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('sandbox'), 'allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin'); | ||||
| 	}); | ||||
| 
 | ||||
| 	test('Loading a post in iframe', async () => { | ||||
|  | @ -159,6 +159,6 @@ describe('MkUrlPreview', () => { | |||
| 		}); | ||||
| 		assert.exists(iframe, 'iframe should exist'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('allow'), 'fullscreen;web-share'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('sandbox'), 'allow-popups allow-scripts allow-same-origin'); | ||||
| 		assert.strictEqual(iframe?.getAttribute('sandbox'), 'allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin'); | ||||
| 	}); | ||||
| }); | ||||
|  |  | |||
|  | @ -1565,7 +1565,8 @@ export type Endpoints = { | |||
|             injectFeaturedNote?: boolean; | ||||
|             receiveAnnouncementEmail?: boolean; | ||||
|             alwaysMarkNsfw?: boolean; | ||||
|             mutedWords?: string[][]; | ||||
|             mutedWords?: (string[] | string)[]; | ||||
|             hardMutedWords?: (string[] | string)[]; | ||||
|             notificationRecieveConfig?: any; | ||||
|             emailNotificationTypes?: string[]; | ||||
|             alsoKnownAs?: string[]; | ||||
|  | @ -2516,7 +2517,8 @@ type MeDetailed = UserDetailed & { | |||
|     integrations: Record<string, any>; | ||||
|     isDeleted: boolean; | ||||
|     isExplorable: boolean; | ||||
|     mutedWords: string[][]; | ||||
|     mutedWords: (string[] | string)[]; | ||||
|     hardMutedWords: (string[] | string)[]; | ||||
|     notificationRecieveConfig: { | ||||
|         [notificationType in typeof notificationTypes_2[number]]?: { | ||||
|             type: 'all'; | ||||
|  | @ -3053,9 +3055,9 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u | |||
| // | ||||
| // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts | ||||
| // src/api.types.ts:20:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts | ||||
| // src/api.types.ts:634:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts | ||||
| // src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts | ||||
| // src/entities.ts:627:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts | ||||
| // src/api.types.ts:635:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts | ||||
| // src/entities.ts:117:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts | ||||
| // src/entities.ts:628:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts | ||||
| // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts | ||||
| 
 | ||||
| // (No @packageDocumentation comment for this package) | ||||
|  |  | |||
|  | @ -432,7 +432,8 @@ export type Endpoints = { | |||
| 		injectFeaturedNote?: boolean; | ||||
| 		receiveAnnouncementEmail?: boolean; | ||||
| 		alwaysMarkNsfw?: boolean; | ||||
| 		mutedWords?: string[][]; | ||||
| 		mutedWords?: (string[] | string)[]; | ||||
| 		hardMutedWords?: (string[] | string)[]; | ||||
| 		notificationRecieveConfig?: any; | ||||
| 		emailNotificationTypes?: string[]; | ||||
| 		alsoKnownAs?: string[]; | ||||
|  |  | |||
|  | @ -112,7 +112,8 @@ export type MeDetailed = UserDetailed & { | |||
| 	integrations: Record<string, any>; | ||||
| 	isDeleted: boolean; | ||||
| 	isExplorable: boolean; | ||||
| 	mutedWords: string[][]; | ||||
| 	mutedWords: (string[] | string)[]; | ||||
| 	hardMutedWords: (string[] | string)[]; | ||||
| 	notificationRecieveConfig: { | ||||
| 		[notificationType in typeof notificationTypes[number]]?: { | ||||
| 			type: 'all'; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue