diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6a1f6f66..dbbcc6bde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### General +- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました + +### Client +- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上 + +### Server +- + + ## 2025.9.0 ### Client diff --git a/package.json b/package.json index 80148d75f4..7f19734453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.9.0-beta.0", + "version": "2025.9.0", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1757823175259-sensitive-ad.js b/packages/backend/migration/1757823175259-sensitive-ad.js new file mode 100644 index 0000000000..46f0f270ab --- /dev/null +++ b/packages/backend/migration/1757823175259-sensitive-ad.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SensitiveAd1757823175259 { + name = 'SensitiveAd1757823175259' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" ADD "isSensitive" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "isSensitive"`); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index f8abfb2f98..2da614a120 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -117,6 +117,7 @@ export class MetaEntityService { ratio: ad.ratio, imageUrl: ad.imageUrl, dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive ? true : undefined, })), notesPerOneAd: instance.notesPerOneAd, enableEmail: instance.enableEmail, diff --git a/packages/backend/src/models/Ad.ts b/packages/backend/src/models/Ad.ts index 108e991c70..0d402fcbe8 100644 --- a/packages/backend/src/models/Ad.ts +++ b/packages/backend/src/models/Ad.ts @@ -54,10 +54,17 @@ export class MiAd { length: 8192, nullable: false, }) public memo: string; + @Column('integer', { default: 0, nullable: false, }) public dayOfWeek: number; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/ad.ts b/packages/backend/src/models/json-schema/ad.ts index b01b39a38b..d88ac23894 100644 --- a/packages/backend/src/models/json-schema/ad.ts +++ b/packages/backend/src/models/json-schema/ad.ts @@ -60,5 +60,10 @@ export const packedAdSchema = { optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: false, + nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 357ff26041..a0e7d490b3 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -195,6 +195,10 @@ export const packedMetaLiteSchema = { type: 'integer', optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: true, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 06047b58a6..6606202118 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -34,13 +34,22 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'MeDetailed', - properties: { - token: { - type: 'string', - optional: false, nullable: false, + allOf: [ + { + type: 'object', + ref: 'MeDetailed', }, - }, + { + type: 'object', + optional: false, nullable: false, + properties: { + token: { + type: 'string', + optional: false, nullable: false, + }, + }, + } + ], }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index 955154f4fb..01697ae185 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -36,6 +36,7 @@ export const paramDef = { startsAt: { type: 'integer' }, imageUrl: { type: 'string', minLength: 1 }, dayOfWeek: { type: 'integer' }, + isSensitive: { type: 'boolean' }, }, required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'], } as const; @@ -55,6 +56,7 @@ export default class extends Endpoint { // eslint- expiresAt: new Date(ps.expiresAt), startsAt: new Date(ps.startsAt), dayOfWeek: ps.dayOfWeek, + isSensitive: ps.isSensitive, url: ps.url, imageUrl: ps.imageUrl, priority: ps.priority, @@ -73,6 +75,7 @@ export default class extends Endpoint { // eslint- expiresAt: ad.expiresAt.toISOString(), startsAt: ad.startsAt.toISOString(), dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive, url: ad.url, imageUrl: ad.imageUrl, priority: ad.priority, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 4f897d98e4..f67cad5bd2 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -63,6 +63,7 @@ export default class extends Endpoint { // eslint- expiresAt: ad.expiresAt.toISOString(), startsAt: ad.startsAt.toISOString(), dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive, url: ad.url, imageUrl: ad.imageUrl, memo: ad.memo, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 4e3d731aca..a3d9aaddc6 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -39,6 +39,7 @@ export const paramDef = { expiresAt: { type: 'integer' }, startsAt: { type: 'integer' }, dayOfWeek: { type: 'integer' }, + isSensitive: { type: 'boolean' }, }, required: ['id'], } as const; @@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint- expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined, startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined, dayOfWeek: ps.dayOfWeek, + isSensitive: ps.isSensitive, }); const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id }); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index ed5952d4c5..c6d477a92f 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -22,17 +22,26 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'UserList', - properties: { - likedCount: { - type: 'number', - optional: true, nullable: false, + allOf: [ + { + type: 'object', + ref: 'UserList', }, - isLiked: { - type: 'boolean', - optional: true, nullable: false, + { + type: 'object', + optional: false, nullable: false, + properties: { + likedCount: { + type: 'number', + optional: true, nullable: false, + }, + isLiked: { + type: 'boolean', + optional: true, nullable: false, + }, + }, }, - }, + ], }, errors: { diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts index 7e24bb7904..056a16ba15 100644 --- a/packages/backend/test-federation/test/utils.ts +++ b/packages/backend/test-federation/test/utils.ts @@ -68,7 +68,6 @@ async function createAdmin(host: Host): Promise { ADMIN_CACHE.set(host, { id: res.id, - // @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this i: res.token, }); return res as Misskey.entities.SignupResponse; diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index 5fdd25b32d..f03efac6d0 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -20,6 +20,6 @@ "dependencies": { "estree-walker": "3.0.3", "magic-string": "0.30.17", - "vite": "7.0.6" + "vite": "7.0.7" } } diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 179d811e77..46247e40d5 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -46,9 +46,71 @@ export default [ allowSingleExtends: true, }], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], + // window ... グローバルスコープと衝突し、予期せぬ結果を招くため + // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため + // close ... window.closeと衝突 or 紛らわしい + // open ... window.openと衝突 or 紛らわしい + // fetch ... window.fetchと衝突 or 紛らわしい + // location ... window.locationと衝突 or 紛らわしい + // document ... window.documentと衝突 or 紛らわしい + // history ... window.historyと衝突 or 紛らわしい + // scroll ... window.scrollと衝突 or 紛らわしい + // setTimeout ... window.setTimeoutと衝突 or 紛らわしい + // setInterval ... window.setIntervalと衝突 or 紛らわしい + // clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい + // clearInterval ... window.clearIntervalと衝突 or 紛らわしい + 'id-denylist': ['error', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'], + 'no-restricted-globals': [ + 'error', + { + 'name': 'open', + 'message': 'Use `window.open`.', + }, + { + 'name': 'close', + 'message': 'Use `window.close`.', + }, + { + 'name': 'fetch', + 'message': 'Use `window.fetch`.', + }, + { + 'name': 'location', + 'message': 'Use `window.location`.', + }, + { + 'name': 'document', + 'message': 'Use `window.document`.', + }, + { + 'name': 'history', + 'message': 'Use `window.history`.', + }, + { + 'name': 'scroll', + 'message': 'Use `window.scroll`.', + }, + { + 'name': 'setTimeout', + 'message': 'Use `window.setTimeout`.', + }, + { + 'name': 'setInterval', + 'message': 'Use `window.setInterval`.', + }, + { + 'name': 'clearTimeout', + 'message': 'Use `window.clearTimeout`.', + }, + { + 'name': 'clearInterval', + 'message': 'Use `window.clearInterval`.', + }, + { + 'name': 'name', + 'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている', + }, + ], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { alphabetical: false, diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 2f5a3fc369..6a3392c021 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -34,7 +34,7 @@ "tsconfig-paths": "4.2.0", "typescript": "5.9.2", "uuid": "11.1.0", - "vite": "7.1.4", + "vite": "7.1.5", "vue": "3.5.21" }, "devDependencies": { diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index 9d69437c30..961cbcef66 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -33,7 +33,7 @@ import type { Theme } from '@/theme.js'; console.log('Misskey Embed'); //#region Embedパラメータの取得・パース -const params = new URLSearchParams(location.search); +const params = new URLSearchParams(window.location.search); const embedParams = parseEmbedParams(params); if (_DEV_) console.log(embedParams); //#endregion @@ -81,7 +81,7 @@ storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload }); //#endregion // サイズの制限 -document.documentElement.style.maxWidth = '500px'; +window.document.documentElement.style.maxWidth = '500px'; // iframeIdの設定 function setIframeIdHandler(event: MessageEvent) { @@ -114,16 +114,16 @@ app.provide(DI.embedParams, embedParams); const rootEl = ((): HTMLElement => { const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID); if (currentRoot) { console.warn('multiple import detected'); return currentRoot; } - const root = document.createElement('div'); + const root = window.document.createElement('div'); root.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(root); + window.document.body.appendChild(root); return root; })(); @@ -159,7 +159,7 @@ console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hu //#endregion function removeSplash() { - const splash = document.getElementById('splash'); + const splash = window.document.getElementById('splash'); if (splash) { splash.style.opacity = '0'; splash.style.pointerEvents = 'none'; diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue index 0bff048ce4..71f0ee9294 100644 --- a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -19,7 +19,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha const canvasPromise = new Promise(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); @@ -34,7 +34,7 @@ const canvasPromise = new Promise(resol ); resolve(workers); } else { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue index 4a116e317a..7add3bb53f 100644 --- a/packages/frontend-embed/src/components/EmInstanceTicker.vue +++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue @@ -29,7 +29,7 @@ const props = defineProps<{ // if no instance data is given, this is for the local instance const instance = props.instance ?? { name: serverMetadata.name, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content, + themeColor: (window.document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content, }; const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico'); diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue index b5aaa95894..0a8ac9c05a 100644 --- a/packages/frontend-embed/src/components/EmMention.vue +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -27,7 +27,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; -const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-mention')); +const bg = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-mention')); bg.setAlpha(0.1); const bgCss = bg.toRgbString(); diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue index 94a91305f4..bd49d127a9 100644 --- a/packages/frontend-embed/src/components/EmPagination.vue +++ b/packages/frontend-embed/src/components/EmPagination.vue @@ -134,7 +134,7 @@ const isBackTop = ref(false); const empty = computed(() => items.value.size === 0); const error = ref(false); -const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -353,7 +353,7 @@ watch(visibility, () => { BACKGROUND_PAUSE_WAIT_SEC * 1000); } else { // 'visible' if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } else { isPausingUpdate = false; @@ -447,11 +447,11 @@ onBeforeMount(() => { init().then(() => { if (props.pagination.reversed) { nextTick(() => { - setTimeout(toBottom, 800); + window.setTimeout(toBottom, 800); // scrollToBottomでmoreFetchingボタンが画面外まで出るまで // more = trueを遅らせる - setTimeout(() => { + window.setTimeout(() => { moreFetching.value = false; }, 2000); }); @@ -461,11 +461,11 @@ onBeforeMount(() => { onBeforeUnmount(() => { if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } if (preventAppearFetchMoreTimer.value) { - clearTimeout(preventAppearFetchMoreTimer.value); + window.clearTimeout(preventAppearFetchMoreTimer.value); preventAppearFetchMoreTimer.value = null; } scrollObserver.value?.disconnect(); diff --git a/packages/frontend-embed/src/server-context.ts b/packages/frontend-embed/src/server-context.ts index a84a1a726a..c061d5a6f1 100644 --- a/packages/frontend-embed/src/server-context.ts +++ b/packages/frontend-embed/src/server-context.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -const providedContextEl = document.getElementById('misskey_embedCtx'); +const providedContextEl = window.document.getElementById('misskey_embedCtx'); export type ServerContext = { clip?: Misskey.entities.Clip; diff --git a/packages/frontend-embed/src/server-metadata.ts b/packages/frontend-embed/src/server-metadata.ts index 6c94aacd48..ad9b5a1a91 100644 --- a/packages/frontend-embed/src/server-metadata.ts +++ b/packages/frontend-embed/src/server-metadata.ts @@ -6,7 +6,7 @@ import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/misskey-api.js'; -const providedMetaEl = document.getElementById('misskey_meta'); +const providedMetaEl = window.document.getElementById('misskey_meta'); const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts index c9b1c0d0c6..c7bc5df85d 100644 --- a/packages/frontend-embed/src/theme.ts +++ b/packages/frontend-embed/src/theme.ts @@ -35,15 +35,15 @@ export function assertIsTheme(theme: Record): theme is Theme { export function applyTheme(theme: Theme, persist = true) { if (timeout) window.clearTimeout(timeout); - document.documentElement.classList.add('_themeChanging_'); + window.document.documentElement.classList.add('_themeChanging_'); timeout = window.setTimeout(() => { - document.documentElement.classList.remove('_themeChanging_'); + window.document.documentElement.classList.remove('_themeChanging_'); }, 1000); const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; - document.documentElement.dataset.colorScheme = colorScheme; + window.document.documentElement.dataset.colorScheme = colorScheme; // Deep copy const _theme = JSON.parse(JSON.stringify(theme)); @@ -55,7 +55,7 @@ export function applyTheme(theme: Theme, persist = true) { const props = compile(_theme); - for (const tag of document.head.children) { + for (const tag of window.document.head.children) { if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { tag.setAttribute('content', props['htmlThemeColor']); break; @@ -63,7 +63,7 @@ export function applyTheme(theme: Theme, persist = true) { } for (const [k, v] of Object.entries(props)) { - document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); + window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); } // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue index 4ba5968a91..711d0eae6d 100644 --- a/packages/frontend-embed/src/ui.vue +++ b/packages/frontend-embed/src/ui.vue @@ -52,8 +52,8 @@ function safeURIDecode(str: string): string { } } -const page = location.pathname.split('/')[2]; -const contentId = safeURIDecode(location.pathname.split('/')[3]); +const page = window.location.pathname.split('/')[2]; +const contentId = safeURIDecode(window.location.pathname.split('/')[3]); if (_DEV_) console.log(page, contentId); const embedParams = inject(DI.embedParams, defaultEmbedParams); diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index 6453be0042..b972cfdb27 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -51,9 +51,71 @@ export default [ allowSingleExtends: true, }], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], + // window ... グローバルスコープと衝突し、予期せぬ結果を招くため + // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため + // close ... window.closeと衝突 or 紛らわしい + // open ... window.openと衝突 or 紛らわしい + // fetch ... window.fetchと衝突 or 紛らわしい + // location ... window.locationと衝突 or 紛らわしい + // document ... window.documentと衝突 or 紛らわしい + // history ... window.historyと衝突 or 紛らわしい + // scroll ... window.scrollと衝突 or 紛らわしい + // setTimeout ... window.setTimeoutと衝突 or 紛らわしい + // setInterval ... window.setIntervalと衝突 or 紛らわしい + // clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい + // clearInterval ... window.clearIntervalと衝突 or 紛らわしい + 'id-denylist': ['error', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'], + 'no-restricted-globals': [ + 'error', + { + 'name': 'open', + 'message': 'Use `window.open`.', + }, + { + 'name': 'close', + 'message': 'Use `window.close`.', + }, + { + 'name': 'fetch', + 'message': 'Use `window.fetch`.', + }, + { + 'name': 'location', + 'message': 'Use `window.location`.', + }, + { + 'name': 'document', + 'message': 'Use `window.document`.', + }, + { + 'name': 'history', + 'message': 'Use `window.history`.', + }, + { + 'name': 'scroll', + 'message': 'Use `window.scroll`.', + }, + { + 'name': 'setTimeout', + 'message': 'Use `window.setTimeout`.', + }, + { + 'name': 'setInterval', + 'message': 'Use `window.setInterval`.', + }, + { + 'name': 'clearTimeout', + 'message': 'Use `window.clearTimeout`.', + }, + { + 'name': 'clearInterval', + 'message': 'Use `window.clearInterval`.', + }, + { + 'name': 'name', + 'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている', + }, + ], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { alphabetical: false, diff --git a/packages/frontend-shared/js/config.ts b/packages/frontend-shared/js/config.ts index ac5c5629f3..6272d3f6b9 100644 --- a/packages/frontend-shared/js/config.ts +++ b/packages/frontend-shared/js/config.ts @@ -4,15 +4,15 @@ */ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -const address = new URL(document.querySelector('meta[property="instance_url"]')?.content || location.href); -const siteName = document.querySelector('meta[property="og:site_name"]')?.content; +const address = new URL(window.document.querySelector('meta[property="instance_url"]')?.content || window.location.href); +const siteName = window.document.querySelector('meta[property="og:site_name"]')?.content; export const host = address.host; export const hostname = address.hostname; export const url = address.origin; export const port = address.port; -export const apiUrl = location.origin + '/api'; -export const wsOrigin = location.origin; +export const apiUrl = window.location.origin + '/api'; +export const wsOrigin = window.location.origin; export const lang = localStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; export const version = _VERSION_; diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts index 9057b896c6..5578cffdec 100644 --- a/packages/frontend-shared/js/scroll.ts +++ b/packages/frontend-shared/js/scroll.ts @@ -51,7 +51,7 @@ export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknow // - toleranceの範囲内に収まる程度の微量なスクロールが発生した let prevTopVisible = firstTopVisible; const onScroll = () => { - if (!document.body.contains(el)) return; + if (!window.document.body.contains(el)) return; const topVisible = isHeadVisible(el, tolerance); if (topVisible !== prevTopVisible) { @@ -78,7 +78,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 const containerOrWindow = container ?? window; const onScroll = () => { - if (!document.body.contains(el)) return; + if (!window.document.body.contains(el)) return; if (isTailVisible(el, 1, container)) { cb(); if (once) removeListener(); @@ -145,8 +145,8 @@ export function isTailVisible(el: HTMLElement, tolerance = 1, container = getScr // https://ja.javascript.info/size-and-scroll-window#ref-932 export function getBodyScrollHeight() { return Math.max( - document.body.scrollHeight, document.documentElement.scrollHeight, - document.body.offsetHeight, document.documentElement.offsetHeight, - document.body.clientHeight, document.documentElement.clientHeight, + window.document.body.scrollHeight, window.document.documentElement.scrollHeight, + window.document.body.offsetHeight, window.document.documentElement.offsetHeight, + window.document.body.clientHeight, window.document.documentElement.clientHeight, ); } diff --git a/packages/frontend-shared/js/use-document-visibility.ts b/packages/frontend-shared/js/use-document-visibility.ts index b1197e68da..a87c1f1bab 100644 --- a/packages/frontend-shared/js/use-document-visibility.ts +++ b/packages/frontend-shared/js/use-document-visibility.ts @@ -7,18 +7,18 @@ import { onMounted, onUnmounted, ref } from 'vue'; import type { Ref } from 'vue'; export function useDocumentVisibility(): Ref { - const visibility = ref(document.visibilityState); + const visibility = ref(window.document.visibilityState); const onChange = (): void => { - visibility.value = document.visibilityState; + visibility.value = window.document.visibilityState; }; onMounted(() => { - document.addEventListener('visibilitychange', onChange); + window.document.addEventListener('visibilitychange', onChange); }); onUnmounted(() => { - document.removeEventListener('visibilitychange', onChange); + window.document.removeEventListener('visibilitychange', onChange); }); return visibility; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 07831f8bb3..bacdc7b133 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -78,7 +78,7 @@ "tsconfig-paths": "4.2.0", "typescript": "5.9.2", "v-code-diff": "1.13.1", - "vite": "7.1.4", + "vite": "7.1.5", "vue": "3.5.21", "vuedraggable": "next", "wanakana": "5.3.1" diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e2febf7225..a41fdbc45d 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only - + - - - - - - + - @@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 3f7519a43f..705301a6a6 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - - +
{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} {{ cancelText ?? i18n.ts.cancel }} @@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; type Input = { @@ -67,17 +60,9 @@ type Input = { maxLength?: number; }; -type SelectItem = { - value: any; - text: string; -}; - type Select = { - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - })[]; - default: string | null; + items: MkSelectItem[]; + default: OptionValue | null; }; type Result = string | number | true | null; @@ -115,7 +100,6 @@ const emit = defineEmits<{ const modal = useTemplateRef('modal'); const inputValue = ref(props.input?.default ?? null); -const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed(() => { if (props.input) { @@ -134,6 +118,14 @@ const okButtonDisabledReason = computed props.select?.items ?? []), + initialValue: props.select?.default ?? null, +}); + // overload function を使いたいので lint エラーを無視する function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 17823deb85..0cb8499699 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only - + - - - {{ i18n.ts._embedCodeGen.header }} {{ i18n.ts._embedCodeGen.rounded }} @@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; @@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro const header = ref(props.params?.header ?? true); const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); -const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); +const { + model: colorMode, + def: colorModeDef, +} = useMkSelect({ + items: [ + { value: 'auto', label: i18n.ts.syncDeviceDarkMode }, + { value: 'light', label: i18n.ts.light }, + { value: 'dark', label: i18n.ts.dark }, + ], + initialValue: props.params?.colorMode ?? 'auto', +}); + const rounded = ref(props.params?.rounded ?? true); const border = ref(props.params?.border ?? true); diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 8d697499a5..142ccb12a3 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only - + - @@ -77,7 +76,8 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -120,16 +120,14 @@ function cancel() { dialog.value?.close(); } -function getEnumLabel(e: EnumItem) { - return typeof e === 'string' ? e : e.label; -} - -function getEnumValue(e: EnumItem) { - return typeof e === 'string' ? e : e.value; -} - -function getEnumKey(e: EnumItem) { - return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value); +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); } function getRadioKey(e: RadioFormItem['options'][number]) { diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 15578ca1c9..13048a2e1b 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - - - - - - - - - - - - - - - - - - - - - + +
@@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - - - +
@@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only - diff --git a/packages/frontend/src/composables/use-lowres-time.ts b/packages/frontend/src/composables/use-lowres-time.ts new file mode 100644 index 0000000000..3c5b561f51 --- /dev/null +++ b/packages/frontend/src/composables/use-lowres-time.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, readonly, computed } from 'vue'; + +const time = ref(Date.now()); + +export const TIME_UPDATE_INTERVAL = 10000; // 10秒 + +/** + * 精度が求められないが定期的に更新しないといけない時計で使用(10秒に一度更新)。 + * tickを各コンポーネントで行うのではなく、ここで一括して行うことでパフォーマンスを改善する。 + * + * ※ マウント前の時刻を返す可能性があるため、通常は`useLowresTime`を使用する +*/ +export const lowresTime = readonly(time); + +/** + * 精度が求められないが定期的に更新しないといけない時計で使用(10秒に一度更新)。 + * tickを各コンポーネントで行うのではなく、ここで一括して行うことでパフォーマンスを改善する。 + * + * 必ず現在時刻以降を返すことを保証するコンポーサブル + */ +export function useLowresTime() { + // lowresTime自体はマウント前の時刻を返す可能性があるため、必ず現在時刻以降を返すことを保証する + const now = Date.now(); + return computed(() => Math.max(time.value, now)); +} + +window.setInterval(() => { + time.value = Date.now(); +}, TIME_UPDATE_INTERVAL); diff --git a/packages/frontend/src/composables/use-mkselect.ts b/packages/frontend/src/composables/use-mkselect.ts new file mode 100644 index 0000000000..7cb470d169 --- /dev/null +++ b/packages/frontend/src/composables/use-mkselect.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import type { Ref, MaybeRefOrGetter } from 'vue'; +import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; + +type UnwrapReadonlyItems = T extends readonly (infer U)[] ? U[] : T; + +/** 指定したオプション定義をもとに型を狭めたrefを生成するコンポーサブル */ +export function useMkSelect< + const TItemsInput extends MaybeRefOrGetter, + const TItems extends TItemsInput extends MaybeRefOrGetter ? U : never, + TInitialValue extends OptionValue | void = void, + TItemsValue = GetMkSelectValueTypesFromDef>, + ModelType = TInitialValue extends void + ? TItemsValue + : (TItemsValue | TInitialValue) +>(opts: { + items: TItemsInput; + initialValue?: (TInitialValue | (OptionValue extends TItemsValue ? OptionValue : TInitialValue)) & ( + TItemsValue extends TInitialValue + ? unknown + : { 'Error: Type of initialValue must include all types of items': TItemsValue } + ); +}): { + def: TItemsInput; + model: Ref; +} { + const model = ref(opts.initialValue ?? null); + + return { + def: opts.items, + model: model as Ref, + }; +} diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index 649561cd75..8cac1b6d2a 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -24,7 +24,7 @@ export const globalEvents = new EventEmitter(); export function useGlobalEvent( event: T, - callback: Events[T], + callback: EventEmitter.EventListener, ): void { globalEvents.on(event, callback); onBeforeUnmount(() => { diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 20d44032df..6dffcf9478 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -94,7 +94,7 @@ export class Pizzax { private mergeState(value: X, def: X): X { if (this.isPureObject(value) && this.isPureObject(def)) { - const merged = deepMerge(value, def); + const merged = deepMerge>(value, def); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 56a2b8d269..6c5f04c6b5 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -14,6 +14,7 @@ import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; import type { UploaderFeatures } from '@/composables/use-uploader.js'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -35,9 +36,9 @@ import { focusParent } from '@/utility/focus.js'; export const openingWindowsCount = ref(0); export type ApiWithDialogCustomErrors = Record; -export const apiWithDialog = (( +export const apiWithDialog = (( endpoint: E, - data: P, + data: Misskey.Endpoints[E]['req'], token?: string | null | undefined, customErrors?: ApiWithDialogCustomErrors, ) => { @@ -502,50 +503,15 @@ export function authenticateDialog(): Promise<{ }); } -type SelectItem = { - value: C; - text: string; -}; - -// default が指定されていたら result は null になり得ないことを保証する overload function -export function select(props: { +export function select(props: { title?: string; text?: string; - default: string; - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - } | undefined)[]; + default?: D; + items: (MkSelectItem | undefined)[]; }): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: C; -}>; -export function select(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C | null; -}>; -export function select(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C | null; + canceled: false; result: Exclude extends null ? C | null : C; }> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 7e514c5a73..4640812756 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -11,12 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only - -
@@ -42,51 +36,33 @@ import XEmoji from './emojis.emoji.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; +import { customEmojis, customEmojiCategories } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; -const customEmojiTags = getCustomEmojiTags(); const q = ref(''); const searchEmojis = ref(null); -const selectedTags = ref(new Set()); function search() { - if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) { + if (q.value === '' || q.value == null) { searchEmojis.value = null; return; } - if (selectedTags.value.size === 0) { - const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); + const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); - if (queryarry) { - searchEmojis.value = customEmojis.value.filter(emoji => - queryarry.includes(`:${emoji.name}:`), - ); - } else { - searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); - } + if (queryarry) { + searchEmojis.value = customEmojis.value.filter(emoji => + queryarry.includes(`:${emoji.name}:`), + ); } else { - searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t))); - } -} - -function toggleTag(tag) { - if (selectedTags.value.has(tag)) { - selectedTags.value.delete(tag); - } else { - selectedTags.value.add(tag); + searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); } } watch(q, () => { search(); }); - -watch(selectedTags, () => { - search(); -}, { deep: true });