Compare commits
	
		
			20 Commits
		
	
	
		
			featured-s
			...
			frontend-d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 9cd7ea77ff | ||
|   | 74c93fcebe | ||
|   | 8be624aa44 | ||
|   | 3fe7e37f10 | ||
|   | 7fe3035059 | ||
|   | 06855f769f | ||
|   | 3e85052754 | ||
|   | b6fdd71957 | ||
|   | 36dff66883 | ||
|   | 255c8bd1b9 | ||
|   | 44f62160cb | ||
|   | 8032a4e12a | ||
|   | 2f009f7d49 | ||
|   | f85aa7b641 | ||
|   | 1008fa32a0 | ||
|   | 043ab1f69b | ||
|   | 21a3095eb0 | ||
|   | 1b5f0571f7 | ||
|   | 59e83605ac | ||
|   | 130ff361c3 | 
							
								
								
									
										3
									
								
								.github/workflows/lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/lint.yml
									
									
									
									
										vendored
									
									
								
							| @@ -78,6 +78,7 @@ jobs: | ||||
|       matrix: | ||||
|         workspace: | ||||
|         - backend | ||||
|         - sw | ||||
|         - misskey-js | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4.1.1 | ||||
| @@ -92,7 +93,7 @@ jobs: | ||||
|     - run: corepack enable | ||||
|     - run: pnpm i --frozen-lockfile | ||||
|     - run: pnpm --filter misskey-js run build | ||||
|       if: ${{ matrix.workspace == 'backend' }} | ||||
|       if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} | ||||
|     - run: pnpm --filter misskey-reversi run build | ||||
|       if: ${{ matrix.workspace == 'backend' }} | ||||
|     - run: pnpm --filter ${{ matrix.workspace }} run typecheck | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -44,6 +44,7 @@ compose.yml | ||||
| /build | ||||
| built | ||||
| built-test | ||||
| js-built | ||||
| /data | ||||
| /.cache-loader | ||||
| /db | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| ## Unreleased | ||||
|  | ||||
| ### General | ||||
| - Enhance: ハイライトからセンシティブなメディアを含むノートを除外するオプション | ||||
| - | ||||
|  | ||||
| ### Client | ||||
| - | ||||
| - サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように | ||||
| - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 | ||||
|  | ||||
| ### Server | ||||
| - | ||||
| - ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 | ||||
|  | ||||
|  | ||||
| ## 2024.8.0 | ||||
|   | ||||
							
								
								
									
										4
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -5068,6 +5068,10 @@ export interface Locale extends ILocale { | ||||
|      * 作成したアンテナ | ||||
|      */ | ||||
|     "createdAntennas": string; | ||||
|     /** | ||||
|      * これ以上このクリップにノートを追加できません。 | ||||
|      */ | ||||
|     "clipNoteLimitExceeded": string; | ||||
|     "_delivery": { | ||||
|         /** | ||||
|          * 配信状態 | ||||
|   | ||||
| @@ -1263,6 +1263,7 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 | ||||
| sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" | ||||
| createdLists: "作成したリスト" | ||||
| createdAntennas: "作成したアンテナ" | ||||
| clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" | ||||
|  | ||||
| _delivery: | ||||
|   status: "配信状態" | ||||
|   | ||||
| @@ -133,7 +133,7 @@ export type Config = { | ||||
| 	proxySmtp: string | undefined; | ||||
| 	proxyBypassHosts: string[] | undefined; | ||||
| 	allowedPrivateNetworks: string[] | undefined; | ||||
| 	maxFileSize: number | undefined; | ||||
| 	maxFileSize: number; | ||||
| 	clusterLimit: number | undefined; | ||||
| 	id: string; | ||||
| 	outgoingAddress: string | undefined; | ||||
| @@ -250,7 +250,7 @@ export function loadConfig(): Config { | ||||
| 		proxySmtp: config.proxySmtp, | ||||
| 		proxyBypassHosts: config.proxyBypassHosts, | ||||
| 		allowedPrivateNetworks: config.allowedPrivateNetworks, | ||||
| 		maxFileSize: config.maxFileSize, | ||||
| 		maxFileSize: config.maxFileSize ?? 262144000, | ||||
| 		clusterLimit: config.clusterLimit, | ||||
| 		outgoingAddress: config.outgoingAddress, | ||||
| 		outgoingAddressFamily: config.outgoingAddressFamily, | ||||
|   | ||||
| @@ -42,7 +42,7 @@ export class DownloadService { | ||||
|  | ||||
| 		const timeout = 30 * 1000; | ||||
| 		const operationTimeout = 60 * 1000; | ||||
| 		const maxSize = this.config.maxFileSize ?? 262144000; | ||||
| 		const maxSize = this.config.maxFileSize; | ||||
|  | ||||
| 		const urlObj = new URL(url); | ||||
| 		let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; | ||||
|   | ||||
| @@ -65,21 +65,21 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followeeHost)') | ||||
| 				.where('following.followeeHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.getRawOne() | ||||
| 				.then(x => parseInt(x.count, 10)), | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followerHost)') | ||||
| 				.where('following.followerHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.getRawOne() | ||||
| 				.then(x => parseInt(x.count, 10)), | ||||
| 			this.followingsRepository.createQueryBuilder('following') | ||||
| 				.select('COUNT(DISTINCT following.followeeHost)') | ||||
| 				.where('following.followeeHost IS NOT NULL') | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) | ||||
| 				.setParameters(pubsubSubQuery.getParameters()) | ||||
| @@ -88,7 +88,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.instancesRepository.createQueryBuilder('instance') | ||||
| 				.select('COUNT(instance.id)') | ||||
| 				.where(`instance.host IN (${ subInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere('instance.suspensionState = \'none\'') | ||||
| 				.andWhere('instance.isNotResponding = false') | ||||
| 				.getRawOne() | ||||
| @@ -96,7 +96,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di | ||||
| 			this.instancesRepository.createQueryBuilder('instance') | ||||
| 				.select('COUNT(instance.id)') | ||||
| 				.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) | ||||
| 				.andWhere('instance.suspensionState = \'none\'') | ||||
| 				.andWhere('instance.isNotResponding = false') | ||||
| 				.getRawOne() | ||||
|   | ||||
| @@ -129,6 +129,7 @@ export class MetaEntityService { | ||||
| 			mediaProxy: this.config.mediaProxy, | ||||
| 			enableUrlPreview: instance.urlPreviewEnabled, | ||||
| 			noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', | ||||
| 			maxFileSize: this.config.maxFileSize, | ||||
| 		}; | ||||
|  | ||||
| 		return packed; | ||||
|   | ||||
| @@ -144,7 +144,9 @@ export interface Schema extends OfSchema { | ||||
| 	readonly type?: TypeStringef; | ||||
| 	readonly nullable?: boolean; | ||||
| 	readonly optional?: boolean; | ||||
| 	readonly prefixItems?: ReadonlyArray<Schema>; | ||||
| 	readonly items?: Schema; | ||||
| 	readonly unevaluatedItems?: Schema | boolean; | ||||
| 	readonly properties?: Obj; | ||||
| 	readonly required?: ReadonlyArray<Extract<keyof NonNullable<this['properties']>, string>>; | ||||
| 	readonly description?: string; | ||||
| @@ -198,6 +200,7 @@ type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X | ||||
| //type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never; | ||||
| type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never; | ||||
| type ArrayUnion<T> = T extends any ? Array<T> : never; | ||||
| type ArrayToTuple<X extends ReadonlyArray<Schema>> = { [K in keyof X]: SchemaType<X[K]> }; | ||||
|  | ||||
| type ObjectSchemaTypeDef<p extends Schema> = | ||||
| 	p['ref'] extends keyof typeof refs ? Packed<p['ref']> : | ||||
| @@ -232,6 +235,12 @@ export type SchemaTypeDef<p extends Schema> = | ||||
| 			p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] : | ||||
| 			never | ||||
| 		) : | ||||
| 		p['prefixItems'] extends ReadonlyArray<Schema> ? ( | ||||
| 			p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] : | ||||
| 			p['items'] extends false ? ArrayToTuple<p['prefixItems']> : | ||||
| 			p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> : | ||||
| 			[...ArrayToTuple<p['prefixItems']>, ...unknown[]] | ||||
| 		) : | ||||
| 		p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] : | ||||
| 		any[] | ||||
| 	) : | ||||
|   | ||||
| @@ -85,7 +85,7 @@ export type MiNotification = { | ||||
| 	/** | ||||
| 	 * アプリ通知のbody | ||||
| 	 */ | ||||
| 	customBody: string | null; | ||||
| 	customBody: string; | ||||
|  | ||||
| 	/** | ||||
| 	 * アプリ通知のheader | ||||
|   | ||||
| @@ -253,6 +253,10 @@ export const packedMetaLiteSchema = { | ||||
| 			optional: false, nullable: false, | ||||
| 			default: 'local', | ||||
| 		}, | ||||
| 		maxFileSize: { | ||||
| 			type: 'number', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; | ||||
| import { notificationTypes } from '@/types.js'; | ||||
|  | ||||
| const baseSchema = { | ||||
| @@ -294,6 +295,7 @@ export const packedNotificationSchema = { | ||||
| 			achievement: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				enum: ACHIEVEMENT_TYPES, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, { | ||||
| @@ -311,11 +313,11 @@ export const packedNotificationSchema = { | ||||
| 			}, | ||||
| 			header: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 			icon: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: false, | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, { | ||||
|   | ||||
| @@ -199,9 +199,18 @@ export class ApiCallService implements OnApplicationShutdown { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const [path] = await createTemp(); | ||||
| 		const [path, cleanup] = await createTemp(); | ||||
| 		await stream.pipeline(multipartData.file, fs.createWriteStream(path)); | ||||
|  | ||||
| 		// ファイルサイズが制限を超えていた場合 | ||||
| 		// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある | ||||
| 		if (multipartData.file.truncated) { | ||||
| 			cleanup(); | ||||
| 			reply.code(413); | ||||
| 			reply.send(); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const fields = {} as Record<string, unknown>; | ||||
| 		for (const [k, v] of Object.entries(multipartData.fields)) { | ||||
| 			fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; | ||||
|   | ||||
| @@ -49,7 +49,7 @@ export class ApiServerService { | ||||
|  | ||||
| 		fastify.register(multipart, { | ||||
| 			limits: { | ||||
| 				fileSize: this.config.maxFileSize ?? 262144000, | ||||
| 				fileSize: this.config.maxFileSize, | ||||
| 				files: 1, | ||||
| 			}, | ||||
| 		}); | ||||
|   | ||||
| @@ -21,8 +21,7 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'array', | ||||
| 			optional: false, nullable: false, | ||||
| 			items: { | ||||
| 				anyOf: [ | ||||
| 			prefixItems: [ | ||||
| 				{ | ||||
| 					type: 'string', | ||||
| 				}, | ||||
| @@ -30,7 +29,7 @@ export const meta = { | ||||
| 					type: 'number', | ||||
| 				}, | ||||
| 			], | ||||
| 			}, | ||||
| 			unevaluatedItems: false, | ||||
| 		}, | ||||
| 		example: [[ | ||||
| 			'example.com', | ||||
|   | ||||
| @@ -21,8 +21,7 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'array', | ||||
| 			optional: false, nullable: false, | ||||
| 			items: { | ||||
| 				anyOf: [ | ||||
| 			prefixItems: [ | ||||
| 				{ | ||||
| 					type: 'string', | ||||
| 				}, | ||||
| @@ -30,7 +29,7 @@ export const meta = { | ||||
| 					type: 'number', | ||||
| 				}, | ||||
| 			], | ||||
| 			}, | ||||
| 			unevaluatedItems: false, | ||||
| 		}, | ||||
| 		example: [[ | ||||
| 			'example.com', | ||||
|   | ||||
| @@ -36,7 +36,6 @@ export const paramDef = { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		channelId: { type: 'string', nullable: true, format: 'misskey:id' }, | ||||
| 		withSensitive: { type: 'boolean', default: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -104,13 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||
|  | ||||
| 			notes.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
|  | ||||
| 			let packed = await this.noteEntityService.packMany(notes, me); | ||||
|  | ||||
| 			if (!ps.withSensitive) { | ||||
| 				packed = packed.filter(note => note.files?.length === 0 || note.files?.every(file => !file.isSensitive)); | ||||
| 			} | ||||
|  | ||||
| 			return packed; | ||||
| 			return await this.noteEntityService.packMany(notes, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -166,7 +166,7 @@ | ||||
|  | ||||
| 		if (!errorsElement) { | ||||
| 			document.body.innerHTML = ` | ||||
| 			<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | ||||
| 			<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | ||||
| 				<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | ||||
| 				<path d="M12 9v2m0 4v.01"></path> | ||||
| 				<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path> | ||||
| @@ -176,9 +176,9 @@ | ||||
| 				<span class="button-label-big">Reload / リロード</span> | ||||
| 			</button> | ||||
| 			<p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p> | ||||
| 			<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p> | ||||
| 			<p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p> | ||||
| 			<p>Disable an adblocker / アドブロッカーを無効にする</p> | ||||
| 			<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p> | ||||
| 			<p>(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p> | ||||
| 			<details style="color: #86b300;"> | ||||
| 				<summary>Other options / その他のオプション</summary> | ||||
| @@ -212,7 +212,7 @@ | ||||
| 		<summary> | ||||
| 			<code>ERROR CODE: ${code}</code> | ||||
| 		</summary> | ||||
| 		<code>${JSON.stringify(details)}</code>`; | ||||
| 		<code>${details.toString()} ${JSON.stringify(details)}</code>`; | ||||
| 		errorsElement.appendChild(detailsElement); | ||||
| 		addStyle(` | ||||
| 		* { | ||||
| @@ -320,6 +320,6 @@ | ||||
| 			#errorInfo { | ||||
| 				width: 50%; | ||||
| 			} | ||||
| 		}`) | ||||
| 		}`); | ||||
| 	} | ||||
| })(); | ||||
|   | ||||
| @@ -36,8 +36,6 @@ html | ||||
| 		link(rel='prefetch' href=serverErrorImageUrl) | ||||
| 		link(rel='prefetch' href=infoImageUrl) | ||||
| 		link(rel='prefetch' href=notFoundImageUrl) | ||||
| 		//- https://github.com/misskey-dev/misskey/issues/9842 | ||||
| 		link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0') | ||||
| 		link(rel='modulepreload' href=`/vite/${clientEntry.file}`) | ||||
|  | ||||
| 		if !config.clientManifestExists | ||||
|   | ||||
| @@ -3,9 +3,13 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import 'reflect-metadata'; | ||||
|  | ||||
| // https://vitejs.dev/config/build-options.html#build-modulepreload | ||||
| import 'vite/modulepreload-polyfill'; | ||||
|  | ||||
| import '@tabler/icons-webfont/dist/tabler-icons.scss'; | ||||
|  | ||||
| import '@/style.scss'; | ||||
| import { mainBoot } from '@/boot/main-boot.js'; | ||||
| import { subBoot } from '@/boot/sub-boot.js'; | ||||
|   | ||||
| @@ -3,11 +3,6 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| // devモードで起動される際(index.htmlを使うとき)はrouterが暴発してしまってうまく読み込めない。 | ||||
| // よって、devモードとして起動されるときはビルド時に組み込む形としておく。 | ||||
| // (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない) | ||||
| import '@tabler/icons-webfont/dist/tabler-icons.scss'; | ||||
|  | ||||
| await main(); | ||||
|  | ||||
| import('@/_boot_.js'); | ||||
|   | ||||
| @@ -171,11 +171,11 @@ function onMousedown(evt: MouseEvent): void { | ||||
| 		background: var(--accent); | ||||
|  | ||||
| 		&:not(:disabled):hover { | ||||
| 			background: var(--X8); | ||||
| 			background: hsl(from var(--accent) h s calc(l + 5)); | ||||
| 		} | ||||
|  | ||||
| 		&:not(:disabled):active { | ||||
| 			background: var(--X8); | ||||
| 			background: hsl(from var(--accent) h s calc(l + 5)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -220,11 +220,11 @@ function onMousedown(evt: MouseEvent): void { | ||||
| 		background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); | ||||
|  | ||||
| 		&:not(:disabled):hover { | ||||
| 			background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 			background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 		} | ||||
|  | ||||
| 		&:not(:disabled):active { | ||||
| 			background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 			background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -117,7 +117,7 @@ const bannerStyle = computed(() => { | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 			background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
| 		} | ||||
|  | ||||
| 		> .name { | ||||
|   | ||||
| @@ -216,7 +216,7 @@ onUnmounted(() => { | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 			background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
|  | ||||
| 			> .fadeLabel { | ||||
| 				display: inline-block; | ||||
|   | ||||
| @@ -859,7 +859,7 @@ function emitUpdReaction(emoji: string, delta: number) { | ||||
| 	z-index: 2; | ||||
| 	width: 100%; | ||||
| 	height: 64px; | ||||
| 	background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 	background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
|  | ||||
| 	&:hover > .collapsedLabel { | ||||
| 		background: var(--panelHighlight); | ||||
|   | ||||
| @@ -62,7 +62,7 @@ onUnmounted(() => { | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 			background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
|  | ||||
| 			> .fadeLabel { | ||||
| 				display: inline-block; | ||||
|   | ||||
| @@ -245,7 +245,7 @@ const submitText = computed((): string => { | ||||
| }); | ||||
|  | ||||
| const textLength = computed((): number => { | ||||
| 	return (text.value + imeText.value).trim().length; | ||||
| 	return (text.value + imeText.value).length; | ||||
| }); | ||||
|  | ||||
| const maxTextLength = computed((): number => { | ||||
| @@ -1128,13 +1128,13 @@ defineExpose({ | ||||
|  | ||||
| 	&:not(:disabled):hover { | ||||
| 		> .inner { | ||||
| 			background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 			background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	&:not(:disabled):active { | ||||
| 		> .inner { | ||||
| 			background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 			background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -62,7 +62,7 @@ const collapsed = ref(isLong); | ||||
| 			left: 0; | ||||
| 			width: 100%; | ||||
| 			height: 64px; | ||||
| 			background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 			background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
|  | ||||
| 			> .fadeLabel { | ||||
| 				display: inline-block; | ||||
|   | ||||
| @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<template v-if="showDecoration"> | ||||
| 		<img | ||||
| 			v-for="decoration in decorations ?? user.avatarDecorations" | ||||
| 			:class="[$style.decoration]" | ||||
| 			:class="[$style.decoration, { [$style.decorationBlink]: decoration.blink }]" | ||||
| 			:src="getDecorationUrl(decoration)" | ||||
| 			:style="{ | ||||
| 				rotate: getDecorationAngle(decoration), | ||||
| @@ -60,7 +60,7 @@ const props = withDefaults(defineProps<{ | ||||
| 	link?: boolean; | ||||
| 	preview?: boolean; | ||||
| 	indicator?: boolean; | ||||
| 	decorations?: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>[]; | ||||
| 	decorations?: (Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; })[]; | ||||
| 	forceShowDecoration?: boolean; | ||||
| }>(), { | ||||
| 	target: null, | ||||
| @@ -330,4 +330,17 @@ watch(() => props.user.avatarBlurhash, () => { | ||||
| 	width: 200%; | ||||
| 	pointer-events: none; | ||||
| } | ||||
|  | ||||
| .decorationBlink { | ||||
| 	animation: blink 1s infinite; | ||||
| } | ||||
|  | ||||
| @keyframes blink { | ||||
| 	0%, 100% { | ||||
| 		filter: brightness(2); | ||||
| 	} | ||||
| 	50% { | ||||
| 		filter: brightness(1); | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div :class="$style.root" class="_gaps"> | ||||
| 			<div :class="$style.subMenus" class="_gaps"> | ||||
| 				<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton> | ||||
| 				<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton> | ||||
| 			</div> | ||||
|  | ||||
| 			<div :class="$style.inputs" class="_gaps"> | ||||
|   | ||||
| @@ -36,7 +36,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import XChart from './overview.queue.chart.vue'; | ||||
| import type { ApQueueDomain } from '@/pages/admin/queue.vue'; | ||||
| import number from '@/filters/number.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
|  | ||||
| @@ -52,10 +54,10 @@ const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); | ||||
| const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	domain: string; | ||||
| 	domain: ApQueueDomain; | ||||
| }>(); | ||||
|  | ||||
| const onStats = (stats) => { | ||||
| function onStats(stats: Misskey.entities.QueueStats) { | ||||
| 	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; | ||||
| 	active.value = stats[props.domain].active; | ||||
| 	delayed.value = stats[props.domain].delayed; | ||||
| @@ -65,13 +67,13 @@ const onStats = (stats) => { | ||||
| 	chartActive.value.pushData(stats[props.domain].active); | ||||
| 	chartDelayed.value.pushData(stats[props.domain].delayed); | ||||
| 	chartWaiting.value.pushData(stats[props.domain].waiting); | ||||
| }; | ||||
| } | ||||
|  | ||||
| const onStatsLog = (statsLog) => { | ||||
| 	const dataProcess = []; | ||||
| 	const dataActive = []; | ||||
| 	const dataDelayed = []; | ||||
| 	const dataWaiting = []; | ||||
| function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { | ||||
| 	const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = []; | ||||
| 	const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = []; | ||||
| 	const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = []; | ||||
| 	const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = []; | ||||
|  | ||||
| 	for (const stats of [...statsLog].reverse()) { | ||||
| 		dataProcess.push(stats[props.domain].activeSincePrevTick); | ||||
| @@ -84,7 +86,7 @@ const onStatsLog = (statsLog) => { | ||||
| 	chartActive.value.setData(dataActive); | ||||
| 	chartDelayed.value.setData(dataDelayed); | ||||
| 	chartWaiting.value.setData(dataWaiting); | ||||
| }; | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	connection.on('stats', onStats); | ||||
|   | ||||
| @@ -49,7 +49,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import XChart from './queue.chart.chart.vue'; | ||||
| import type { ApQueueDomain } from '@/pages/admin/queue.vue'; | ||||
| import number from '@/filters/number.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| @@ -62,17 +64,17 @@ const activeSincePrevTick = ref(0); | ||||
| const active = ref(0); | ||||
| const delayed = ref(0); | ||||
| const waiting = ref(0); | ||||
| const jobs = ref<(string | number)[][]>([]); | ||||
| const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]); | ||||
| const chartProcess = shallowRef<InstanceType<typeof XChart>>(); | ||||
| const chartActive = shallowRef<InstanceType<typeof XChart>>(); | ||||
| const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); | ||||
| const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	domain: string; | ||||
| 	domain: ApQueueDomain; | ||||
| }>(); | ||||
|  | ||||
| const onStats = (stats) => { | ||||
| function onStats(stats: Misskey.entities.QueueStats) { | ||||
| 	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; | ||||
| 	active.value = stats[props.domain].active; | ||||
| 	delayed.value = stats[props.domain].delayed; | ||||
| @@ -82,13 +84,13 @@ const onStats = (stats) => { | ||||
| 	chartActive.value.pushData(stats[props.domain].active); | ||||
| 	chartDelayed.value.pushData(stats[props.domain].delayed); | ||||
| 	chartWaiting.value.pushData(stats[props.domain].waiting); | ||||
| }; | ||||
| } | ||||
|  | ||||
| const onStatsLog = (statsLog) => { | ||||
| 	const dataProcess = []; | ||||
| 	const dataActive = []; | ||||
| 	const dataDelayed = []; | ||||
| 	const dataWaiting = []; | ||||
| function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { | ||||
| 	const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = []; | ||||
| 	const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = []; | ||||
| 	const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = []; | ||||
| 	const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = []; | ||||
|  | ||||
| 	for (const stats of [...statsLog].reverse()) { | ||||
| 		dataProcess.push(stats[props.domain].activeSincePrevTick); | ||||
| @@ -101,14 +103,12 @@ const onStatsLog = (statsLog) => { | ||||
| 	chartActive.value.setData(dataActive); | ||||
| 	chartDelayed.value.setData(dataDelayed); | ||||
| 	chartWaiting.value.setData(dataWaiting); | ||||
| }; | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	if (props.domain === 'inbox' || props.domain === 'deliver') { | ||||
| 	misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => { | ||||
| 		jobs.value = result; | ||||
| 	}); | ||||
| 	} | ||||
|  | ||||
| 	connection.on('stats', onStats); | ||||
| 	connection.on('statsLog', onStatsLog); | ||||
|   | ||||
| @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, computed } from 'vue'; | ||||
| import { ref, computed, type Ref } from 'vue'; | ||||
| import XQueue from './queue.chart.vue'; | ||||
| import XHeader from './_header_.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| @@ -25,7 +25,9 @@ import { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
|  | ||||
| const tab = ref('deliver'); | ||||
| export type ApQueueDomain = 'deliver' | 'inbox'; | ||||
|  | ||||
| const tab: Ref<ApQueueDomain> = ref('deliver'); | ||||
|  | ||||
| function clear() { | ||||
| 	os.confirm({ | ||||
|   | ||||
| @@ -310,7 +310,7 @@ definePageMetadata(() => ({ | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	height: 64px; | ||||
| 	background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 	background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
| } | ||||
|  | ||||
| .bannerStatus { | ||||
|   | ||||
| @@ -7,11 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <MkSpacer :contentMax="800"> | ||||
| 	<MkTab v-model="tab" style="margin-bottom: var(--margin);"> | ||||
| 		<option value="notes">{{ i18n.ts.notes }}</option> | ||||
| 		<option value="notesWithSensitive">{{ i18n.ts.notes }} (+{{ i18n.ts.sensitive }})</option> | ||||
| 		<option value="polls">{{ i18n.ts.poll }}</option> | ||||
| 	</MkTab> | ||||
| 	<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> | ||||
| 	<MkNotes v-else-if="tab === 'notesWithSensitive'" :pagination="paginationForNotesWithSensitive"/> | ||||
| 	<MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> | ||||
| </MkSpacer> | ||||
| </template> | ||||
| @@ -25,17 +23,6 @@ import { i18n } from '@/i18n.js'; | ||||
| const paginationForNotes = { | ||||
| 	endpoint: 'notes/featured' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		withSensitive: false, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| const paginationForNotesWithSensitive = { | ||||
| 	endpoint: 'notes/featured' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		withSensitive: true, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| const paginationForPolls = { | ||||
|   | ||||
| @@ -96,6 +96,7 @@ const decorationsForPreview = computed(() => { | ||||
| 		flipH: flipH.value, | ||||
| 		offsetX: offsetX.value, | ||||
| 		offsetY: offsetY.value, | ||||
| 		blink: true, | ||||
| 	}; | ||||
| 	const decorations = [...$i.avatarDecorations]; | ||||
| 	if (props.usingIndex != null) { | ||||
|   | ||||
| @@ -84,7 +84,7 @@ onUpdated(() => { | ||||
| 		left: 0; | ||||
| 		width: 100%; | ||||
| 		height: 64px; | ||||
| 		background: linear-gradient(0deg, var(--panel), var(--X15)); | ||||
| 		background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -66,6 +66,11 @@ export async function getNoteClipMenu(props: { | ||||
| 							}); | ||||
| 							if (props.currentClip?.id === clip.id) props.isDeleted.value = true; | ||||
| 						} | ||||
| 					} else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') { | ||||
| 						os.alert({ | ||||
| 							type: 'error', | ||||
| 							text: i18n.ts.clipNoteLimitExceeded, | ||||
| 						}); | ||||
| 					} else { | ||||
| 						os.alert({ | ||||
| 							type: 'error', | ||||
|   | ||||
| @@ -137,7 +137,6 @@ export class I18n<T extends ILocale> { | ||||
| 			return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>; | ||||
| 		} | ||||
|  | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||||
| 		if (this.tsxCache) { | ||||
| 			return this.tsxCache; | ||||
| 		} | ||||
| @@ -244,51 +243,3 @@ export class I18n<T extends ILocale> { | ||||
| 		return str; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| if (import.meta.vitest) { | ||||
| 	const { describe, expect, it } = import.meta.vitest; | ||||
|  | ||||
| 	describe('i18n', () => { | ||||
| 		it('t', () => { | ||||
| 			const i18n = new I18n({ | ||||
| 				foo: 'foo', | ||||
| 				bar: { | ||||
| 					baz: 'baz', | ||||
| 					qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 					quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			expect(i18n.t('foo')).toBe('foo'); | ||||
| 			expect(i18n.t('bar.baz')).toBe('baz'); | ||||
| 			expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); | ||||
| 			expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); | ||||
| 		}); | ||||
| 		it('ts', () => { | ||||
| 			const i18n = new I18n({ | ||||
| 				foo: 'foo', | ||||
| 				bar: { | ||||
| 					baz: 'baz', | ||||
| 					qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 					quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			expect(i18n.ts.foo).toBe('foo'); | ||||
| 			expect(i18n.ts.bar.baz).toBe('baz'); | ||||
| 		}); | ||||
| 		it('tsx', () => { | ||||
| 			const i18n = new I18n({ | ||||
| 				foo: 'foo', | ||||
| 				bar: { | ||||
| 					baz: 'baz', | ||||
| 					qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 					quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); | ||||
| 			expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { apiUrl } from '@/config.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { alert } from '@/os.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { instance } from '@/instance.js'; | ||||
|  | ||||
| type Uploading = { | ||||
| 	id: string; | ||||
| @@ -39,6 +40,15 @@ export function uploadFile( | ||||
|  | ||||
| 	if (folder && typeof folder === 'object') folder = folder.id; | ||||
|  | ||||
| 	if (file.size > instance.maxFileSize) { | ||||
| 		alert({ | ||||
| 			type: 'error', | ||||
| 			title: i18n.ts.failedToUpload, | ||||
| 			text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, | ||||
| 		}); | ||||
| 		return Promise.reject(); | ||||
| 	} | ||||
|  | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		const id = uuid(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										23
									
								
								packages/frontend/src/services/AccountService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/frontend/src/services/AccountService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { inject, injectable, container } from 'tsyringe'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { defineAsyncComponent, reactive, ref } from 'vue'; | ||||
| import { miLocalStorage } from '@/local-storage.js'; | ||||
|  | ||||
| type Account = Misskey.entities.MeDetailed & { token: string }; | ||||
|  | ||||
| const accountData = miLocalStorage.getItem('account'); | ||||
|  | ||||
| const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; | ||||
|  | ||||
| @injectable() | ||||
| export class AccountService { | ||||
| 	constructor( | ||||
| 	) {} | ||||
|  | ||||
| 	public readonly i = $i; | ||||
| } | ||||
							
								
								
									
										170
									
								
								packages/frontend/src/services/UploaderService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								packages/frontend/src/services/UploaderService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { inject, injectable, container } from 'tsyringe'; | ||||
| import { reactive, ref } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; | ||||
| import { getCompressionConfig } from './upload/compress-config.js'; | ||||
| import { AccountService } from './AccountService.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { apiUrl } from '@/config.js'; | ||||
| import { alert } from '@/os.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
| type Uploading = { | ||||
| 	id: string; | ||||
| 	name: string; | ||||
| 	progressMax: number | undefined; | ||||
| 	progressValue: number | undefined; | ||||
| 	img: string; | ||||
| }; | ||||
| export const uploads = ref<Uploading[]>([]); | ||||
|  | ||||
| const mimeTypeMap = { | ||||
| 	'image/webp': 'webp', | ||||
| 	'image/jpeg': 'jpg', | ||||
| 	'image/png': 'png', | ||||
| } as const; | ||||
|  | ||||
| @injectable() | ||||
| export class Uploader { | ||||
| 	constructor( | ||||
| 		@inject('AccountService') private accountService: AccountService, | ||||
| 		@inject('ServerMetadataService') private serverMetadataService: ServerMetadataService, | ||||
| 	) {} | ||||
|  | ||||
| 	public uploadFile( | ||||
| 		file: File, | ||||
| 		folder?: any, | ||||
| 		name?: string, | ||||
| 		keepOriginal: boolean = defaultStore.state.keepOriginalUploading, | ||||
| 	): Promise<Misskey.entities.DriveFile> { | ||||
| 		if (this.accountService.i == null) throw new Error('Not logged in'); | ||||
|  | ||||
| 		if (folder && typeof folder === 'object') folder = folder.id; | ||||
|  | ||||
| 		return fetchServerMetadata().then((serverMetadata) => new Promise((resolve, reject) => { | ||||
| 			if (file.size > serverMetadata.maxFileSize) { | ||||
| 				alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.failedToUpload, | ||||
| 					text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, | ||||
| 				}); | ||||
| 				return reject(); | ||||
| 			} | ||||
|  | ||||
| 			const id = uuid(); | ||||
|  | ||||
| 			const reader = new FileReader(); | ||||
| 			reader.onload = async (): Promise<void> => { | ||||
| 				const filename = name ?? file.name ?? 'untitled'; | ||||
| 				const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; | ||||
|  | ||||
| 				const ctx = reactive<Uploading>({ | ||||
| 					id, | ||||
| 					name: defaultStore.state.keepOriginalFilename ? filename : id + extension, | ||||
| 					progressMax: undefined, | ||||
| 					progressValue: undefined, | ||||
| 					img: window.URL.createObjectURL(file), | ||||
| 				}); | ||||
|  | ||||
| 				uploads.value.push(ctx); | ||||
|  | ||||
| 				const config = !keepOriginal ? await getCompressionConfig(file) : undefined; | ||||
| 				let resizedImage: Blob | undefined; | ||||
| 				if (config) { | ||||
| 					try { | ||||
| 						const resized = await readAndCompressImage(file, config); | ||||
| 						if (resized.size < file.size || file.type === 'image/webp') { | ||||
| 							// The compression may not always reduce the file size | ||||
| 							// (and WebP is not browser safe yet) | ||||
| 							resizedImage = resized; | ||||
| 						} | ||||
| 						if (_DEV_) { | ||||
| 							const saved = ((1 - resized.size / file.size) * 100).toFixed(2); | ||||
| 							console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); | ||||
| 						} | ||||
|  | ||||
| 						ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; | ||||
| 					} catch (err) { | ||||
| 						console.error('Failed to resize image', err); | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				const formData = new FormData(); | ||||
| 				formData.append('i', this.accountService.i.token); | ||||
| 				formData.append('force', 'true'); | ||||
| 				formData.append('file', resizedImage ?? file); | ||||
| 				formData.append('name', ctx.name); | ||||
| 				if (folder) formData.append('folderId', folder); | ||||
|  | ||||
| 				const xhr = new XMLHttpRequest(); | ||||
| 				xhr.open('POST', apiUrl + '/drive/files/create', true); | ||||
| 				xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { | ||||
| 					if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | ||||
| 						// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい | ||||
| 						uploads.value = uploads.value.filter(x => x.id !== id); | ||||
|  | ||||
| 						if (xhr.status === 413) { | ||||
| 							alert({ | ||||
| 								type: 'error', | ||||
| 								title: i18n.ts.failedToUpload, | ||||
| 								text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, | ||||
| 							}); | ||||
| 						} else if (ev.target?.response) { | ||||
| 							const res = JSON.parse(ev.target.response); | ||||
| 							if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { | ||||
| 								alert({ | ||||
| 									type: 'error', | ||||
| 									title: i18n.ts.failedToUpload, | ||||
| 									text: i18n.ts.cannotUploadBecauseInappropriate, | ||||
| 								}); | ||||
| 							} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { | ||||
| 								alert({ | ||||
| 									type: 'error', | ||||
| 									title: i18n.ts.failedToUpload, | ||||
| 									text: i18n.ts.cannotUploadBecauseNoFreeSpace, | ||||
| 								}); | ||||
| 							} else { | ||||
| 								alert({ | ||||
| 									type: 'error', | ||||
| 									title: i18n.ts.failedToUpload, | ||||
| 									text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, | ||||
| 								}); | ||||
| 							} | ||||
| 						} else { | ||||
| 							alert({ | ||||
| 								type: 'error', | ||||
| 								title: 'Failed to upload', | ||||
| 								text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, | ||||
| 							}); | ||||
| 						} | ||||
|  | ||||
| 						reject(); | ||||
| 						return; | ||||
| 					} | ||||
|  | ||||
| 					const driveFile = JSON.parse(ev.target.response); | ||||
|  | ||||
| 					resolve(driveFile); | ||||
|  | ||||
| 					uploads.value = uploads.value.filter(x => x.id !== id); | ||||
| 				}) as (ev: ProgressEvent<EventTarget>) => any; | ||||
|  | ||||
| 				xhr.upload.onprogress = ev => { | ||||
| 					if (ev.lengthComputable) { | ||||
| 						ctx.progressMax = ev.total; | ||||
| 						ctx.progressValue = ev.loaded; | ||||
| 					} | ||||
| 				}; | ||||
|  | ||||
| 				xhr.send(formData); | ||||
| 			}; | ||||
| 			reader.readAsArrayBuffer(file); | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
| @@ -255,11 +255,11 @@ rt { | ||||
| 	background: var(--accent); | ||||
|  | ||||
| 	&:not(:disabled):hover { | ||||
| 		background: var(--X8); | ||||
| 		background: hsl(from var(--accent) h s calc(l + 5)); | ||||
| 	} | ||||
|  | ||||
| 	&:not(:disabled):active { | ||||
| 		background: var(--X9); | ||||
| 		background: hsl(from var(--accent) h s calc(l - 5)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -269,11 +269,11 @@ rt { | ||||
| 	background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); | ||||
|  | ||||
| 	&:not(:disabled):hover { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
|  | ||||
| 	&:not(:disabled):active { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -77,21 +77,14 @@ | ||||
| 		codeBoolean: '#c59eff', | ||||
| 		deckBg: '#000', | ||||
| 		htmlThemeColor: '@bg', | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X4: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X5: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X6: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X7: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
| 		X11: 'rgba(0, 0, 0, 0.3)', | ||||
| 		X12: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X13: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 		X16: ':alpha<0.7<@panel', | ||||
| 		X17: ':alpha<0.8<@bg', | ||||
| 	}, | ||||
|  | ||||
| 	codeHighlighter: { | ||||
|   | ||||
| @@ -77,21 +77,14 @@ | ||||
| 		codeBoolean: '#62b70c', | ||||
| 		deckBg: ':darken<3<@bg', | ||||
| 		htmlThemeColor: '@bg', | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X4: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X5: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X6: 'rgba(0, 0, 0, 0.25)', | ||||
| 		X7: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
| 		X11: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X12: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X13: 'rgba(0, 0, 0, 0.15)', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 		X16: ':alpha<0.7<@panel', | ||||
| 		X17: ':alpha<0.8<@bg', | ||||
| 	}, | ||||
|  | ||||
| 	codeHighlighter: { | ||||
|   | ||||
| @@ -57,20 +57,13 @@ | ||||
| 		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', | ||||
| 		panelHeaderDivider: 'rgba(0, 0, 0, 0)', | ||||
| 		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X4: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X5: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X6: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X7: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
| 		X11: 'rgba(0, 0, 0, 0.3)', | ||||
| 		X12: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X13: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X14: ':alpha<0.5<@navBg', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 		X16: ':alpha<0.7<@panel', | ||||
| 	}, | ||||
| } | ||||
|   | ||||
| @@ -3,14 +3,11 @@ | ||||
| 	base: 'dark', | ||||
| 	name: 'Mi U0 Dark', | ||||
| 	props: { | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X4: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X5: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X6: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X7: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		bg: '#172426', | ||||
| 		fg: '#dadada', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
|   | ||||
| @@ -3,14 +3,11 @@ | ||||
| 	base: 'light', | ||||
| 	name: 'Mi U0 Light', | ||||
| 	props: { | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X4: 'rgba(255, 255, 255, 0.1)', | ||||
| 		X5: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X6: 'rgba(255, 255, 255, 0.15)', | ||||
| 		X7: 'rgba(255, 255, 255, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		bg: '#e7e7eb', | ||||
| 		fg: '#5f5f5f', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
|   | ||||
| @@ -60,21 +60,13 @@ | ||||
| 		fgTransparentWeak: ':alpha<0.75<@fg', | ||||
| 		panelHeaderDivider: '@divider', | ||||
| 		scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', | ||||
| 		X2: ':darken<2<@panel', | ||||
| 		X3: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X4: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X5: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X6: 'rgba(0, 0, 0, 0.25)', | ||||
| 		X7: 'rgba(0, 0, 0, 0.05)', | ||||
| 		X8: ':lighten<5<@accent', | ||||
| 		X9: ':darken<5<@accent', | ||||
| 		X10: ':alpha<0.4<@accent', | ||||
| 		X11: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X12: 'rgba(0, 0, 0, 0.1)', | ||||
| 		X13: 'rgba(0, 0, 0, 0.15)', | ||||
| 		X14: ':alpha<0.5<@navBg', | ||||
| 		X15: ':alpha<0<@panel', | ||||
| 		X16: ':alpha<0.7<@panel', | ||||
| 		X17: ':alpha<0.8<@bg', | ||||
| 	}, | ||||
| } | ||||
|   | ||||
| @@ -450,7 +450,7 @@ body { | ||||
| 	} | ||||
|  | ||||
| 	&:active { | ||||
| 		background: var(--X2); | ||||
| 		background: hsl(from var(--panel) h s calc(l - 2)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -460,11 +460,11 @@ body { | ||||
| 	color: var(--fgOnAccent); | ||||
|  | ||||
| 	&:hover { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
|  | ||||
| 	&:active { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -427,7 +427,7 @@ $widgets-hide-threshold: 1090px; | ||||
| 	} | ||||
|  | ||||
| 	&:active { | ||||
| 		background: var(--X2); | ||||
| 		background: hsl(from var(--panel) h s calc(l - 2)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -437,11 +437,11 @@ $widgets-hide-threshold: 1090px; | ||||
| 	color: var(--fgOnAccent); | ||||
|  | ||||
| 	&:hover { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
|  | ||||
| 	&:active { | ||||
| 		background: linear-gradient(90deg, var(--X8), var(--X8)); | ||||
| 		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -138,7 +138,7 @@ function onStats(connStats: Misskey.entities.ServerStats) { | ||||
| } | ||||
|  | ||||
| function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) { | ||||
| 	for (const revStats of statsLog.reverse()) { | ||||
| 	for (const revStats of statsLog.toReversed()) { | ||||
| 		onStats(revStats); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -111,7 +111,7 @@ function onStats(connStats: Misskey.entities.ServerStats) { | ||||
| } | ||||
|  | ||||
| function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) { | ||||
| 	for (const revStats of statsLog.reverse()) { | ||||
| 	for (const revStats of statsLog.toReversed()) { | ||||
| 		onStats(revStats); | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										52
									
								
								packages/frontend/test/i18n.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/frontend/test/i18n.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { describe, expect, it } from 'vitest'; | ||||
| import { I18n } from '@/scripts/i18n.js'; | ||||
| import { ParameterizedString } from '../../../locales/index.js'; | ||||
|  | ||||
| describe('i18n', () => { | ||||
| 	it('t', () => { | ||||
| 		const i18n = new I18n({ | ||||
| 			foo: 'foo', | ||||
| 			bar: { | ||||
| 				baz: 'baz', | ||||
| 				qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 				quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(i18n.t('foo')).toBe('foo'); | ||||
| 		expect(i18n.t('bar.baz')).toBe('baz'); | ||||
| 		expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); | ||||
| 		expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); | ||||
| 	}); | ||||
| 	it('ts', () => { | ||||
| 		const i18n = new I18n({ | ||||
| 			foo: 'foo', | ||||
| 			bar: { | ||||
| 				baz: 'baz', | ||||
| 				qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 				quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(i18n.ts.foo).toBe('foo'); | ||||
| 		expect(i18n.ts.bar.baz).toBe('baz'); | ||||
| 	}); | ||||
| 	it('tsx', () => { | ||||
| 		const i18n = new I18n({ | ||||
| 			foo: 'foo', | ||||
| 			bar: { | ||||
| 				baz: 'baz', | ||||
| 				qux: 'qux {0}' as unknown as ParameterizedString<'0'>, | ||||
| 				quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); | ||||
| 		expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); | ||||
| 	}); | ||||
| }); | ||||
| @@ -4245,15 +4245,16 @@ export type components = { | ||||
|       /** @enum {string} */ | ||||
|       type: 'roleAssigned'; | ||||
|       role: components['schemas']['Role']; | ||||
|     } | { | ||||
|     } | ({ | ||||
|       /** Format: id */ | ||||
|       id: string; | ||||
|       /** Format: date-time */ | ||||
|       createdAt: string; | ||||
|       /** @enum {string} */ | ||||
|       type: 'achievementEarned'; | ||||
|       achievement: string; | ||||
|     } | { | ||||
|       /** @enum {string} */ | ||||
|       achievement: 'notes1' | 'notes10' | 'notes100' | 'notes500' | 'notes1000' | 'notes5000' | 'notes10000' | 'notes20000' | 'notes30000' | 'notes40000' | 'notes50000' | 'notes60000' | 'notes70000' | 'notes80000' | 'notes90000' | 'notes100000' | 'login3' | 'login7' | 'login15' | 'login30' | 'login60' | 'login100' | 'login200' | 'login300' | 'login400' | 'login500' | 'login600' | 'login700' | 'login800' | 'login900' | 'login1000' | 'passedSinceAccountCreated1' | 'passedSinceAccountCreated2' | 'passedSinceAccountCreated3' | 'loggedInOnBirthday' | 'loggedInOnNewYearsDay' | 'noteClipped1' | 'noteFavorited1' | 'myNoteFavorited1' | 'profileFilled' | 'markedAsCat' | 'following1' | 'following10' | 'following50' | 'following100' | 'following300' | 'followers1' | 'followers10' | 'followers50' | 'followers100' | 'followers300' | 'followers500' | 'followers1000' | 'collectAchievements30' | 'viewAchievements3min' | 'iLoveMisskey' | 'foundTreasure' | 'client30min' | 'client60min' | 'noteDeletedWithin1min' | 'postedAtLateNight' | 'postedAt0min0sec' | 'selfQuote' | 'htl20npm' | 'viewInstanceChart' | 'outputHelloWorldOnScratchpad' | 'open3windows' | 'driveFolderCircularReference' | 'reactWithoutRead' | 'clickedClickHere' | 'justPlainLucky' | 'setNameToSyuilo' | 'cookieClicked' | 'brainDiver' | 'smashTestNotificationButton' | 'tutorialCompleted' | 'bubbleGameExplodingHead' | 'bubbleGameDoubleExplodingHead'; | ||||
|     }) | ({ | ||||
|       /** Format: id */ | ||||
|       id: string; | ||||
|       /** Format: date-time */ | ||||
| @@ -4261,9 +4262,9 @@ export type components = { | ||||
|       /** @enum {string} */ | ||||
|       type: 'app'; | ||||
|       body: string; | ||||
|       header: string; | ||||
|       icon: string; | ||||
|     } | { | ||||
|       header: string | null; | ||||
|       icon: string | null; | ||||
|     }) | { | ||||
|       /** Format: id */ | ||||
|       id: string; | ||||
|       /** Format: date-time */ | ||||
| @@ -4947,6 +4948,7 @@ export type components = { | ||||
|        * @enum {string} | ||||
|        */ | ||||
|       noteSearchableScope: 'local' | 'global'; | ||||
|       maxFileSize: number; | ||||
|     }; | ||||
|     MetaDetailedOnly: { | ||||
|       features?: { | ||||
| @@ -8216,7 +8218,7 @@ export type operations = { | ||||
|       /** @description OK (with results) */ | ||||
|       200: { | ||||
|         content: { | ||||
|           'application/json': ((string | number)[])[]; | ||||
|           'application/json': [string, number][]; | ||||
|         }; | ||||
|       }; | ||||
|       /** @description Client error */ | ||||
| @@ -8262,7 +8264,7 @@ export type operations = { | ||||
|       /** @description OK (with results) */ | ||||
|       200: { | ||||
|         content: { | ||||
|           'application/json': ((string | number)[])[]; | ||||
|           'application/json': [string, number][]; | ||||
|         }; | ||||
|       }; | ||||
|       /** @description Client error */ | ||||
|   | ||||
| @@ -8,10 +8,10 @@ | ||||
| import { fileURLToPath } from 'node:url'; | ||||
| import * as esbuild from 'esbuild'; | ||||
| import locales from '../../locales/index.js'; | ||||
| import meta from '../../package.json' with { type: "json" }; | ||||
| import meta from '../../package.json' with { type: 'json' }; | ||||
| const watch = process.argv[2]?.includes('watch'); | ||||
|  | ||||
| const __dirname = fileURLToPath(new URL('.', import.meta.url)) | ||||
| const __dirname = fileURLToPath(new URL('.', import.meta.url)); | ||||
|  | ||||
| console.log('Starting SW building...'); | ||||
|  | ||||
|   | ||||
| @@ -41,11 +41,10 @@ export async function createNotification<K extends keyof PushNotificationDataMap | ||||
|  | ||||
| async function composeNotification(data: PushNotificationDataMap[keyof PushNotificationDataMap]): Promise<[string, NotificationOptions] | null> { | ||||
| 	const i18n = await (swLang.i18n ?? swLang.fetchLocale()); | ||||
| 	const { t } = i18n; | ||||
| 	switch (data.type) { | ||||
| 		/* | ||||
| 		case 'driveFileCreated': // TODO (Server Side) | ||||
| 			return [t('_notification.fileUploaded'), { | ||||
| 			return [i18n.ts._notification.fileUploaded, { | ||||
| 				body: body.name, | ||||
| 				icon: body.url, | ||||
| 				data | ||||
| @@ -58,52 +57,52 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 					const account = await getAccountFromId(data.userId); | ||||
| 					if (!account) return null; | ||||
| 					const userDetail = await cli.request('users/show', { userId: data.body.userId }, account.token); | ||||
| 					return [t('_notification.youWereFollowed'), { | ||||
| 					return [i18n.ts._notification.youWereFollowed, { | ||||
| 						body: getUserName(data.body.user), | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('user-plus'), | ||||
| 						data, | ||||
| 						actions: userDetail.isFollowing ? [] : [ | ||||
| 							{ | ||||
| 								action: 'follow', | ||||
| 								title: t('_notification._actions.followBack'), | ||||
| 								title: i18n.ts._notification._actions.followBack, | ||||
| 							}, | ||||
| 						], | ||||
| 					}]; | ||||
| 				} | ||||
|  | ||||
| 				case 'mention': | ||||
| 					return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), { | ||||
| 					return [i18n.tsx._notification.youGotMention({ name: getUserName(data.body.user) }), { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('at'), | ||||
| 						data, | ||||
| 						actions: [ | ||||
| 							{ | ||||
| 								action: 'reply', | ||||
| 								title: t('_notification._actions.reply'), | ||||
| 								title: i18n.ts._notification._actions.reply, | ||||
| 							}, | ||||
| 						], | ||||
| 					}]; | ||||
|  | ||||
| 				case 'reply': | ||||
| 					return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), { | ||||
| 					return [i18n.tsx._notification.youGotReply({ name: getUserName(data.body.user) }), { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('arrow-back-up'), | ||||
| 						data, | ||||
| 						actions: [ | ||||
| 							{ | ||||
| 								action: 'reply', | ||||
| 								title: t('_notification._actions.reply'), | ||||
| 								title: i18n.ts._notification._actions.reply, | ||||
| 							}, | ||||
| 						], | ||||
| 					}]; | ||||
|  | ||||
| 				case 'renote': | ||||
| 					return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), { | ||||
| 					return [i18n.tsx._notification.youRenoted({ name: getUserName(data.body.user) }), { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('repeat'), | ||||
| 						data, | ||||
| 						actions: [ | ||||
| @@ -115,29 +114,29 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 					}]; | ||||
|  | ||||
| 				case 'quote': | ||||
| 					return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), { | ||||
| 					return [i18n.tsx._notification.youGotQuote({ name: getUserName(data.body.user) }), { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('quote'), | ||||
| 						data, | ||||
| 						actions: [ | ||||
| 							{ | ||||
| 								action: 'reply', | ||||
| 								title: t('_notification._actions.reply'), | ||||
| 								title: i18n.ts._notification._actions.reply, | ||||
| 							}, | ||||
| 							...((data.body.note.visibility === 'public' || data.body.note.visibility === 'home') ? [ | ||||
| 								{ | ||||
| 									action: 'renote', | ||||
| 									title: t('_notification._actions.renote'), | ||||
| 									title: i18n.ts._notification._actions.renote, | ||||
| 								}, | ||||
| 							] : []), | ||||
| 						], | ||||
| 					}]; | ||||
|  | ||||
| 				case 'note': | ||||
| 					return [t('_notification.newNote') + ': ' + getUserName(data.body.user), { | ||||
| 					return [i18n.ts._notification.newNote + ': ' + getUserName(data.body.user), { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						data, | ||||
| 					}]; | ||||
|  | ||||
| @@ -164,7 +163,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 					const tag = `reaction:${data.body.note.id}`; | ||||
| 					return [`${reaction} ${getUserName(data.body.user)}`, { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						tag, | ||||
| 						badge, | ||||
| 						data, | ||||
| @@ -178,41 +177,41 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 				} | ||||
|  | ||||
| 				case 'receiveFollowRequest': | ||||
| 					return [t('_notification.youReceivedFollowRequest'), { | ||||
| 					return [i18n.ts._notification.youReceivedFollowRequest, { | ||||
| 						body: getUserName(data.body.user), | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('user-plus'), | ||||
| 						data, | ||||
| 						actions: [ | ||||
| 							{ | ||||
| 								action: 'accept', | ||||
| 								title: t('accept'), | ||||
| 								title: i18n.ts.accept, | ||||
| 							}, | ||||
| 							{ | ||||
| 								action: 'reject', | ||||
| 								title: t('reject'), | ||||
| 								title: i18n.ts.reject, | ||||
| 							}, | ||||
| 						], | ||||
| 					}]; | ||||
|  | ||||
| 				case 'followRequestAccepted': | ||||
| 					return [t('_notification.yourFollowRequestAccepted'), { | ||||
| 					return [i18n.ts._notification.yourFollowRequestAccepted, { | ||||
| 						body: getUserName(data.body.user), | ||||
| 						icon: data.body.user.avatarUrl, | ||||
| 						icon: data.body.user.avatarUrl ?? undefined, | ||||
| 						badge: iconUrl('circle-check'), | ||||
| 						data, | ||||
| 					}]; | ||||
|  | ||||
| 				case 'achievementEarned': | ||||
| 					return [t('_notification.achievementEarned'), { | ||||
| 						body: t(`_achievements._types._${data.body.achievement}.title`), | ||||
| 					return [i18n.ts._notification.achievementEarned, { | ||||
| 						body: i18n.ts._achievements._types[`_${data.body.achievement}`].title, | ||||
| 						badge: iconUrl('medal'), | ||||
| 						data, | ||||
| 						tag: `achievement:${data.body.achievement}`, | ||||
| 					}]; | ||||
|  | ||||
| 				case 'pollEnded': | ||||
| 					return [t('_notification.pollEnded'), { | ||||
| 					return [i18n.ts._notification.pollEnded, { | ||||
| 						body: data.body.note.text ?? '', | ||||
| 						badge: iconUrl('chart-arrows'), | ||||
| 						data, | ||||
| @@ -226,8 +225,8 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 					}]; | ||||
|  | ||||
| 				case 'test': | ||||
| 					return [t('_notification.testNotification'), { | ||||
| 						body: t('_notification.notificationWillBeDisplayedLikeThis'), | ||||
| 					return [i18n.ts._notification.testNotification, { | ||||
| 						body: i18n.ts._notification.notificationWillBeDisplayedLikeThis, | ||||
| 						badge: iconUrl('bell'), | ||||
| 						data, | ||||
| 					}]; | ||||
| @@ -236,9 +235,9 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| 					return null; | ||||
| 			} | ||||
| 		case 'unreadAntennaNote': | ||||
| 			return [t('_notification.unreadAntennaNote', { name: data.body.antenna.name }), { | ||||
| 			return [i18n.tsx._notification.unreadAntennaNote({ name: data.body.antenna.name }), { | ||||
| 				body: `${getUserName(data.body.note.user)}: ${data.body.note.text ?? ''}`, | ||||
| 				icon: data.body.note.user.avatarUrl, | ||||
| 				icon: data.body.note.user.avatarUrl ?? undefined, | ||||
| 				badge: iconUrl('antenna'), | ||||
| 				tag: `antenna:${data.body.antenna.id}`, | ||||
| 				data, | ||||
| @@ -252,7 +251,6 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif | ||||
| export async function createEmptyNotification(): Promise<void> { | ||||
| 	return new Promise<void>(async res => { | ||||
| 		const i18n = await (swLang.i18n ?? swLang.fetchLocale()); | ||||
| 		const { t } = i18n; | ||||
|  | ||||
| 		await globalThis.registration.showNotification( | ||||
| 			(new URL(origin)).host, | ||||
| @@ -264,11 +262,11 @@ export async function createEmptyNotification(): Promise<void> { | ||||
| 				actions: [ | ||||
| 					{ | ||||
| 						action: 'markAllAsRead', | ||||
| 						title: t('markAllAsRead'), | ||||
| 						title: i18n.ts.markAllAsRead, | ||||
| 					}, | ||||
| 					{ | ||||
| 						action: 'settings', | ||||
| 						title: t('notificationSettings'), | ||||
| 						title: i18n.ts.notificationSettings, | ||||
| 					}, | ||||
| 				], | ||||
| 				data: {}, | ||||
|   | ||||
| @@ -4,9 +4,10 @@ | ||||
|  */ | ||||
|  | ||||
| import { get } from 'idb-keyval'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
|  | ||||
| export async function getAccountFromId(id: string): Promise<{ token: string; id: string } | void> { | ||||
| 	const accounts = await get<{ token: string; id: string }[]>('accounts'); | ||||
| export async function getAccountFromId(id: string): Promise<Pick<Misskey.entities.SignupResponse, 'id' | 'token'> | undefined> { | ||||
| 	const accounts = await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts'); | ||||
| 	if (!accounts) { | ||||
| 		console.log('Accounts are not recorded'); | ||||
| 		return; | ||||
|   | ||||
| @@ -1,37 +0,0 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| export type Locale = { [key: string]: string | Locale }; | ||||
|  | ||||
| export class I18n<T extends Locale = Locale> { | ||||
| 	public ts: T; | ||||
|  | ||||
| 	constructor(locale: T) { | ||||
| 		this.ts = locale; | ||||
|  | ||||
| 		//#region BIND | ||||
| 		this.t = this.t.bind(this); | ||||
| 		//#endregion | ||||
| 	} | ||||
|  | ||||
| 	// string にしているのは、ドット区切りでのパス指定を許可するため | ||||
| 	// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも | ||||
| 	public t(key: string, args?: Record<string, string>): string { | ||||
| 		try { | ||||
| 			let str = key.split('.').reduce<Locale | Locale[keyof Locale]>((o, i) => o[i], this.ts); | ||||
| 			if (typeof str !== 'string') throw new Error(); | ||||
|  | ||||
| 			if (args) { | ||||
| 				for (const [k, v] of Object.entries(args)) { | ||||
| 					str = str.replace(`{${k}}`, v); | ||||
| 				} | ||||
| 			} | ||||
| 			return str; | ||||
| 		} catch (err) { | ||||
| 			console.warn(`missing localization '${key}'`); | ||||
| 			return key; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -7,7 +7,8 @@ | ||||
|  * Language manager for SW | ||||
|  */ | ||||
| import { get, set } from 'idb-keyval'; | ||||
| import { I18n, type Locale } from '@/scripts/i18n.js'; | ||||
| import { I18n } from '../../../frontend/src/scripts/i18n.js'; | ||||
| import type { Locale } from '../../../../locales/index.js'; | ||||
|  | ||||
| class SwLang { | ||||
| 	public cacheName = `mk-cache-${_VERSION_}`; | ||||
| @@ -23,7 +24,7 @@ class SwLang { | ||||
| 		return this.fetchLocale(); | ||||
| 	} | ||||
|  | ||||
| 	public i18n: Promise<I18n> | null = null; | ||||
| 	public i18n: Promise<I18n<Locale>> | null = null; | ||||
|  | ||||
| 	public fetchLocale(): Promise<I18n<Locale>> { | ||||
| 		return (this.i18n = this._fetch()); | ||||
|   | ||||
| @@ -14,15 +14,22 @@ import { getUrlWithLoginId } from '@/scripts/login-id.js'; | ||||
|  | ||||
| export const cli = new Misskey.api.APIClient({ origin, fetch: (...args): Promise<Response> => fetch(...args) }); | ||||
|  | ||||
| export async function api<E extends keyof Misskey.Endpoints, O extends Misskey.Endpoints[E]['req']>(endpoint: E, userId?: string, options?: O): Promise<void | ReturnType<typeof cli.request<E, O>>> { | ||||
| 	let account: { token: string; id: string } | void = undefined; | ||||
| export async function api< | ||||
| 	E extends keyof Misskey.Endpoints, | ||||
| 	P extends Misskey.Endpoints[E]['req'] | ||||
| >(endpoint: E, userId?: string, params?: P): Promise<Misskey.api.SwitchCaseResponseType<E, P> | undefined> { | ||||
| 	let account: Pick<Misskey.entities.SignupResponse, 'id' | 'token'> | undefined; | ||||
|  | ||||
| 	if (userId) { | ||||
| 		account = await getAccountFromId(userId); | ||||
| 		if (!account) return; | ||||
| 	} | ||||
|  | ||||
| 	return cli.request(endpoint, options, account?.token); | ||||
| 	return (cli.request as <E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>( | ||||
| 		endpoint: E, | ||||
| 		params: P, | ||||
| 		credential?: string | null, | ||||
| 	) => Promise<Misskey.api.SwitchCaseResponseType<E, P>>)(endpoint, params, account?.token); | ||||
| } | ||||
|  | ||||
| // mark-all-as-read送出を1秒間隔に制限する | ||||
| @@ -33,7 +40,7 @@ export function sendMarkAllAsRead(userId: string): Promise<null | undefined | vo | ||||
| 	return new Promise(resolve => { | ||||
| 		setTimeout(() => { | ||||
| 			readBlockingStatus.set(userId, false); | ||||
| 			api('notifications/mark-all-as-read', userId).then(resolve, resolve); | ||||
| 			(api('notifications/mark-all-as-read', userId) as Promise<void>).then(resolve, resolve); | ||||
| 		}, 1000); | ||||
| 	}); | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,8 @@ | ||||
| import { get } from 'idb-keyval'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import type { PushNotificationDataMap } from '@/types.js'; | ||||
| import type { I18n, Locale } from '@/scripts/i18n.js'; | ||||
| import type { I18n } from '../../frontend/src/scripts/i18n.js'; | ||||
| import type { Locale } from '../../../locales/index.js'; | ||||
| import { createEmptyNotification, createNotification } from '@/scripts/create-notification.js'; | ||||
| import { swLang } from '@/scripts/lang.js'; | ||||
| import * as swos from '@/scripts/operations.js'; | ||||
| @@ -30,8 +31,8 @@ globalThis.addEventListener('activate', ev => { | ||||
| async function offlineContentHTML() { | ||||
| 	const i18n = await (swLang.i18n ?? swLang.fetchLocale()) as Partial<I18n<Locale>>; | ||||
| 	const messages = { | ||||
| 		title: i18n.ts?._offlineScreen?.title ?? 'Offline - Could not connect to server', | ||||
| 		header: i18n.ts?._offlineScreen?.header ?? 'Could not connect to server', | ||||
| 		title: i18n.ts?._offlineScreen.title ?? 'Offline - Could not connect to server', | ||||
| 		header: i18n.ts?._offlineScreen.header ?? 'Could not connect to server', | ||||
| 		reload: i18n.ts?.reload ?? 'Reload', | ||||
| 	}; | ||||
|  | ||||
| @@ -159,8 +160,8 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv | ||||
| 					case 'markAllAsRead': | ||||
| 						await globalThis.registration.getNotifications() | ||||
| 							.then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close())); | ||||
| 						await get('accounts').then(accounts => { | ||||
| 							return Promise.all(accounts.map(async account => { | ||||
| 						await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts').then(accounts => { | ||||
| 							return Promise.all((accounts ?? []).map(async account => { | ||||
| 								await swos.sendMarkAllAsRead(account.id); | ||||
| 							})); | ||||
| 						}); | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| 	"compilerOptions": { | ||||
| 		"allowJs": true, | ||||
| 		"noEmitOnError": false, | ||||
| 		"noImplicitAny": false, | ||||
| 		"noImplicitReturns": true, | ||||
| 		"noUnusedParameters": false, | ||||
| 		"noUnusedLocals": true, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user