diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-08-31 08:42:43 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-31 08:42:43 +0000 |
| commit | ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b (patch) | |
| tree | 2c7aef5ba1626009377faf96941a57411dd619e5 /packages/frontend/src | |
| parent | Merge pull request #16244 from misskey-dev/develop (diff) | |
| parent | Release: 2025.8.0 (diff) | |
| download | misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.tar.gz misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.tar.bz2 misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.zip | |
Merge pull request #16335 from misskey-dev/develop
Release: 2025.8.0
Diffstat (limited to 'packages/frontend/src')
304 files changed, 4438 insertions, 2653 deletions
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 354fb95544..111a4abbfd 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -16,7 +16,7 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/verify-email', '/install-extensions']; if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) { subBoot(); diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 3693ac3308..afa2ecb911 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -23,7 +23,7 @@ export async function getAccounts(): Promise<{ host: string; id: Misskey.entities.User['id']; username: Misskey.entities.User['username']; - user?: Misskey.entities.User | null; + user?: Misskey.entities.MeDetailed | null; token: string | null; }[]> { const tokens = store.s.accountTokens; @@ -38,7 +38,7 @@ export async function getAccounts(): Promise<{ })); } -async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { +async function addAccount(host: string, user: Misskey.entities.MeDetailed, token: AccountWithToken['token']) { if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user }); @@ -149,9 +149,10 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie export async function refreshCurrentAccount() { if (!$i) return; + const me = $i; return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => { if (reason === isAccountDeleted) { - removeAccount(host, $i.id); + removeAccount(host, me.id); if (Object.keys(store.s.accountTokens).length > 0) { login(Object.values(store.s.accountTokens)[0]); } else { @@ -214,46 +215,58 @@ export async function openAccountMenu(opts: { includeCurrentAccount?: boolean; withExtraOperation: boolean; active?: Misskey.entities.User['id']; - onChoose?: (account: Misskey.entities.User) => void; + onChoose?: (account: Misskey.entities.MeDetailed) => void; }, ev: MouseEvent) { if (!$i) return; + const me = $i; - function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem { + const callback = opts.onChoose; + + function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.MeDetailed | null | undefined, token: string | null): MenuItem { if (account) { return { type: 'user' as const, user: account, active: opts.active != null ? opts.active === id : false, action: async () => { - if (opts.onChoose) { - opts.onChoose(account); + if (callback) { + callback(account); } else { switchAccount(host, id); } }, }; - } else { + } else if (token != null) { return { type: 'button' as const, text: username, active: opts.active != null ? opts.active === id : false, action: async () => { - if (opts.onChoose) { + if (callback) { fetchAccount(token, id).then(account => { - opts.onChoose(account); + callback(account); }); } else { switchAccount(host, id); } }, }; + } else { + return { + type: 'button' as const, + text: username, + active: opts.active != null ? opts.active === id : false, + action: async () => { + // TODO + }, + }; } } const menuItems: MenuItem[] = []; // TODO: $iのホストも比較したいけど通常null - const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token)); + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== me.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token)); if (opts.withExtraOperation) { menuItems.push({ diff --git a/packages/frontend/src/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts index a27ece512e..9c330da3c5 100644 --- a/packages/frontend/src/aiscript/ui.ts +++ b/packages/frontend/src/aiscript/ui.ts @@ -4,11 +4,11 @@ */ import { utils, values } from '@syuilo/aiscript'; -import { genId } from '@/utility/id.js'; import { ref } from 'vue'; -import type { Ref } from 'vue'; import * as Misskey from 'misskey-js'; import { assertStringAndIsIn } from './common.js'; +import type { Ref } from 'vue'; +import { genId } from '@/utility/id.js'; const ALIGNS = ['left', 'center', 'right'] as const; const FONTS = ['serif', 'sans-serif', 'monospace'] as const; @@ -21,16 +21,15 @@ type BorderStyle = (typeof BORDER_STYLES)[number]; export type AsUiComponentBase = { id: string; hidden?: boolean; + children?: AsUiComponent['id'][]; }; export type AsUiRoot = AsUiComponentBase & { type: 'root'; - children: AsUiComponent['id'][]; }; export type AsUiContainer = AsUiComponentBase & { type: 'container'; - children?: AsUiComponent['id'][]; align?: Align; bgColor?: string; fgColor?: string; @@ -123,7 +122,6 @@ export type AsUiSelect = AsUiComponentBase & { export type AsUiFolder = AsUiComponentBase & { type: 'folder'; - children?: AsUiComponent['id'][]; title?: string; opened?: boolean; }; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 992bde9bd1..574012ff78 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,9 +5,10 @@ import { computed, watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, updateLocale, locale, apiUrl } from '@@/js/config.js'; +import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import { storeBootloaderErrors } from '@@/js/store-boot-errors'; import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; @@ -28,6 +29,7 @@ import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; import { $i } from '@/i.js'; +import { launchPlugins } from '@/plugin.js'; export async function common(createVue: () => Promise<App<Element>>) { console.info(`Misskey v${version}`); @@ -79,25 +81,7 @@ export async function common(createVue: () => Promise<App<Element>>) { //#endregion //#region Detect language & fetch translations - const localeVersion = miLocalStorage.getItem('localeVersion'); - const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); - - async function fetchAndUpdateLocale({ useCache } = { useCache: true }) { - const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' }; - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - miLocalStorage.setItem('locale', newLocale); - miLocalStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } - } - - if (localeOutdated) { - fetchAndUpdateLocale(); - } + storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload }); if (import.meta.hot) { import.meta.hot.on('locale-update', async (updatedLang: string) => { @@ -106,7 +90,8 @@ export async function common(createVue: () => Promise<App<Element>>) { await new Promise(resolve => { window.setTimeout(resolve, 500); }); - await fetchAndUpdateLocale({ useCache: false }); + // fetch with cache: 'no-store' to ensure the latest locale is fetched + await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text()); window.location.reload(); } }); @@ -168,28 +153,35 @@ export async function common(createVue: () => Promise<App<Element>>) { // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) watch(store.r.darkMode, (darkMode) => { - applyTheme(darkMode - ? (prefer.s.darkTheme ?? defaultDarkTheme) - : (prefer.s.lightTheme ?? defaultLightTheme), - ); - }, { immediate: miLocalStorage.getItem('theme') == null }); + const theme = (() => { + if (darkMode) { + return isSafeMode ? defaultDarkTheme : (prefer.s.darkTheme ?? defaultDarkTheme); + } else { + return isSafeMode ? defaultLightTheme : (prefer.s.lightTheme ?? defaultLightTheme); + } + })(); + + applyTheme(theme); + }, { immediate: isSafeMode || miLocalStorage.getItem('theme') == null }); window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light'; - const darkTheme = prefer.model('darkTheme'); - const lightTheme = prefer.model('lightTheme'); + if (!isSafeMode) { + const darkTheme = prefer.model('darkTheme'); + const lightTheme = prefer.model('lightTheme'); - watch(darkTheme, (theme) => { - if (store.s.darkMode) { - applyTheme(theme ?? defaultDarkTheme); - } - }); + watch(darkTheme, (theme) => { + if (store.s.darkMode) { + applyTheme(theme ?? defaultDarkTheme); + } + }); - watch(lightTheme, (theme) => { - if (!store.s.darkMode) { - applyTheme(theme ?? defaultLightTheme); - } - }); + watch(lightTheme, (theme) => { + if (!store.s.darkMode) { + applyTheme(theme ?? defaultLightTheme); + } + }); + } //#region Sync dark mode if (prefer.s.syncDeviceDarkMode) { @@ -203,17 +195,19 @@ export async function common(createVue: () => Promise<App<Element>>) { }); //#endregion - if (prefer.s.darkTheme && store.s.darkMode) { - if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); - } else if (prefer.s.lightTheme && !store.s.darkMode) { - if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme); - } + if (!isSafeMode) { + if (prefer.s.darkTheme && store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); + } else if (prefer.s.lightTheme && !store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme); + } - fetchInstanceMetaPromise.then(() => { - // TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア - if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme)); - if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme)); - }); + fetchInstanceMetaPromise.then(() => { + // TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア + if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme)); + if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme)); + }); + } watch(prefer.r.overridedDeviceKind, (kind) => { updateDeviceKind(kind); @@ -345,6 +339,12 @@ export async function common(createVue: () => Promise<App<Element>>) { }); } + try { + await launchPlugins(); + } catch (error) { + console.error('Failed to launch plugins:', error); + } + app.mount(rootEl); // boot.jsのやつを解除 diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index ae4e0445db..18817d3f79 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -26,10 +26,9 @@ import { mainRouter } from '@/router.js'; import { makeHotkey } from '@/utility/hotkey.js'; import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; -import { launchPlugins } from '@/plugin.js'; import { updateCurrentAccountPartial } from '@/accounts.js'; -import { signout } from '@/signout.js'; import { migrateOldSettings } from '@/pref-migrate.js'; +import { unisonReload } from '@/utility/unison-reload.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(async () => { @@ -79,8 +78,6 @@ export async function mainBoot() { } } - launchPlugins(); - try { if (prefer.s.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; @@ -371,11 +368,6 @@ export async function mainBoot() { }); }); - main.on('unreadAntenna', () => { - updateCurrentAccountPartial({ hasUnreadAntenna: true }); - sound.playMisskeySfx('antenna'); - }); - main.on('newChatMessage', () => { updateCurrentAccountPartial({ hasUnreadChatMessages: true }); sound.playMisskeySfx('chatMessage'); @@ -391,6 +383,8 @@ export async function mainBoot() { } // shortcut + let safemodeRequestCount = 0; + let safemodeRequestTimer: number | null = null; const keymap = { 'p|n': () => { if ($i == null) return; @@ -402,6 +396,24 @@ export async function mainBoot() { 's': () => { mainRouter.push('/search'); }, + 'g': { + callback: () => { + // mを5回押すとセーフモードに入る + safemodeRequestCount++; + if (safemodeRequestCount >= 5) { + miLocalStorage.setItem('isSafeMode', 'true'); + unisonReload(); + } else { + if (safemodeRequestTimer != null) { + window.clearTimeout(safemodeRequestTimer); + } + safemodeRequestTimer = window.setTimeout(() => { + safemodeRequestCount = 0; + }, 300); + } + }, + allowRepeat: true, + }, } as const satisfies Keymap; window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index b62096bbe9..2eb17b1b9e 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -2,14 +2,13 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; -import type { StoryObj } from '@storybook/vue3'; + +import { action } from 'storybook/actions'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index b907b5b25a..fff29262f1 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 70766634ce..c786e9fe9f 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum', }]" > - <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }"> + <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg ?? '' }"> <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img"> </div> </div> @@ -61,8 +61,8 @@ import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utili const props = withDefaults(defineProps<{ user: Misskey.entities.User; - withLocked: boolean; - withDescription: boolean; + withLocked?: boolean; + withDescription?: boolean; }>(), { withLocked: true, withDescription: true, @@ -71,7 +71,7 @@ const props = withDefaults(defineProps<{ const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null); const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x))); -function fetch() { +function _fetch_() { misskeyApi('users/achievements', { userId: props.user.id }).then(res => { achievements.value = []; for (const t of ACHIEVEMENT_TYPES) { @@ -84,11 +84,11 @@ function fetch() { function clickHere() { claimAchievement('clickedClickHere'); - fetch(); + _fetch_(); } onMounted(() => { - fetch(); + _fetch_(); }); </script> diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index e57fbcdee3..19a21f6e24 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -44,7 +44,7 @@ function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); const shaderProgram = gl.createProgram(); - if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null; + if (vertexShader == null || fragmentShader == null) return null; gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); @@ -71,8 +71,10 @@ onMounted(() => { canvas.width = width; canvas.height = height; - const gl = canvas.getContext('webgl', { premultipliedAlpha: true }); - if (gl == null) return; + const maybeGl = canvas.getContext('webgl', { premultipliedAlpha: true }); + if (maybeGl == null) return; + + const gl = maybeGl; gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -229,8 +231,8 @@ onMounted(() => { gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); if (isChromatic()) { - gl!.uniform1f(u_time, 0); - gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + gl.uniform1f(u_time, 0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } else { function render(timeStamp: number) { let sizeChanged = false; @@ -249,8 +251,8 @@ onMounted(() => { gl.viewport(0, 0, width, height); } - gl!.uniform1f(u_time, timeStamp); - gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + gl.uniform1f(u_time, timeStamp); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); handle = window.requestAnimationFrame(render); } @@ -263,6 +265,8 @@ onUnmounted(() => { if (handle) { window.cancelAnimationFrame(handle); } + + // TODO: WebGLリソースの解放 }); </script> diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index 627cb0c4ff..743bdda032 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts index 4d921a4c48..5c4b05481a 100644 --- a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts index 5878b52fb9..1a70cb745c 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 64ccb708aa..15aab8daed 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index 0a569b3beb..4420cc4f05 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a77ebd6ac5..b729128a21 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { nextTick, onMounted, useTemplateRef } from 'vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -45,7 +46,7 @@ const props = defineProps<{ inline?: boolean; link?: boolean; to?: string; - linkBehavior?: null | 'window' | 'browser'; + linkBehavior?: MkABehavior; autofocus?: boolean; wait?: boolean; danger?: boolean; diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index 4304c2e2b7..095805ba95 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts index 47ca864dc0..33a61c8f7a 100644 --- a/packages/frontend/src/components/MkChannelList.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -3,14 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkChannelList from './MkChannelList.vue'; +import type { StoryObj } from '@storybook/vue3'; +import { Paginator } from '@/utility/paginator.js'; export const Default = { render(args) { return { @@ -33,10 +32,7 @@ export const Default = { }; }, args: { - pagination: { - endpoint: 'channels/search', - limit: 10, - }, + paginator: new Paginator('channels/search', {}), }, parameters: { chromatic: { diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 4d67bba70d..c54081ad42 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -589,7 +589,10 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -611,7 +614,10 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -626,7 +632,10 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -641,7 +650,10 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -664,7 +676,10 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -680,7 +695,10 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -695,7 +713,10 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/notes', { userId: userId, limit: props.limit, span: props.span }); return { series: [...(props.args?.withoutAll ? [] : [{ name: 'All', @@ -727,7 +748,10 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserPvChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/pv', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Unique PV (user)', @@ -754,7 +778,10 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -769,7 +796,10 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -784,7 +814,10 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/drive', { userId: userId, limit: props.limit, span: props.span }); return { bytes: true, series: [{ diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts index 8268adc36f..74fdff6fdd 100644 --- a/packages/frontend/src/components/MkChatHistories.stories.impl.ts +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -4,7 +4,7 @@ */ import { http, HttpResponse } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { chatMessage } from '../../.storybook/fakes'; import MkChatHistories from './MkChatHistories.vue'; import type { StoryObj } from '@storybook/vue3'; diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index 6e1eb13d61..f9012742cb 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; diff --git a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts index c76b6fd08e..24b8e9119b 100644 --- a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import MkCodeEditor from './MkCodeEditor.vue'; const code = `for (let i, 100) { <: if (i % 15 == 0) "FizzBuzz" diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts index 3df92ca858..f8ec58bbcc 100644 --- a/packages/frontend/src/components/MkColorInput.stories.impl.ts +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import MkColorInput from './MkColorInput.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index 78cb4120de..bd6733f9a8 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 7f592fba79..6c07eac47a 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, useTemplateRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; @@ -55,17 +55,19 @@ const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; const loading = ref(true); -const ok = async () => { - const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { - const croppedImage = await cropper?.getCropperImage(); - const croppedSection = await cropper?.getCropperSelection(); +async function ok() { + const promise = new Promise<Blob>(async (res) => { + if (cropper == null) throw new Error('Cropper is not initialized'); + + const croppedImage = await cropper.getCropperImage()!; + const croppedSection = await cropper.getCropperSelection()!; // 拡大率を計算し、(ほぼ)元の大きさに戻す const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth; const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate; - const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); - croppedCanvas?.toBlob(blob => { + const croppedCanvas = await croppedSection.$toCanvas({ width: widthToRender }); + croppedCanvas.toBlob(blob => { if (!blob) return; res(blob); }); @@ -74,25 +76,27 @@ const ok = async () => { const f = await promise; emit('ok', f); - dialogEl.value!.close(); -}; + if (dialogEl.value != null) dialogEl.value.close(); +} -const cancel = () => { +function cancel() { emit('cancel'); - dialogEl.value!.close(); -}; + if (dialogEl.value != null) dialogEl.value.close(); +} -const onImageLoad = () => { +function onImageLoad() { loading.value = false; if (cropper) { cropper.getCropperImage()!.$center('contain'); cropper.getCropperSelection()!.$center(); } -}; +} onMounted(() => { - cropper = new Cropper(imgEl.value!, { + if (imgEl.value == null) return; // TSを黙らすため + + cropper = new Cropper(imgEl.value, { }); const computedStyle = getComputedStyle(window.document.documentElement); @@ -104,16 +108,22 @@ onMounted(() => { selection.outlined = true; window.setTimeout(() => { - cropper!.getCropperImage()!.$center('contain'); + if (cropper == null) return; + cropper.getCropperImage()!.$center('contain'); selection.$center(); }, 100); // モーダルオープンアニメーションが終わったあとで再度調整 window.setTimeout(() => { - cropper!.getCropperImage()!.$center('contain'); + if (cropper == null) return; + cropper.getCropperImage()!.$center('contain'); selection.$center(); }, 500); }); + +onUnmounted(() => { + URL.revokeObjectURL(imgUrl); +}); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkCwButton.stories.impl.ts b/packages/frontend/src/components/MkCwButton.stories.impl.ts index bbe5f4eddb..de38b98c4b 100644 --- a/packages/frontend/src/components/MkCwButton.stories.impl.ts +++ b/packages/frontend/src/components/MkCwButton.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { file } from '../../.storybook/fakes.js'; import MkCwButton from './MkCwButton.vue'; diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts index 57c7916049..c168d31cce 100644 --- a/packages/frontend/src/components/MkDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts index 71d0c20c63..bd1b74281d 100644 --- a/packages/frontend/src/components/MkDonation.stories.impl.ts +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkDonation from './MkDonation.vue'; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 933383775c..9981ee77ac 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index e6c7c2f645..6fa8d2253f 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts index 4394eebfda..00930af380 100644 --- a/packages/frontend/src/components/MkDrive.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 9fe1c7ef21..9f1364aec4 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -145,18 +145,19 @@ import { claimAchievement } from '@/utility/achievements.js'; import { prefer } from '@/preferences.js'; import { chooseFileFromPcAndUpload, selectDriveFolder } from '@/utility/drive.js'; import { store } from '@/store.js'; -import { isSeparatorNeeded, getSeparatorInfo, makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import { makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; import { globalEvents, useGlobalEvent } from '@/events.js'; import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; const props = withDefaults(defineProps<{ - initialFolder?: Misskey.entities.DriveFolder['id'] | null; + initialFolder?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id'] | null; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; }>(), { + initialFolder: null, multiple: false, select: null, }); @@ -293,7 +294,7 @@ function onDragleave() { draghover.value = false; } -function onDrop(ev: DragEvent) { +function onDrop(ev: DragEvent): void | boolean { draghover.value = false; if (!ev.dataTransfer) return; @@ -363,7 +364,7 @@ function onDrop(ev: DragEvent) { //#endregion } -function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) { +function onUploadRequested(files: File[], folder?: Misskey.entities.DriveFolder | null) { os.launchUploader(files, { folderId: folder?.id ?? null, }); diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 88afdef114..3933421fc0 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :forceBlurhash="forceBlurhash" /> <img - v-else-if="isThumbnailAvailable" + v-else-if="isThumbnailAvailable && file.thumbnailUrl != null" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" diff --git a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue index 2ebab1088f..d5b6b0cbec 100644 --- a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue @@ -39,13 +39,13 @@ withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFolder[]): void; + (ev: 'done', r?: (Misskey.entities.DriveFolder | null)[]): void; (ev: 'closed'): void; }>(); const dialog = useTemplateRef('dialog'); -const selected = ref<Misskey.entities.DriveFolder[]>([]); +const selected = ref<(Misskey.entities.DriveFolder | null)[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(v: Misskey.entities.DriveFolder[]) { +function onChangeSelection(v: (Misskey.entities.DriveFolder | null)[]) { selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts index bf4158a2c8..cc934040f5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 68da098439..6904c417ce 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -141,6 +141,7 @@ import { $i } from '@/i.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { prefer } from '@/preferences.js'; import { useRouter } from '@/router.js'; +import { haptic } from '@/utility/haptic.js'; const router = useRouter(); @@ -151,7 +152,7 @@ const props = withDefaults(defineProps<{ asDrawer?: boolean; asWindow?: boolean; asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; }>(), { showPinned: true, }); @@ -431,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, const key = getKey(emoji); emit('chosen', key); + haptic(); + // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { let recents = store.s.recentlyUsedEmojis; @@ -495,7 +498,7 @@ function done(query?: string): boolean | void { function settings() { emit('esc'); - router.push('settings/emoji-palette'); + router.push('/settings/emoji-palette'); } onMounted(() => { @@ -585,6 +588,14 @@ defineExpose({ grid-template-columns: var(--columns); font-size: 30px; + > .config { + aspect-ratio: 1 / 1; + width: auto; + height: auto; + min-width: 0; + font-size: 14px; + } + > .item { aspect-ratio: 1 / 1; width: auto; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 1627dc8760..0ff4e8f38d 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -44,11 +44,11 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; choseAndClose?: boolean; }>(), { manualShowing: null, diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index a2247d844b..c9d18ee731 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo> - <div v-if="isPlugin" class="_gaps_s"> + <div v-if="extension.type === 'plugin'" class="_gaps_s"> <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-info-circle"></i></template> <template #label>{{ i18n.ts.metadata }}</template> @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCode :code="extension.raw"/> </MkFolder> </div> - <div v-else-if="isTheme" class="_gaps_s"> + <div v-else-if="extension.type === 'theme'" class="_gaps_s"> <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-info-circle"></i></template> <template #label>{{ i18n.ts.metadata }}</template> @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSplit> <MkKeyValue> <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + <template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template> </MkKeyValue> </div> </MkFolder> diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index b65f610986..c7361a19c6 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -46,6 +46,7 @@ import { claimAchievement } from '@/utility/achievements.js'; import { pleaseLogin } from '@/utility/please-login.js'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { haptic } from '@/utility/haptic.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -84,6 +85,8 @@ async function onClick() { wait.value = true; + haptic(); + try { if (isFollowing.value) { const { canceled } = await os.confirm({ diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index 96214a9542..eb559e611c 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div v-if="form.modified.value" :class="$style.root"> <div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div> <div style="margin-left: auto;" class="_buttons"> <MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton> @@ -16,16 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; import MkButton from './MkButton.vue'; +import type { useForm } from '@/composables/use-form.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - form: { - modifiedCount: { - value: number; - }; - discard: () => void; - save: () => void; - }; + form: ReturnType<typeof useForm>; canSaving?: boolean; }>(), { canSaving: true, diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue index d8466fa7ca..f734325039 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -14,73 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <div :class="$style.root" class="_gaps"> - <div v-for="[k, v] in Object.entries(fx.params)" :key="k"> - <MkSwitch - v-if="v.type === 'boolean'" - v-model="layer.params[k]" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkSwitch> - <MkRange - v-else-if="v.type === 'number'" - v-model="layer.params[k]" - continuousUpdate - :min="v.min" - :max="v.max" - :step="v.step" - :textConverter="fx.params[k].toViewValue" - @thumbDoubleClicked="() => { - if (fx.params[k].default != null) { - layer.params[k] = fx.params[k].default; - } else { - layer.params[k] = v.min; - } - }" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkRange> - <MkRadios - v-else-if="v.type === 'number:enum'" - v-model="layer.params[k]" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - <option v-for="item in v.enum" :value="item.value">{{ item.label }}</option> - </MkRadios> - <div v-else-if="v.type === 'seed'"> - <MkRange - v-model="layer.params[k]" - continuousUpdate - type="number" - :min="0" - :max="10000" - :step="1" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkRange> - </div> - <MkInput - v-else-if="v.type === 'color'" - :modelValue="getHex(layer.params[k])" - type="color" - @update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkInput> - </div> - </div> + <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" /> </MkFolder> </template> <script setup lang="ts"> import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; -import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkRange from '@/components/MkRange.vue'; +import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue'; import { FXS } from '@/utility/image-effector/fxs.js'; const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); @@ -94,28 +36,4 @@ const emit = defineEmits<{ (e: 'swapUp'): void; (e: 'swapDown'): void; }>(); - -function getHex(c: [number, number, number]) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; -} - -function getRgb(hex: string | number): [number, number, number] | null { - if ( - typeof hex === 'number' || - typeof hex !== 'string' || - !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) - ) { - return null; - } - - const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); - if (m == null) return [0, 0, 0]; - return m.map(x => parseInt(x, 16) / 255) as [number, number, number]; -} </script> - -<style module> -.root { - -} -</style> diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue new file mode 100644 index 0000000000..d7ab620132 --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue @@ -0,0 +1,95 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-for="v, k in paramDefs" :key="k"> + <MkSwitch + v-if="v.type === 'boolean'" + v-model="params[k]"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkSwitch> + <MkRange + v-else-if="v.type === 'number'" + v-model="params[k]" + continuousUpdate + :min="v.min" + :max="v.max" + :step="v.step" + :textConverter="v.toViewValue" + @thumbDoubleClicked="() => { + params[k] = v.default; + }" + > + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkRange> + <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + <option v-for="item in v.enum" :value="item.value"> + <i v-if="item.icon" :class="item.icon"></i> + <template v-else>{{ item.label }}</template> + </option> + </MkRadios> + <div v-else-if="v.type === 'seed'"> + <MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkRange> + </div> + <MkInput v-else-if="v.type === 'color'" :modelValue="getHex(params[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params[k] = c; }"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkInput> + </div> + <div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure"> + {{ i18n.ts._imageEffector.nothingToConfigure }} + </div> +</div> +</template> + +<script setup lang="ts"> +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import { i18n } from '@/i18n.js'; +import type { ImageEffectorRGB, ImageEffectorFxParamDefs } from '@/utility/image-effector/ImageEffector.js'; + +defineProps<{ + paramDefs: ImageEffectorFxParamDefs; +}>(); + +const params = defineModel<Record<string, any>>({ required: true }); + +function getHex(c: ImageEffectorRGB) { + return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; +} + +function getRgb(hex: string | number): ImageEffectorRGB | null { + if ( + typeof hex === 'number' || + typeof hex !== 'string' || + !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) + ) { + return null; + } + + const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); + if (m == null) return [0, 0, 0]; + return m.map(x => parseInt(x, 16) / 255) as ImageEffectorRGB; +} +</script> + +<style module> +.nothingToConfigure { + opacity: 0.7; + text-align: center; + font-size: 14px; + padding: 0 10px; +} +</style> diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts index 339e6d10f3..7da705a23f 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { file } from '../../.storybook/fakes.js'; import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 361aeff4d0..983a0932c3 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -52,15 +52,20 @@ import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; +// テスト環境で Web Worker インスタンスは作成できない +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const isTest = (import.meta.env.MODE === 'test' || window.Cypress != null); + const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { - // テスト環境で Web Worker インスタンスは作成できない - if (import.meta.env.MODE === 'test') { + if (isTest) { const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); return; } + const testWorker = new TestWebGL2(); testWorker.addEventListener('message', event => { if (event.data.result) { @@ -189,7 +194,7 @@ function drawAvg() { } async function draw() { - if (import.meta.env.MODE === 'test' && props.hash == null) return; + if (isTest && props.hash == null) return; drawAvg(); diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 584afff55c..d8725ade0b 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -34,9 +34,10 @@ import { deviceKind } from '@/utility/device-kind.js'; import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; anchor?: { x: string; y: string; }; }>(), { + anchorElement: null, anchor: () => ({ x: 'right', y: 'center' }), }); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 309ef727da..163f172f57 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -17,11 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@@/js/config.js'; +import { maybeMakeRelative } from '@@/js/url.js'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import { useTooltip } from '@/composables/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/utility/url-preview.js'; -import type { MkABehavior } from '@/components/global/MkA.vue'; -import { maybeMakeRelative } from '@@/js/url.js'; const props = withDefaults(defineProps<{ url: string; @@ -39,10 +39,12 @@ const el = ref<HTMLElement | { $el: HTMLElement }>(); if (isEnabledUrlPreview.value) { useTooltip(el, (showing) => { + const anchorElement = el.value instanceof HTMLElement ? el.value : el.value?.$el; + if (anchorElement == null) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el.value instanceof HTMLElement ? el.value : el.value?.$el, + anchorElement: anchorElement, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index b7052ad918..e3bb39549f 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > - <button v-if="hide" :class="$style.hidden" @click="show"> + <button v-if="hide" :class="$style.hidden" @click="reveal"> <div :class="$style.hiddenTextWrapper"> <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> @@ -157,7 +157,7 @@ const audioEl = useTemplateRef('audioEl'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); -async function show() { +async function reveal() { if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index f23cf507fb..7730e01a9f 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> - <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="show"> + <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> @@ -37,7 +37,7 @@ const props = defineProps<{ const hide = ref(true); -async function show() { +async function reveal() { if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 1e5eb06a31..99ea606a11 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -96,10 +96,10 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages) ? props.image.url : prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) - : props.image.thumbnailUrl, + : props.image.thumbnailUrl!, ); -async function onclick(ev: MouseEvent) { +async function reveal(ev: MouseEvent) { if (!props.controls) { return; } diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 4a1100c324..bfc8179e13 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -94,6 +94,8 @@ async function calcAspectRatio() { onMounted(() => { calcAspectRatio(); + if (gallery.value == null) return; // TSを黙らすため + lightbox = new PhotoSwipeLightbox({ dataSource: props.mediaList .filter(media => { diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 81a5ab27c7..b0f7a909d3 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > - <button v-if="hide" :class="$style.hidden" @click="show"> + <button v-if="hide" :class="$style.hidden" @click="reveal"> <div :class="$style.hiddenTextWrapper"> <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> @@ -178,7 +178,7 @@ function hasFocus() { // eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); -async function show() { +async function reveal() { if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index f7cd72b6c6..37c3a3f5e3 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -16,7 +16,7 @@ import type { MenuItem } from '@/types/menu.js'; const props = defineProps<{ items: MenuItem[]; - targetElement: HTMLElement; + anchorElement: HTMLElement; rootElement: HTMLElement; width?: number; }>(); @@ -36,10 +36,10 @@ const SCROLLBAR_THICKNESS = 16; function setPosition() { if (el.value == null) return; const rootRect = props.rootElement.getBoundingClientRect(); - const parentRect = props.targetElement.getBoundingClientRect(); + const parentRect = props.anchorElement.getBoundingClientRect(); const myRect = el.value.getBoundingClientRect(); - let left = props.targetElement.offsetWidth; + let left = props.anchorElement.offsetWidth; let top = (parentRect.top - rootRect.top) - 8; if (rootRect.left + left + myRect.width >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = -myRect.width; @@ -59,7 +59,7 @@ function onChildClosed(actioned?: boolean) { } } -watch(() => props.targetElement, () => { +watch(() => props.anchorElement, () => { setPosition(); }); diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index fbae4f0d8a..6c8fac934c 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -208,7 +208,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/> + <XChild ref="child" :items="childMenu" :anchorElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/> </div> </div> </template> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 06686ddfc0..660d5a26be 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -91,7 +91,7 @@ const emit = defineEmits<{ (ev: 'opened'): void; (ev: 'click'): void; (ev: 'esc'): void; - (ev: 'close'): void; + (ev: 'close'): void; // TODO: (refactor) closing に改名する (ev: 'closed'): void; }>(); @@ -148,7 +148,6 @@ function close(opts: { useSendAnimation?: boolean } = {}) { useSendAnime.value = true; } - // eslint-disable-next-line vue/no-mutating-props if (props.anchorElement) props.anchorElement.style.pointerEvents = 'auto'; showing.value = false; emit('close'); @@ -319,7 +318,6 @@ const alignObserver = new ResizeObserver((entries, observer) => { onMounted(() => { watch(() => props.anchorElement, async () => { if (props.anchorElement) { - // eslint-disable-next-line vue/no-mutating-props props.anchorElement.style.pointerEvents = 'none'; } fixed.value = (type.value === 'drawer') || (getFixedContainer(props.anchorElement) != null); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0605030d5b..729bded03c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" tabindex="0" > - <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> @@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> </div> - <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> @@ -265,24 +265,22 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul let note = deepClone(props.note); -// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある -// https://github.com/aiscript-dev/aiscript/issues/937 -//// plugin -//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -//if (noteViewInterruptors.length > 0) { -// let result: Misskey.entities.Note | null = deepClone(note); -// for (const interruptor of noteViewInterruptors) { -// try { -// result = await interruptor.handler(result!) as Misskey.entities.Note | null; -// } catch (err) { -// console.error(err); -// } -// } -// note = result as Misskey.entities.Note; -//} +// plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +if (noteViewInterruptors.length > 0) { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = interruptor.handler(result!) as Misskey.entities.Note | null; + } catch (err) { + console.error(err); + } + } + note = result as Misskey.entities.Note; +} const isRenote = Misskey.note.isPureRenote(note); -const appearNote = getAppearNote(note); +const appearNote = getAppearNote(note) ?? note; const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ note: appearNote, parentNote: note, @@ -431,7 +429,7 @@ if (!props.mock) { showing, users, count: appearNote.renoteCount, - targetElement: renoteButton.value, + anchorElement: renoteButton.value, }, { closed: () => dispose(), }); @@ -454,7 +452,7 @@ if (!props.mock) { reaction: '❤️', users, count: $appearNote.reactionCount, - targetElement: reactButton.value!, + anchorElement: reactButton.value!, }, { closed: () => dispose(), }); @@ -462,14 +460,12 @@ if (!props.mock) { } } -function renote(viaKeyboard = false) { +function renote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); - os.popupMenu(menu, renoteButton.value, { - viaKeyboard, - }); + os.popupMenu(menu, renoteButton.value); subscribeManuallyToNoteCapture(); } @@ -658,7 +654,7 @@ function showRenoteMenu(): void { getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), - ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, + ...(($i?.isModerator || $i?.isAdmin) ? [getUnrenote()] : []), ], renoteTime.value); } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index fb37bb1ae6..48fd9908bd 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> </div> - <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> @@ -287,23 +287,22 @@ const inChannel = inject('inChannel', null); let note = deepClone(props.note); -// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある -//// plugin -//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -//if (noteViewInterruptors.length > 0) { -// let result: Misskey.entities.Note | null = deepClone(note); -// for (const interruptor of noteViewInterruptors) { -// try { -// result = await interruptor.handler(result!) as Misskey.entities.Note | null; -// } catch (err) { -// console.error(err); -// } -// } -// note = result as Misskey.entities.Note; -//} +// plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +if (noteViewInterruptors.length > 0) { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = interruptor.handler(result!) as Misskey.entities.Note | null; + } catch (err) { + console.error(err); + } + } + note = result as Misskey.entities.Note; +} const isRenote = Misskey.note.isPureRenote(note); -const appearNote = getAppearNote(note); +const appearNote = getAppearNote(note) ?? note; const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ note: appearNote, parentNote: note, @@ -393,6 +392,9 @@ const reactionsPaginator = markRaw(new Paginator('notes/reactions', { })); useTooltip(renoteButton, async (showing) => { + const anchorElement = renoteButton.value; + if (anchorElement == null) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.id, limit: 11, @@ -406,7 +408,7 @@ useTooltip(renoteButton, async (showing) => { showing, users, count: appearNote.renoteCount, - targetElement: renoteButton.value, + anchorElement: anchorElement, }, { closed: () => dispose(), }); @@ -429,7 +431,7 @@ if (appearNote.reactionAcceptance === 'likeOnly') { reaction: '❤️', users, count: $appearNote.reactionCount, - targetElement: reactButton.value!, + anchorElement: reactButton.value!, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index e684cf2a30..ed0b3ad555 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <MkAvatar :class="$style.avatar" :user="note.user" link preview/> +<div v-if="note" :class="$style.root"> + <MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="note.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> @@ -19,6 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> +<div v-else :class="$style.deleted"> + {{ i18n.ts.deletedNote }} +</div> </template> <script lang="ts" setup> @@ -27,9 +30,11 @@ import * as Misskey from 'misskey-js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note | null; }>(); const showContent = ref(false); @@ -50,9 +55,12 @@ const showContent = ref(false); width: 34px; height: 34px; border-radius: 8px; - position: sticky !important; - top: calc(16px + var(--MI-stickyTop, 0px)); - left: 0; + + &.useSticky { + position: sticky !important; + top: calc(16px + var(--MI-stickyTop, 0px)); + left: 0; + } } .main { @@ -101,4 +109,14 @@ const showContent = ref(false); height: 48px; } } + +.deleted { + text-align: center; + padding: 8px !important; + margin: 8px 8px 0 8px; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); + border-radius: 8px; +} </style> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 4fd1c210cb..3f5cc51938 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -4,7 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="!muted" :class="[$style.root, { [$style.children]: depth > 1 }]"> +<div v-if="note == null" :class="$style.deleted"> + {{ i18n.ts.deletedNote }} +</div> +<div v-else-if="!muted" :class="[$style.root, { [$style.children]: depth > 1 }]"> <div :class="$style.main"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> @@ -53,7 +56,7 @@ import { userPage } from '@/filters/user.js'; import { checkWordMute } from '@/utility/check-word-mute.js'; const props = withDefaults(defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note | null; detail?: boolean; // how many notes are in between this one and the note being viewed in detail @@ -62,12 +65,12 @@ const props = withDefaults(defineProps<{ depth: 1, }); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); +const muted = ref(props.note && $i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const showContent = ref(false); const replies = ref<Misskey.entities.Note[]>([]); -if (props.detail) { +if (props.detail && props.note) { misskeyApi('notes/children', { noteId: props.note.id, limit: 5, @@ -160,4 +163,14 @@ if (props.detail) { margin: 8px 8px 0 8px; border-radius: 8px; } + +.deleted { + text-align: center; + padding: 8px !important; + margin: 8px 8px 0 8px; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); + border-radius: 8px; +} </style> diff --git a/packages/frontend/src/components/MkNotesTimeline.vue b/packages/frontend/src/components/MkNotesTimeline.vue index 83af7db26f..d94cf3924c 100644 --- a/packages/frontend/src/components/MkNotesTimeline.vue +++ b/packages/frontend/src/components/MkNotesTimeline.vue @@ -4,21 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPagination :paginator="paginator" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl"> +<MkPagination :paginator="paginator" :direction="direction" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl"> <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"> <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> - <div :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <div + v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i - 1].createdAt, note.createdAt)" + :data-scroll-anchor="note.id" + :class="{ '_gaps': !noGap }" + > + <div :class="[$style.date, { [$style.noGap]: noGap }]"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i - 1].createdAt, note.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i - 1].createdAt, note.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + <div v-if="note._shouldInsertAd_" :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> </div> - <div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> + <div v-else-if="note._shouldInsertAd_" :class="{ '_gaps': !noGap }" :data-scroll-anchor="note.id"> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> <div :class="$style.ad"> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> @@ -43,11 +50,14 @@ import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-sep const props = withDefaults(defineProps<{ paginator: T; noGap?: boolean; + + direction?: 'up' | 'down' | 'both'; autoLoad?: boolean; pullToRefresh?: boolean; withControl?: boolean; }>(), { autoLoad: true, + direction: 'down', pullToRefresh: true, withControl: true, }); @@ -103,7 +113,10 @@ defineExpose({ opacity: 0.75; padding: 8px 8px; margin: 0 auto; - border-bottom: solid 0.5px var(--MI_THEME-divider); + + &.noGap { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } } .ad:empty { diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 1310ea6a77..d21e09a984 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -23,8 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div :class="$style.root" class="_forceShrinkSpacer"> - <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount" :router="windowRouter"/> - <RouterView v-else :key="reloadCount" :router="windowRouter"/> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount.toString() + ':stacking'" :router="windowRouter"/> + <RouterView v-else :key="reloadCount.toString() + ':non-stacking'" :router="windowRouter"/> </div> </MkWindow> </template> @@ -58,20 +58,15 @@ const windowRouter = createRouter(props.initialPath); const pageMetadata = ref<null | PageMetadata>(null); const windowEl = useTemplateRef('windowEl'); -const history = ref<{ path: string; }[]>([{ +const _history_ = ref<{ path: string; }[]>([{ path: windowRouter.getCurrentFullPath(), }]); const buttonsLeft = computed(() => { - const buttons: Record<string, unknown>[] = []; - - if (history.value.length > 1) { - buttons.push({ - icon: 'ti ti-arrow-left', - onClick: back, - }); - } - - return buttons; + return _history_.value.length > 1 ? [{ + icon: 'ti ti-arrow-left', + title: i18n.ts.goBack, + onClick: back, + }] : []; }); const buttonsRight = computed(() => { const buttons = [{ @@ -97,12 +92,12 @@ function getSearchMarker(path: string) { const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath)); windowRouter.addListener('push', ctx => { - history.value.push({ path: ctx.fullPath }); + _history_.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('replace', ctx => { - history.value.pop(); - history.value.push({ path: ctx.fullPath }); + _history_.value.pop(); + _history_.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('change', ctx => { @@ -150,8 +145,8 @@ const contextmenu = computed(() => ([{ }])); function back() { - history.value.pop(); - windowRouter.replace(history.value.at(-1)!.path); + _history_.value.pop(); + windowRouter.replaceByPath(_history_.value.at(-1)!.path); } function reload() { @@ -163,7 +158,7 @@ function close() { } function expand() { - mainRouter.push(windowRouter.getCurrentFullPath(), 'forcePage'); + mainRouter.pushByPath(windowRouter.getCurrentFullPath(), 'forcePage'); windowEl.value?.close(); } diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 8ca1c80e84..4ea62f2812 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else key="_root_" class="_gaps"> - <slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> - <div v-if="paginator.order.value === 'oldest'"> - <MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer()"> + <div v-if="direction === 'up' || direction === 'both'" v-show="upButtonVisible"> + <MkButton v-if="!upButtonLoading" :class="$style.more" primary rounded @click="upButtonClick"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else/> </div> - <div v-else v-show="paginator.canFetchOlder.value"> - <MkButton v-if="!paginator.fetchingOlder.value" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder()"> + <slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> + <div v-if="direction === 'down' || direction === 'both'" v-show="downButtonVisible"> + <MkButton v-if="!downButtonLoading" :class="$style.more" primary rounded @click="downButtonClick"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else/> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup generic="T extends IPaginator"> import { isLink } from '@@/js/is-link.js'; -import { onMounted, watch, unref } from 'vue'; +import { onMounted, computed, watch, unref } from 'vue'; import type { UnwrapRef } from 'vue'; import type { IPaginator } from '@/utility/paginator.js'; import MkButton from '@/components/MkButton.vue'; @@ -58,11 +58,20 @@ import * as os from '@/os.js'; const props = withDefaults(defineProps<{ paginator: T; + + // ページネーションを進める方向 + // up: 上方向 + // down: 下方向 (default) + // both: 双方向 + // NOTE: この方向はページネーションの方向であって、アイテムの並び順ではない + direction?: 'up' | 'down' | 'both'; + autoLoad?: boolean; pullToRefresh?: boolean; withControl?: boolean; }>(), { autoLoad: true, + direction: 'down', pullToRefresh: true, withControl: false, }); @@ -93,6 +102,36 @@ if (props.paginator.computedParams) { }, { immediate: false, deep: true }); } +const upButtonVisible = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.canFetchOlder.value : props.paginator.canFetchNewer.value; +}); +const upButtonLoading = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.fetchingOlder.value : props.paginator.fetchingNewer.value; +}); + +function upButtonClick() { + if (props.paginator.order.value === 'oldest') { + props.paginator.fetchOlder(); + } else { + props.paginator.fetchNewer(); + } +} + +const downButtonVisible = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.canFetchNewer.value : props.paginator.canFetchOlder.value; +}); +const downButtonLoading = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.fetchingNewer.value : props.paginator.fetchingOlder.value; +}); + +function downButtonClick() { + if (props.paginator.order.value === 'oldest') { + props.paginator.fetchNewer(); + } else { + props.paginator.fetchOlder(); + } +} + defineSlots<{ empty: () => void; default: (props: { items: UnwrapRef<T['items']> }) => void; diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 22fe189a63..174c923bcf 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </section> <section v-else-if="expiration === 'after'"> - <MkInput v-model="after" small type="number" min="1" class="input"> + <MkInput v-model="after" small type="number" :min="1" class="input"> <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> <MkSelect v-model="unit" small> diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue index 002950cdf1..739f55125b 100644 --- a/packages/frontend/src/components/MkPositionSelector.vue +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -44,6 +44,11 @@ const y = defineModel<string>('y', { default: 'center' }); height: 32px; background: var(--MI_THEME-panel); border-radius: 4px; + transition: background 0.1s ease; + + &:not(.active):hover { + background: var(--MI_THEME-buttonHoverBg); + } &.active { background: var(--MI_THEME-accentedBg); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 174a73e0fd..56683b8f8c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -907,6 +907,11 @@ async function post(ev?: MouseEvent) { if (uploader.items.value.some(x => x.uploaded == null)) { await uploadFiles(); + + // アップロード失敗したものがあったら中止 + if (uploader.items.value.some(x => x.uploaded == null)) { + return; + } } let postData = { @@ -954,7 +959,16 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; + const storedAccount = storedAccounts.find(x => x.id === postAccount.value?.id); + if (storedAccount && storedAccount.token != null) { + token = storedAccount.token; + } else { + await os.alert({ + type: 'error', + text: 'cannot find the token of the selected account.', + }); + return; + } } posting.value = true; diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 1f7796bd83..bf332e706e 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -57,7 +57,7 @@ async function _close() { modal.value?.close(); } -function onEsc(ev: KeyboardEvent) { +function onEsc() { _close(); } diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index c792ff3488..89aca5d29b 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -27,6 +27,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; import { i18n } from '@/i18n.js'; import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; +import { haptic } from '@/utility/haptic.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -203,6 +204,8 @@ function moving(event: MouseEvent | TouchEvent) { pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; + + if (isPulledEnough.value) haptic(); } /** diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 9c37eb5e72..c651d3a3f5 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -78,7 +78,7 @@ function subscribe() { // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters return promiseDialog(registration.value.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), + applicationServerKey: urlBase64ToBase64(instance.swPublickey), }) .then(async subscription => { pushSubscription.value = subscription; @@ -131,22 +131,16 @@ function encode(buffer: ArrayBuffer | null) { } /** - * Convert the URL safe base64 string to a Uint8Array + * Convert the URL safe base64 string to a base64 string * @param base64String base64 string */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { +function urlBase64ToBase64(base64String: string): string { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; + return base64; } if (navigator.serviceWorker == null) { diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 67a9094cad..c0acfa8c60 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -167,7 +167,7 @@ function onMouseenter() { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl.value ?? undefined, + anchorElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); @@ -191,7 +191,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl.value ?? undefined, + anchorElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 7d62456e03..2bfdfa7599 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -28,7 +28,7 @@ if (props.withTooltip) { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), { showing, reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'), - targetElement: elRef.value.$el, + anchorElement: elRef.value.$el, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue index 77ca841ad0..971ebc060b 100644 --- a/packages/frontend/src/components/MkReactionTooltip.vue +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> <MkReactionIcon :reaction="reaction" :class="$style.icon" :noStyle="true"/> <div :class="$style.name">{{ reaction.replace('@.', '') }}</div> @@ -20,7 +20,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; defineProps<{ showing: boolean; reaction: string; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index d24e0b15bf..1c785f0fd1 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> <div :class="$style.reaction"> <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/> @@ -33,7 +33,7 @@ defineProps<{ reaction: string; users: Misskey.entities.UserLite[]; count: number; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 7d76dffa5a..d96f0e2420 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -38,6 +38,7 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { noteEvents } from '@/composables/use-note-capture.js'; import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ noteId: Misskey.entities.Note['id']; @@ -57,18 +58,22 @@ const emit = defineEmits<{ const buttonEl = useTemplateRef('buttonEl'); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); -const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); const canToggle = computed(() => { + const emoji = customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction); + // TODO - //return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); - return !props.reaction.match(/@\w/) && $i && emoji.value; + //return !props.reaction.match(/@\w/) && $i && emoji && checkReactionPermissions($i, props.note, emoji); + return !props.reaction.match(/@\w/) && $i && emoji; }); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.'); async function toggleReaction() { if (!canToggle.value) return; + if ($i == null) return; + + const me = $i; const oldReaction = props.myReaction; if (oldReaction) { @@ -80,6 +85,7 @@ async function toggleReaction() { if (oldReaction !== props.reaction) { sound.playMisskeySfx('reaction'); + haptic(); } if (mock) { @@ -91,7 +97,7 @@ async function toggleReaction() { noteId: props.noteId, }).then(() => { noteEvents.emit(`unreacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: oldReaction, }); if (oldReaction !== props.reaction) { @@ -99,10 +105,12 @@ async function toggleReaction() { noteId: props.noteId, reaction: props.reaction, }).then(() => { + const emoji = customEmojisMap.get(emojiName.value); + if (emoji == null) return; noteEvents.emit(`reacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: props.reaction, - emoji: emoji.value, + emoji: emoji, }); }); } @@ -118,6 +126,7 @@ async function toggleReaction() { } sound.playMisskeySfx('reaction'); + haptic(); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); @@ -128,10 +137,13 @@ async function toggleReaction() { noteId: props.noteId, reaction: props.reaction, }).then(() => { + const emoji = customEmojisMap.get(emojiName.value); + if (emoji == null) return; + noteEvents.emit(`reacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: props.reaction, - emoji: emoji.value, + emoji: emoji, }); }); // TODO: 上位コンポーネントでやる @@ -214,6 +226,8 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { + if (buttonEl.value == null) return; + const reactions = await misskeyApiGet('notes/reactions', { noteId: props.noteId, type: props.reaction, @@ -228,7 +242,7 @@ if (!mock) { reaction: props.reaction, users, count: props.count, - targetElement: buttonEl.value, + anchorElement: buttonEl.value, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index fc7ba50fb3..f1cc98def4 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -105,9 +105,7 @@ async function addRole() { .map(r => ({ text: r.name, value: r })); const { canceled, result: role } = await os.select({ items }); - if (canceled) { - return; - } + if (canceled || role == null) return; selectedRoleIds.value.push(role.id); } diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 485d163ac4..9cbaf676c7 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -39,13 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; -import { useInterval } from '@@/js/use-interval.js'; -import type { VNode, VNodeChild } from 'vue'; -import type { MenuItem } from '@/types/menu.js'; -import * as os from '@/os.js'; - +<script lang="ts"> type ItemOption = { type?: 'option'; value: string | number | null; @@ -60,11 +54,32 @@ type ItemGroup = { export type MkSelectItem = ItemOption | ItemGroup; +type ValuesOfItems<T> = T extends (infer U)[] + ? U extends { type: 'group'; items: infer V } + ? V extends (infer W)[] + ? W extends { value: infer X } + ? X + : never + : never + : U extends { value: infer Y } + ? Y + : never + : never; +</script> + +<script lang="ts" setup generic="T extends MkSelectItem[]"> +import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; +import { useInterval } from '@@/js/use-interval.js'; +import type { VNode, VNodeChild } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; + // TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) // see: https://github.com/misskey-dev/misskey/issues/15558 +// あと型推論と相性が良くない const props = defineProps<{ - modelValue: string | number | null; + modelValue: ValuesOfItems<T>; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -73,11 +88,11 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; - items?: MkSelectItem[]; + items?: T; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: string | number | null): void; + (ev: 'update:modelValue', value: ValuesOfItems<T>): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue index 65e0d6d9de..1d2dfed297 100644 --- a/packages/frontend/src/components/MkServerSetupWizard.vue +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-planet"></i></template> <div class="_gaps_s"> - <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}</div> + <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}<br><MkLink target="_blank" url="https://wikipedia.org/wiki/Fediverse">{{ i18n.ts.learnMore }}</MkLink></div> <MkRadios v-model="q_federation" :vertical="true"> <option value="yes">{{ i18n.ts.yes }}</option> @@ -63,6 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRadios> <MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo> + + <MkSwitch v-if="q_federation === 'yes'" v-model="q_remoteContentsCleaning"> + <template #label>{{ i18n.ts._serverSetupWizard.remoteContentsCleaning }}</template> + <template #caption>{{ i18n.ts._serverSetupWizard.remoteContentsCleaning_description }}</template> + </MkSwitch> </div> </MkFolder> @@ -111,6 +116,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ serverSettings.federation === 'none' ? i18n.ts.no : i18n.ts.all }}</div> </div> <div> + <div><b>{{ i18n.ts._serverSettings.remoteNotesCleaning }}:</b></div> + <div>{{ serverSettings.enableRemoteNotesCleaning ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> <div><b>FTT:</b></div> <div>{{ serverSettings.enableFanoutTimeline ? i18n.ts.yes : i18n.ts.no }}</div> </div> @@ -124,6 +133,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div> + <div><b>{{ i18n.ts._serverSettings.entrancePageStyle }}:</b></div> + <div>{{ serverSettings.clientOptions.entrancePageStyle }}</div> + </div> + + <div> <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div> <div>{{ defaultPolicies.rateLimitFactor }}</div> </div> @@ -185,7 +199,9 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkLink from '@/components/MkLink.vue'; const emit = defineEmits<{ (ev: 'finished'): void; @@ -200,6 +216,7 @@ const q_name = ref(''); const q_use = ref('single'); const q_scale = ref('small'); const q_federation = ref('yes'); +const q_remoteContentsCleaning = ref(true); const q_adminName = ref(''); const q_adminEmail = ref(''); @@ -217,9 +234,13 @@ const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => { emailRequiredForSignup: q_use.value === 'open', enableIpLogging: q_use.value === 'open', federation: q_federation.value === 'yes' ? 'all' : 'none', + enableRemoteNotesCleaning: q_remoteContentsCleaning.value, enableFanoutTimeline: true, enableFanoutTimelineDbFallback: q_use.value === 'single', enableReactionsBuffering, + clientOptions: { + entrancePageStyle: q_use.value === 'open' ? 'classic' : 'simple', + }, }; }); diff --git a/packages/frontend/src/components/MkServerSetupWizardDialog.vue b/packages/frontend/src/components/MkServerSetupWizardDialog.vue new file mode 100644 index 0000000000..ea2c5dd47f --- /dev/null +++ b/packages/frontend/src/components/MkServerSetupWizardDialog.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="windowEl" + :withOkButton="false" + :okButtonDisabled="false" + :width="500" + :height="600" + @close="onCloseModalWindow" + @closed="emit('closed')" +> + <template #header>Server setup wizard</template> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> + <Suspense> + <template #default> + <MkServerSetupWizard @finished="onWizardFinished"/> + </template> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { useTemplateRef } from 'vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkServerSetupWizard from '@/components/MkServerSetupWizard.vue'; + +const emit = defineEmits<{ + (ev: 'closed'), +}>(); + +const windowEl = useTemplateRef('windowEl'); + +function onWizardFinished() { + windowEl.value?.close(); +} + +function onCloseModalWindow() { + windowEl.value?.close(); +} +</script> + +<style module lang="scss"> +.root { + max-height: 410px; + height: 410px; + display: flex; + flex-direction: column; +} +</style> diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue index cd003a39df..3b3b6a0b73 100644 --- a/packages/frontend/src/components/MkSignin.password.vue +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> - <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha"/> + <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha" :sitekey="null"/> </div> <MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index 3e50bdefd2..bc6ebf0918 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -32,9 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-for="(note, i) in paginator.items.value" :key="note.id"> <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> <div :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> </div> @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> </template> </component> - <button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> <MkLoading v-else :inline="true"/> </button> @@ -297,76 +297,97 @@ function prepend(note: Misskey.entities.Note & MisskeyEntity) { } } -let connection: Misskey.IChannelConnection | null = null; -let connection2: Misskey.IChannelConnection | null = null; - const stream = store.s.realtimeMode ? useStream() : null; +const connections = { + antenna: null as Misskey.IChannelConnection<Misskey.Channels['antenna']> | null, + homeTimeline: null as Misskey.IChannelConnection<Misskey.Channels['homeTimeline']> | null, + localTimeline: null as Misskey.IChannelConnection<Misskey.Channels['localTimeline']> | null, + hybridTimeline: null as Misskey.IChannelConnection<Misskey.Channels['hybridTimeline']> | null, + globalTimeline: null as Misskey.IChannelConnection<Misskey.Channels['globalTimeline']> | null, + main: null as Misskey.IChannelConnection<Misskey.Channels['main']> | null, + userList: null as Misskey.IChannelConnection<Misskey.Channels['userList']> | null, + channel: null as Misskey.IChannelConnection<Misskey.Channels['channel']> | null, + roleTimeline: null as Misskey.IChannelConnection<Misskey.Channels['roleTimeline']> | null, +}; + function connectChannel() { if (stream == null) return; if (props.src === 'antenna') { if (props.antenna == null) return; - connection = stream.useChannel('antenna', { + connections.antenna = stream.useChannel('antenna', { antennaId: props.antenna, }); + connections.antenna.on('note', prepend); } else if (props.src === 'home') { - connection = stream.useChannel('homeTimeline', { + connections.homeTimeline = stream.useChannel('homeTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }); - connection2 = stream.useChannel('main'); + connections.main = stream.useChannel('main'); + connections.homeTimeline.on('note', prepend); } else if (props.src === 'local') { - connection = stream.useChannel('localTimeline', { + connections.localTimeline = stream.useChannel('localTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); + connections.localTimeline.on('note', prepend); } else if (props.src === 'social') { - connection = stream.useChannel('hybridTimeline', { + connections.hybridTimeline = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); + connections.hybridTimeline.on('note', prepend); } else if (props.src === 'global') { - connection = stream.useChannel('globalTimeline', { + connections.globalTimeline = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }); + connections.globalTimeline.on('note', prepend); } else if (props.src === 'mentions') { - connection = stream.useChannel('main'); - connection.on('mention', prepend); + connections.main = stream.useChannel('main'); + connections.main.on('mention', prepend); } else if (props.src === 'directs') { const onNote = note => { if (note.visibility === 'specified') { prepend(note); } }; - connection = stream.useChannel('main'); - connection.on('mention', onNote); + connections.main = stream.useChannel('main'); + connections.main.on('mention', onNote); } else if (props.src === 'list') { if (props.list == null) return; - connection = stream.useChannel('userList', { + connections.userList = stream.useChannel('userList', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); + connections.userList.on('note', prepend); } else if (props.src === 'channel') { if (props.channel == null) return; - connection = stream.useChannel('channel', { + connections.channel = stream.useChannel('channel', { channelId: props.channel, }); + connections.channel.on('note', prepend); } else if (props.src === 'role') { if (props.role == null) return; - connection = stream.useChannel('roleTimeline', { + connections.roleTimeline = stream.useChannel('roleTimeline', { roleId: props.role, }); + connections.roleTimeline.on('note', prepend); } - if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); } function disconnectChannel() { - if (connection) connection.dispose(); - if (connection2) connection2.dispose(); + for (const key in connections) { + const conn = connections[key as keyof typeof connections]; + if (conn != null) { + conn.dispose(); + connections[key as keyof typeof connections] = null; + } + } } if (store.s.realtimeMode) { diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue index 4617e659c8..15e8e2105f 100644 --- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only > <div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item"> <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type) && 'note' in notification" :class="$style.content" :note="notification.note" :withHardMute="true"/> <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/> </div> </component> - <button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> <MkLoading v-else/> </button> @@ -59,7 +59,7 @@ import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-sep import { Paginator } from '@/utility/paginator.js'; const props = defineProps<{ - excludeTypes?: typeof notificationTypes[number][]; + excludeTypes?: typeof notificationTypes[number][] | null; }>(); const rootEl = useTemplateRef('rootEl'); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 3f8d92a61d..dbc673333c 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -52,9 +52,9 @@ SPDX-License-Identifier: AGPL-3.0-only {{ item.label }} </template> <template v-else> - <span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span> + <span style="opacity: 0.7; font-size: 90%; word-break: break-word;">{{ item.parentLabels.join(' > ') }}</span> <br> - <span>{{ item.label }}</span> + <span style="word-break: break-word;">{{ item.label }}</span> </template> </span> </MkA> @@ -95,7 +95,7 @@ export type SuperMenuDef = { <script lang="ts" setup> import { useTemplateRef, ref, watch, nextTick, computed } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; -import type { SearchIndexItem } from '@/utility/settings-search-index.js'; +import type { SearchIndexItem } from '@/utility/inapp-search.js'; import MkInput from '@/components/MkInput.vue'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; @@ -165,12 +165,28 @@ watch(rawSearchQuery, (value) => { }); }; - for (const item of searchIndexItemById.values()) { - if ( - compareStringIncludes(item.label, value) || - item.keywords.some((x) => compareStringIncludes(x, value)) - ) { + // label, keywords, texts の順に優先して表示 + + let items = Array.from(searchIndexItemById.values()); + + for (const item of items) { + if (compareStringIncludes(item.label, value)) { + addSearchResult(item); + items = items.filter(i => i.id !== item.id); + } + } + + for (const item of items) { + if (item.keywords.some((x) => compareStringIncludes(x, value))) { + addSearchResult(item); + items = items.filter(i => i.id !== item.id); + } + } + + for (const item of items) { + if (item.texts.some((x) => compareStringIncludes(x, value))) { addSearchResult(item); + items = items.filter(i => i.id !== item.id); } } } @@ -186,7 +202,7 @@ function searchOnKeyDown(ev: KeyboardEvent) { if (ev.key === 'Enter' && searchSelectedIndex.value != null) { ev.preventDefault(); - router.push(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); + router.pushByPath(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); } else if (ev.key === 'ArrowDown') { ev.preventDefault(); const current = searchSelectedIndex.value ?? -1; diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 92359b773a..9a2bea3616 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { toRefs } from 'vue'; import type { Ref } from 'vue'; import XButton from '@/components/MkSwitch.button.vue'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -48,6 +49,8 @@ const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); emit('change', !checked.value); + + haptic(); }; </script> diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue index 5c4a67b026..57fb6548ba 100644 --- a/packages/frontend/src/components/MkTabs.vue +++ b/packages/frontend/src/components/MkTabs.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.tabs"> +<div :class="[$style.tabs, { [$style.centered]: props.centered }]"> <div :class="$style.tabsInner"> <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="tabHighlightEl" - :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation, [$style.tabHighlightUpper]: tabHighlightUpper }]" ></div> </div> </template> @@ -39,17 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only export type Tab = { key: string; onClick?: (ev: MouseEvent) => void; -} & ( - | { - iconOnly?: false; - title: string; - icon?: string; - } - | { - iconOnly: true; - icon: string; - } -); + iconOnly?: boolean; + title: string; + icon?: string; +}; </script> <script lang="ts" setup> @@ -59,6 +52,8 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; + centered?: boolean; + tabHighlightUpper?: boolean; }>(), { tabs: () => ([] as Tab[]), }); @@ -169,6 +164,16 @@ onUnmounted(() => { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; + + &.centered { + text-align: center; + } +} + +@container (max-width: 450px) { + .tabs { + font-size: 80%; + } } .tabsInner { @@ -227,5 +232,10 @@ onUnmounted(() => { &.animate { transition: width 0.15s ease, left 0.15s ease; } + + &.tabHighlightUpper { + top: 0; + bottom: auto; + } } </style> diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts index ac932c8342..9e8c2d8917 100644 --- a/packages/frontend/src/components/MkTagItem.stories.impl.ts +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkTagItem from './MkTagItem.vue'; diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 3fe80f4ab4..aa041c88e5 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -31,7 +31,7 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showing: boolean; - targetElement?: HTMLElement; + anchorElement?: HTMLElement; x?: number; y?: number; text?: string; @@ -58,7 +58,7 @@ const zIndex = os.claimZIndex('high'); function setPosition() { if (el.value == null) return; const data = calcPopupPosition(el.value, { - anchorElement: props.targetElement, + anchorElement: props.anchorElement, direction: props.direction, align: 'center', innerMargin: props.innerMargin, diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 79ab464cb0..eba8e5472c 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -4,10 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> +<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> + <div v-if="isBeta" :class="$style.beta">{{ i18n.ts.thankYouForTestingBeta }}</div> <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> <MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton> </div> @@ -25,6 +26,8 @@ import { confetti } from '@/utility/confetti.js'; const modal = useTemplateRef('modal'); +const isBeta = version.includes('-beta') || version.includes('-alpha') || version.includes('-rc'); + function whatIsNew() { modal.value?.close(); window.open(`https://misskey-hub.net/docs/releases/#_${version.replace(/\./g, '')}`, '_blank'); @@ -58,6 +61,10 @@ onMounted(() => { margin: 1em 0; } +.beta { + margin: 1em 0; +} + .gotIt { margin: 8px 0 0 0; } diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index fd36d6a82b..09558b0319 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -20,7 +20,7 @@ import { prefer } from '@/preferences.js'; const props = defineProps<{ showing: boolean; url: string; - source: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ @@ -32,9 +32,9 @@ const top = ref(0); const left = ref(0); onMounted(() => { - const rect = props.source.getBoundingClientRect(); - const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX; - const y = rect.top + props.source.offsetHeight + window.scrollY; + const rect = props.anchorElement.getBoundingClientRect(); + const x = Math.max((rect.left + (props.anchorElement.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX; + const y = rect.top + props.anchorElement.offsetHeight + window.scrollY; top.value = y; left.value = x; diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index 0cb7f22e93..d0bfebc463 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="250" @closed="emit('closed')"> <div :class="$style.root"> <div v-for="u in users" :key="u.id" :class="$style.user"> <MkAvatar :class="$style.avatar" :user="u"/> @@ -23,7 +23,7 @@ defineProps<{ showing: boolean; users: Misskey.entities.UserLite[]; count: number; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 3801195da6..88b934bb58 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -53,7 +53,7 @@ const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; localOnly: boolean; - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; isReplyVisibilitySpecified?: boolean; }>(), { }); diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index a809e9040d..50520b5d9d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div v-if="stats" :class="$style.stats"> + <div v-if="stats && instance.clientOptions.showActivitiesForVisitor !== false" :class="$style.stats"> <div :class="[$style.statsItem, $style.panel]"> <div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div> <div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div> @@ -40,13 +40,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div> </div> </div> - <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> + <div v-if="instance.policies.ltlAvailable && instance.clientOptions.showTimelineForVisitor !== false" :class="[$style.tl, $style.panel]"> <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> <div :class="$style.tlBody"> <MkStreamingNotesTimeline src="local"/> </div> </div> - <div :class="$style.panel"> + <div v-if="instance.clientOptions.showActivitiesForVisitor !== false" :class="$style.panel"> <XActiveUsersChart/> </div> </div> @@ -55,12 +55,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { instanceName } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -68,13 +69,14 @@ import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; import { openInstanceMenu } from '@/ui/_common_/common.js'; -import type { MenuItem } from '@/types/menu.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); -misskeyApi('stats', {}).then((res) => { - stats.value = res; -}); +if (instance.clientOptions.showActivitiesForVisitor !== false) { + misskeyApi('stats', {}).then((res) => { + stats.value = res; + }); +} function signin() { const { dispose } = os.popup(XSigninDialog, { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index f606b0b001..08a018ea9b 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -51,6 +51,7 @@ export type DefaultStoredWidget = { <script lang="ts" setup> import { defineAsyncComponent, ref, computed } from 'vue'; +import { isLink } from '@@/js/is-link.js'; import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; @@ -58,7 +59,6 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { isLink } from '@@/js/is-link.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -81,7 +81,7 @@ const emit = defineEmits<{ (ev: 'updateWidgets', widgets: Widget[]): void; (ev: 'addWidget', widget: Widget): void; (ev: 'removeWidget', widget: Widget): void; - (ev: 'updateWidget', widget: Partial<Widget>): void; + (ev: 'updateWidget', widget: { id: Widget['id']; data: Widget['data']; }): void; (ev: 'exit'): void; }>(); @@ -104,7 +104,7 @@ const addWidget = () => { const removeWidget = (widget) => { emit('removeWidget', widget); }; -const updateWidget = (id, data) => { +const updateWidget = (id: Widget['id'], data: Widget['data']) => { emit('updateWidget', { id, data }); }; diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 821f07510b..3b23acf612 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading/> </div> <div v-else-if="resolved"> - <slot :result="result"></slot> + <slot :result="result as T"></slot> </div> <div v-else> <div :class="$style.error"> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 4004db5b12..ae1b4549ec 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -64,7 +64,7 @@ function onContextmenu(ev) { icon: 'ti ti-player-eject', text: i18n.ts.showInPage, action: () => { - router.push(props.to, 'forcePage'); + router.pushByPath(props.to, 'forcePage'); }, }, { type: 'divider' }, { icon: 'ti ti-external-link', @@ -99,6 +99,6 @@ function nav(ev: MouseEvent) { return openWindow(); } - router.push(props.to, ev.ctrlKey ? 'forcePage' : null); + router.pushByPath(props.to, ev.ctrlKey ? 'forcePage' : null); } </script> diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index c5a928b5cf..07e06a6897 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -2,11 +2,10 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ + import { expect, userEvent, waitFor, within } from '@storybook/test'; -import type { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; const common = { @@ -68,7 +67,7 @@ const common = { await expect(imgAgain).toBeInTheDocument(); }, args: { - prefer: [], + preferForms: [], specify: { id: 'someadid', ratio: 1, diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 2f55700b47..c592079f03 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -52,7 +52,7 @@ import { prefer } from '@/preferences.js'; type Ad = (typeof instance)['ads'][number]; const props = defineProps<{ - preferForms: string[]; + preferForms?: string[]; specify?: Ad; }>(); @@ -71,7 +71,7 @@ const choseAd = (): Ad | null => { ratio: 0, } : ad); - let ads = allAds.filter(ad => props.preferForms.includes(ad.place)); + let ads = props.preferForms ? allAds.filter(ad => props.preferForms!.includes(ad.place)) : allAds; if (ads.length === 0) { ads = allAds.filter(ad => ad.place === 'square'); diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 8a9cc5286a..c2548cc7be 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -84,7 +84,6 @@ const bound = computed(() => props.link : {}); const url = computed(() => { - if (props.user.avatarUrl == null) return null; if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); return props.user.avatarUrl; }); diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index e150493a18..497cdfb3d5 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, waitFor } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 15938d0495..0ac6304054 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { waitFor } from '@storybook/test'; import MkPageHeader from './MkPageHeader.vue'; import type { StoryObj } from '@storybook/vue3'; @@ -59,6 +59,7 @@ export const Icon = { { ...OneTab.args.tabs[0], icon: 'ti ti-home', + title: 'Home', }, ], }, @@ -71,6 +72,7 @@ export const IconOnly = { { key: Icon.args.tabs[0].key, icon: Icon.args.tabs[0].icon, + title: Icon.args.tabs[0].title, iconOnly: true, }, ], diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index f2173b2e22..a1b57f30d9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -39,17 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only export type Tab = { key: string; onClick?: (ev: MouseEvent) => void; -} & ( - | { - iconOnly?: false; - title: string; - icon?: string; - } - | { - iconOnly: true; - icon: string; - } -); + iconOnly?: boolean; + title: string; + icon?: string; +}; </script> <script lang="ts" setup> @@ -59,7 +52,7 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; - rootEl?: HTMLElement; + rootEl?: HTMLElement | null; }>(), { tabs: () => ([] as Tab[]), }); diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 542c3d8d12..2f4de840db 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -52,6 +52,7 @@ export type PageHeaderProps = { actions?: PageHeaderItem[] | null; thin?: boolean; hideTitle?: boolean; + canOmitTitle?: boolean; displayMyAvatar?: boolean; }; </script> @@ -77,7 +78,7 @@ const emit = defineEmits<{ const injectedPageMetadata = inject(DI.pageMetadata, ref(null)); const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); -const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); +const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle || (props.canOmitTitle && props.tabs.length > 0)); const thin_ = props.thin || inject('shouldHeaderThin', false); const el = useTemplateRef('el'); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 914c495d7a..159af6f11e 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -62,7 +62,7 @@ if (props.showUrlPreview && isEnabledUrlPreview.value) { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el.value instanceof HTMLElement ? el.value : el.value?.$el, + anchorElement: el.value instanceof HTMLElement ? el.value : el.value?.$el, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d90afb652e..d368dee88a 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -6,14 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> + <template #header> + <MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/> + <MkPageHeader v-else v-model:tab="tab" v-bind="pageHeaderProps"/> + </template> <div :class="$style.body"> <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> <slot></slot> </MkSwiper> <slot v-else></slot> </div> - <template #footer><slot name="footer"></slot></template> + <template #footer> + <slot name="footer"></slot> + <div v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" :class="$style.footerTabs"> + <MkTabs v-model:tab="tab" :tabs="props.tabs" :centered="true" :tabHighlightUpper="true"/> + </div> + </template> </MkStickyContainer> </div> </template> @@ -26,6 +34,7 @@ import { useScrollPositionKeeper } from '@/composables/use-scroll-position-keepe import MkSwiper from '@/components/MkSwiper.vue'; import { useRouter } from '@/router.js'; import { prefer } from '@/preferences.js'; +import MkTabs from '@/components/MkTabs.vue'; const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; @@ -40,6 +49,11 @@ const pageHeaderProps = computed(() => { return rest; }); +const pageHeaderPropsWithoutTabs = computed(() => { + const { reversed, tabs, ...rest } = props; + return rest; +}); + const tab = defineModel<string>('tab'); const rootEl = useTemplateRef('rootEl'); @@ -68,4 +82,11 @@ defineExpose({ .body, .swiper { min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); } + +.footerTabs { + background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-top: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 27f7b18559..d42beb531d 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -30,19 +30,21 @@ const props = defineProps<{ router?: Router; }>(); -const router = props.router ?? inject(DI.router); +const _router = props.router ?? inject(DI.router); -if (router == null) { +if (_router == null) { throw new Error('no router provided'); } +const router = _router; + const viewId = randomId(); provide(DI.viewId, viewId); const currentDepth = inject(DI.routerCurrentDepth, 0); provide(DI.routerCurrentDepth, currentDepth + 1); -const current = router.current!; +const current = router.current; const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); let currentRoutePath = current.route.path; @@ -52,14 +54,10 @@ router.useListener('change', ({ resolved }) => { if (resolved == null || 'redirect' in resolved.route) return; if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return; - function _() { - currentPageComponent.value = resolved.route.component; - currentPageProps.value = resolved.props; - key.value = router.getCurrentFullPath(); - currentRoutePath = resolved.route.path; - } - - _(); + currentPageComponent.value = resolved.route.component; + currentPageProps.value = resolved.props; + key.value = router.getCurrentFullPath(); + currentRoutePath = resolved.route.path; }); </script> diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchText.vue index 27a284faf0..27a284faf0 100644 --- a/packages/frontend/src/components/global/SearchKeyword.vue +++ b/packages/frontend/src/components/global/SearchText.vue diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index c95c74aef3..4c56767608 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -76,7 +76,7 @@ function mount() { function back() { const prev = tabs.value[tabs.value.length - 2]; tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)]; - router.replace(prev.fullPath); + router?.replaceByPath(prev.fullPath); } router.useListener('change', ({ resolved }) => { @@ -87,7 +87,7 @@ router.useListener('change', ({ resolved }) => { const fullPath = router.getCurrentFullPath(); if (tabs.value.some(tab => tab.routePath === routePath && deepEqual(resolved.props, tab.props))) { - const newTabs = []; + const newTabs = [] as typeof tabs.value; for (const tab of tabs.value) { newTabs.push(tab); diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue index fd289c6cd9..6cd4f9ec1c 100644 --- a/packages/frontend/src/components/grid/MkCellTooltip.vue +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="250" @closed="emit('closed')"> <div :class="$style.root"> {{ content }} </div> @@ -18,7 +18,7 @@ import MkTooltip from '@/components/MkTooltip.vue'; defineProps<{ showing: boolean; content: string; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 444509e6b3..0f326b14ca 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -300,7 +300,7 @@ useTooltip(rootEl, (showing) => { const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), { showing, content, - targetElement: rootEl.value!, + anchorElement: rootEl.value!, }, { closed: () => { result.dispose(); diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts index f85bf146e8..5ed8465299 100644 --- a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { ref } from 'vue'; import { commonHandlers } from '../../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 19766e8575..6b1b80695f 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -31,7 +31,7 @@ import PageWithHeader from './global/PageWithHeader.vue'; import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; import SearchLabel from './global/SearchLabel.vue'; -import SearchKeyword from './global/SearchKeyword.vue'; +import SearchText from './global/SearchText.vue'; import SearchIcon from './global/SearchIcon.vue'; import type { App } from 'vue'; @@ -71,7 +71,7 @@ export const components = { PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, SearchLabel: SearchLabel, - SearchKeyword: SearchKeyword, + SearchText: SearchText, SearchIcon: SearchIcon, }; @@ -105,7 +105,7 @@ declare module '@vue/runtime-core' { PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; SearchLabel: typeof SearchLabel; - SearchKeyword: typeof SearchKeyword; + SearchText: typeof SearchText; SearchIcon: typeof SearchIcon; } } diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts index 73a3cecd3b..208adae8fe 100644 --- a/packages/frontend/src/deck.ts +++ b/packages/frontend/src/deck.ts @@ -62,6 +62,8 @@ export type Column = { withSensitive?: boolean; onlyFiles?: boolean; soundSetting?: SoundStore; + // The cache for the name of the antenna, channel, list, or role + timelineNameCache?: string; }; const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']); diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts index 802477e00b..f5fec108dc 100644 --- a/packages/frontend/src/directives/appear.ts +++ b/packages/frontend/src/directives/appear.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { throttle } from 'throttle-debounce'; import type { Directive } from 'vue'; export default { @@ -10,12 +11,14 @@ export default { const fn = binding.value; if (fn == null) return; - const observer = new IntersectionObserver(entries => { + const check = throttle(1000, (entries) => { if (entries.some(entry => entry.isIntersecting)) { fn(); } }); + const observer = new IntersectionObserver(check); + observer.observe(src); src._observer_ = observer; diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts index 750acd0588..62aecbc87c 100644 --- a/packages/frontend/src/directives/tooltip.ts +++ b/packages/frontend/src/directives/tooltip.ts @@ -57,7 +57,7 @@ export default { text: self.text, asMfm: binding.modifiers.mfm, direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', - targetElement: el, + anchorElement: el, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts index 94deea82c7..b11ef8f088 100644 --- a/packages/frontend/src/directives/user-preview.ts +++ b/packages/frontend/src/directives/user-preview.ts @@ -6,6 +6,7 @@ import { defineAsyncComponent, ref } from 'vue'; import type { Directive } from 'vue'; import { popup } from '@/os.js'; +import { isTouchUsing } from '@/utility/touch.js'; export class UserPreview { private el; @@ -107,6 +108,7 @@ export class UserPreview { export default { mounted(el: HTMLElement, binding, vn) { if (binding.value == null) return; + if (isTouchUsing) return; // TODO: 新たにプロパティを作るのをやめMapを使う // ただメモリ的には↓の方が省メモリかもしれないので検討中 diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 6ad503b089..0b2b206b7e 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -5,11 +5,12 @@ import { markRaw } from 'vue'; import { I18n } from '@@/js/i18n.js'; +import { locale } from '@@/js/locale.js'; import type { Locale } from '../../../locales/index.js'; -import { locale } from '@@/js/config.js'; export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); +// test 以外では使わないこと。インライン化されてるのでだいたい意味がない export function updateI18n(newLocale: Locale) { i18n.locale = newLocale; } diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts index a166df9eb0..74dda9decd 100644 --- a/packages/frontend/src/lib/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -58,7 +58,7 @@ export type RouterEvents = { beforeFullPath: string; fullPath: string; route: RouteDef | null; - props: Map<string, string> | null; + props: Map<string, string | boolean> | null; }) => void; same: () => void; }; @@ -77,6 +77,112 @@ export type PathResolvedResult = { }; }; +//#region Path Types +type Prettify<T> = { + [K in keyof T]: T[K] +} & {}; + +type RemoveNever<T> = { + [K in keyof T as T[K] extends never ? never : K]: T[K]; +} & {}; + +type IsPathParameter<Part extends string> = Part extends `${string}:${infer Parameter}` ? Parameter : never; + +type GetPathParamKeys<Path extends string> = + Path extends `${infer A}/${infer B}` + ? IsPathParameter<A> | GetPathParamKeys<B> + : IsPathParameter<Path>; + +type GetPathParams<Path extends string> = Prettify<{ + [Param in GetPathParamKeys<Path> as Param extends `${string}?` ? never : Param]: string; +} & { + [Param in GetPathParamKeys<Path> as Param extends `${infer OptionalParam}?` ? OptionalParam : never]?: string; +}>; + +type UnwrapReadOnly<T> = T extends ReadonlyArray<infer U> + ? U + : T extends Readonly<infer U> + ? U + : T; + +type GetPaths<Def extends RouteDef> = Def extends { path: infer Path } + ? Path extends string + ? Def extends { children: infer Children } + ? Children extends RouteDef[] + ? Path | `${Path}${FlattenAllPaths<Children>}` + : Path + : Path + : never + : never; + +type FlattenAllPaths<Defs extends RouteDef[]> = GetPaths<Defs[number]>; + +type GetSinglePathQuery<Def extends RouteDef, Path extends FlattenAllPaths<RouteDef[]>> = RemoveNever< + Def extends { path: infer BasePath, children: infer Children } + ? BasePath extends string + ? Path extends `${BasePath}${infer ChildPath}` + ? Children extends RouteDef[] + ? ChildPath extends FlattenAllPaths<Children> + ? GetPathQuery<Children, ChildPath> + : Record<string, never> + : never + : never + : never + : Def['path'] extends Path + ? Def extends { query: infer Query } + ? Query extends Record<string, string> + ? UnwrapReadOnly<{ [Key in keyof Query]?: string; }> + : Record<string, never> + : Record<string, never> + : Record<string, never> + >; + +type GetPathQuery<Defs extends RouteDef[], Path extends FlattenAllPaths<Defs>> = GetSinglePathQuery<Defs[number], Path>; + +type RequiredIfNotEmpty<K extends string, T extends Record<string, unknown>> = T extends Record<string, never> + ? { [Key in K]?: T } + : { [Key in K]: T }; + +type NotRequiredIfEmpty<T extends Record<string, unknown>> = T extends Record<string, never> ? T | undefined : T; + +type GetRouterOperationProps<Defs extends RouteDef[], Path extends FlattenAllPaths<Defs>> = NotRequiredIfEmpty<RequiredIfNotEmpty<'params', GetPathParams<Path>> & { + query?: GetPathQuery<Defs, Path>; + hash?: string; +}>; +//#endregion + +function buildFullPath(args: { + path: string; + params?: Record<string, string>; + query?: Record<string, string>; + hash?: string; +}) { + let fullPath = args.path; + + if (args.params) { + for (const key in args.params) { + const value = args.params[key]; + const replaceRegex = new RegExp(`:${key}(\\?)?`, 'g'); + fullPath = fullPath.replace(replaceRegex, value ? encodeURIComponent(value) : ''); + } + // remove any optional parameters that are not provided + fullPath = fullPath.replace(/\/:\w+\?(?=\/|$)/g, ''); + } + + if (args.query) { + const queryString = new URLSearchParams(args.query).toString(); + if (queryString) { + fullPath += '?' + queryString; + } + } + + if (args.hash) { + fullPath += '#' + encodeURIComponent(args.hash); + } + + return fullPath; +} + function parsePath(path: string): ParsedPath { const res = [] as ParsedPath; @@ -282,7 +388,7 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { } } - if (res.route.loginRequired && !this.isLoggedIn) { + if (res.route.loginRequired && !this.isLoggedIn && 'component' in res.route) { res.route.component = this.notFoundPageComponent; res.props.set('showLoginPopup', true); } @@ -310,14 +416,35 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { return this.currentFullPath; } - public push(fullPath: string, flag?: RouterFlag) { + public push<P extends FlattenAllPaths<DEF>>(path: P, props?: GetRouterOperationProps<DEF, P>, flag?: RouterFlag | null) { + const fullPath = buildFullPath({ + path, + params: props?.params, + query: props?.query, + hash: props?.hash, + }); + this.pushByPath(fullPath, flag); + } + + public replace<P extends FlattenAllPaths<DEF>>(path: P, props?: GetRouterOperationProps<DEF, P>) { + const fullPath = buildFullPath({ + path, + params: props?.params, + query: props?.query, + hash: props?.hash, + }); + this.replaceByPath(fullPath); + } + + /** どうしても必要な場合に使用(パスが確定している場合は `Nirax.push` を使用すること) */ + public pushByPath(fullPath: string, flag?: RouterFlag | null) { const beforeFullPath = this.currentFullPath; if (fullPath === beforeFullPath) { this.emit('same'); return; } if (this.navHook) { - const cancel = this.navHook(fullPath, flag); + const cancel = this.navHook(fullPath, flag ?? undefined); if (cancel) return; } const res = this.navigate(fullPath); @@ -333,14 +460,15 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { } } - public replace(fullPath: string) { + /** どうしても必要な場合に使用(パスが確定している場合は `Nirax.replace` を使用すること) */ + public replaceByPath(fullPath: string) { const res = this.navigate(fullPath); this.emit('replace', { fullPath: res._parsedRoute.fullPath, }); } - public useListener<E extends keyof RouterEvents, L = RouterEvents[E]>(event: E, listener: L) { + public useListener<E extends keyof RouterEvents>(event: E, listener: EventEmitter.EventListener<RouterEvents, E>) { this.addListener(event, listener); onBeforeUnmount(() => { diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 78fba9f7b4..687983bcdb 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -22,8 +22,7 @@ export type Keys = ( 'fontSize' | 'ui' | 'ui_temp' | - 'locale' | - 'localeVersion' | + 'bootloaderLocales' | 'theme' | 'themeId' | 'customCss' | @@ -33,6 +32,7 @@ export type Keys = ( 'preferences' | 'latestPreferencesUpdate' | 'hidePreferencesRestoreSuggestion' | + 'isSafeMode' | `miux:${string}` | `ui:folder:${string}` | `themes:${string}` | // DEPRECATED diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index bf0e5e1b37..56a2b8d269 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -9,7 +9,7 @@ import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; import type { Component, Ref } from 'vue'; -import type { ComponentProps as CP } from 'vue-component-type-helpers'; +import type { ComponentEmit, ComponentProps as CP } from 'vue-component-type-helpers'; import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; @@ -157,28 +157,9 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { return zIndexes[priority]; } -// InstanceType<typeof Component>['$emit'] だとインターセクション型が返ってきて -// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する -// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい -type ComponentEmit<T> = T extends new () => { $props: infer Props } - ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] - ? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい) - : EmitsExtractor<Props> - : T extends (...args: any) => any - ? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } } - ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] - ? Record<string, unknown> - : EmitsExtractor<Props> - : never - : never; - // props に ref を許可するようにする type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; -type EmitsExtractor<T> = { - [K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K]; -}; - export function popup<T extends Component>( component: T, props: ComponentProps<T>, @@ -703,7 +684,7 @@ export async function cropImageFile(imageFile: File | Blob, options: { }); } -export function popupMenu(items: MenuItem[], anchorElement?: HTMLElement | EventTarget | null, options?: { +export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElement | EventTarget | null, options?: { align?: string; width?: number; onClosing?: () => void; @@ -715,7 +696,7 @@ export function popupMenu(items: MenuItem[], anchorElement?: HTMLElement | Event let returnFocusTo = getHTMLElementOrNull(anchorElement) ?? getHTMLElementOrNull(window.document.activeElement); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkPopupMenu, { - items, + items: items.filter(x => x != null), anchorElement, width: options?.width, align: options?.align, diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 057deec4cf..a481972174 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -111,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a> </div> + <div> + <a style="display: inline-block;" class="sads-llc" title="合同会社サッズ" href="https://sads-llc.co.jp/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/sads-llc.png" alt="合同会社サッズ"></a> + </div> </div> </FormSection> <FormSection> @@ -286,6 +289,12 @@ const patronsWithIcon = [{ }, { name: '井上千二十四', icon: 'https://assets.misskey-hub.net/patrons/193afa1f039b4c339866039c3dcd74bf.jpg', +}, { + name: 'NigN', + icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg', +}, { + name: 'しゃどかの', + icon: 'https://assets.misskey-hub.net/patrons/5bec3c6b402942619e03f7a2ae76d69e.jpg', }]; const patrons = [ @@ -399,6 +408,8 @@ const patrons = [ 'みりめい', '東雲 琥珀', 'ほとラズ', + 'スズカケン', + '蒼井よみこ', ]; const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index b166dfd940..7e514c5a73 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> - <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> + <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category ?? '___root___'"> <template #header>{{ category || i18n.ts.other }}</template> <div :class="$style.emojis"> <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> @@ -48,7 +48,7 @@ import { $i } from '@/i.js'; const customEmojiTags = getCustomEmojiTags(); const q = ref(''); -const searchEmojis = ref<Misskey.entities.EmojiSimple[]>(null); +const searchEmojis = ref<Misskey.entities.EmojiSimple[] | null>(null); const selectedTags = ref(new Set()); function search() { diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 47ec675d57..fd5e061d52 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -22,20 +22,46 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="blocked">{{ i18n.ts.blocked }}</option> <option value="notResponding">{{ i18n.ts.notResponding }}</option> </MkSelect> - <MkSelect v-model="sort"> + <MkSelect + v-model="sort" :items="[{ + label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, + value: '+pubSub', + }, { + label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, + value: '-pubSub', + }, { + label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, + value: '+notes', + }, { + label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, + value: '-notes', + }, { + label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, + value: '+users', + }, { + label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, + value: '-users', + }, { + label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, + value: '+following', + }, { + label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, + value: '-following', + }, { + label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, + value: '+followers', + }, { + label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, + value: '-followers', + }, { + label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, + value: '+firstRetrievedAt', + }, { + label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, + value: '-firstRetrievedAt', + }] as const" + > <template #label>{{ i18n.ts.sort }}</template> - <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> </MkSelect> </FormSplit> </div> @@ -52,6 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, markRaw, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -62,7 +89,7 @@ import { Paginator } from '@/utility/paginator.js'; const host = ref(''); const state = ref('federating'); -const sort = ref('+pubSub'); +const sort = ref<NonNullable<Misskey.entities.FederationInstancesRequest['sort']>>('+pubSub'); const paginator = markRaw(new Paginator('federation/instances', { limit: 10, offsetMode: true, diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue index 0b9eee7d49..4786615173 100644 --- a/packages/frontend/src/pages/about.overview.vue +++ b/packages/frontend/src/pages/about.overview.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div style="overflow: clip;"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> + <img :src="instance.iconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> <div :class="$style.bannerName"> <b>{{ instance.name ?? host }}</b> </div> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 0edf2db1eb..ba9acd3ad2 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -48,34 +48,22 @@ watch(tab, () => { const headerActions = computed(() => []); -const headerTabs = computed(() => { - const items = []; - - items.push({ - key: 'overview', - title: i18n.ts.overview, - }, { - key: 'emojis', - title: i18n.ts.customEmojis, - icon: 'ti ti-icons', - }); - - if (instance.federation !== 'none') { - items.push({ - key: 'federation', - title: i18n.ts.federation, - icon: 'ti ti-whirl', - }); - } - - items.push({ - key: 'charts', - title: i18n.ts.charts, - icon: 'ti ti-chart-line', - }); - - return items; -}); +const headerTabs = computed(() => [{ + key: 'overview', + title: i18n.ts.overview, +}, { + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'ti ti-icons', +}, ...(instance.federation !== 'none' ? [{ + key: 'federation', + title: i18n.ts.federation, + icon: 'ti ti-whirl', +}] : []), { + key: 'charts', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}]); definePage(() => ({ title: i18n.ts.instanceInfo, diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue index 1560403b70..f4de2df6d5 100644 --- a/packages/frontend/src/pages/achievements.vue +++ b/packages/frontend/src/pages/achievements.vue @@ -16,9 +16,11 @@ import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; import MkAchievements from '@/components/MkAchievements.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { $i } from '@/i.js'; +import { ensureSignin } from '@/i.js'; import { claimAchievement } from '@/utility/achievements.js'; +const $i = ensureSignin(); + let timer: number | null; function viewAchievements3min() { diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 7a49ba542f..052829ffe2 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -111,13 +111,13 @@ const props = defineProps<{ fileId: string, }>(); -async function fetch() { +async function _fetch_() { file.value = await misskeyApi('drive/files/show', { fileId: props.fileId }); info.value = await misskeyApi('admin/drive/show-file', { fileId: props.fileId }); isSensitive.value = file.value.isSensitive; } -fetch(); +_fetch_(); async function del() { const { canceled } = await os.confirm({ @@ -172,7 +172,7 @@ const headerTabs = computed(() => [{ key: 'raw', title: 'Raw data', icon: 'ti ti-code', -}]); +}].filter(x => x != null)); definePage(() => ({ title: file.value ? `${i18n.ts.file}: ${file.value.name}` : i18n.ts.file, diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index a194b9a94f..38e3c7a11b 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -6,58 +6,57 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> - <div v-if="tab === 'overview'" class="_gaps_m"> - <div class="aeakzknw"> - <MkAvatar class="avatar" :user="user" indicator link preview/> - <div class="body"> - <span class="name"><MkUserName class="name" :user="user"/></span> - <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> - <span class="state"> - <span v-if="suspended" class="suspended">Suspended</span> - <span v-if="silenced" class="silenced">Silenced</span> - <span v-if="moderator" class="moderator">Moderator</span> - </span> - </div> + <div v-if="tab === 'overview'" class="_gaps_m"> + <div class="aeakzknw"> + <MkAvatar class="avatar" :user="user" indicator link preview/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="state"> + <span v-if="suspended" class="suspended">Suspended</span> + <span v-if="silenced" class="silenced">Silenced</span> + <span v-if="moderator" class="moderator">Moderator</span> + </span> </div> + </div> - <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> + <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> - <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> - <div style="display: flex; flex-direction: column; gap: 1em;"> - <MkKeyValue :copy="user.id" oneline> - <template #key>ID</template> - <template #value><span class="_monospace">{{ user.id }}</span></template> - </MkKeyValue> - <!-- 要る? + <div style="display: flex; flex-direction: column; gap: 1em;"> + <MkKeyValue :copy="user.id" oneline> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </MkKeyValue> + <!-- 要る? <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline> <template #key>IP (recent)</template> <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> </MkKeyValue> --> - <template v-if="!isSystem"> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.createdAt }}</template> - <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> - </MkKeyValue> - <MkKeyValue v-if="info" oneline> - <template #key>{{ i18n.ts.lastActiveDate }}</template> - <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> - </MkKeyValue> - <MkKeyValue v-if="info" oneline> - <template #key>{{ i18n.ts.email }}</template> - <template #value><span class="_monospace">{{ info.email }}</span></template> - </MkKeyValue> - </template> - </div> + <template v-if="!isSystem"> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> + </MkKeyValue> + </template> + </div> - <MkTextarea v-if="!isSystem" v-model="moderationNote" manualSave> - <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> + <MkTextarea v-if="!isSystem" v-model="moderationNote" manualSave> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> - <!-- + <!-- <FormSection> <template #label>ActivityPub</template> @@ -93,119 +92,118 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> --> - <FormSection v-if="!isSystem"> - <div class="_gaps"> - <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> + <FormSection v-if="!isSystem"> + <div class="_gaps"> + <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> - <div> - <MkButton v-if="user.host == null" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> - </div> + <div> + <MkButton v-if="user.host == null" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> + </div> - <MkFolder> - <template #icon><i class="ti ti-license"></i></template> - <template #label>{{ i18n.ts._role.policies }}</template> - <div class="_gaps"> - <div v-for="policy in Object.keys(info.policies)" :key="policy"> - {{ policy }} ... {{ info.policies[policy] }} - </div> + <MkFolder> + <template #icon><i class="ti ti-license"></i></template> + <template #label>{{ i18n.ts._role.policies }}</template> + <div class="_gaps"> + <div v-for="policy in Object.keys(info.policies)" :key="policy"> + {{ policy }} ... {{ info.policies[policy] }} </div> - </MkFolder> + </div> + </MkFolder> - <MkFolder> - <template #icon><i class="ti ti-password"></i></template> - <template #label>IP</template> - <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> - <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> - <template v-if="iAmAdmin && ips"> - <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> - <span class="date">{{ record.createdAt }}</span> - <span class="ip">{{ record.ip }}</span> - </div> - </template> - </MkFolder> + <MkFolder> + <template #icon><i class="ti ti-password"></i></template> + <template #label>IP</template> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> + <template v-if="iAmAdmin && ips"> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </template> + </MkFolder> - <div> - <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton> - <MkButton v-if="iAmModerator" inline danger @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton> - </div> - <MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton> + <div> + <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton> + <MkButton v-if="iAmModerator" inline danger @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton> </div> - </FormSection> - </div> + <MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton> + </div> + </FormSection> + </div> - <div v-else-if="tab === 'roles'" class="_gaps"> - <MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> + <div v-else-if="tab === 'roles'" class="_gaps"> + <MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> - <div v-for="role in info.roles" :key="role.id"> - <div :class="$style.roleItemMain"> - <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> - <button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button> - <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> - <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> - </div> - <div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub"> - <div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div> - <div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> - </div> + <div v-for="role in info.roles" :key="role.id"> + <div :class="$style.roleItemMain"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> + <button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> + </div> + <div v-if="expandedRoleIds.includes(role.id)" :class="$style.roleItemSub"> + <div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id)!.createdAt" mode="detail"/></div> + <div v-if="info.roleAssigns.find(a => a.roleId === role.id)!.expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id)!.expiresAt!).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> + </div> - <div v-else-if="tab === 'announcements'" class="_gaps"> - <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> + <div v-else-if="tab === 'announcements'" class="_gaps"> + <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> - <MkSelect v-model="announcementsStatus"> - <template #label>{{ i18n.ts.filter }}</template> - <option value="active">{{ i18n.ts.active }}</option> - <option value="archived">{{ i18n.ts.archived }}</option> - </MkSelect> + <MkSelect v-model="announcementsStatus"> + <template #label>{{ i18n.ts.filter }}</template> + <option value="active">{{ i18n.ts.active }}</option> + <option value="archived">{{ i18n.ts.archived }}</option> + </MkSelect> - <MkPagination :paginator="announcementsPaginator"> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> - <span style="margin-right: 0.5em;"> - <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> - <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> - <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> - <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i> - </span> - <span>{{ announcement.title }}</span> - <span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span> - </div> + <MkPagination :paginator="announcementsPaginator"> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> + <span style="margin-right: 0.5em;"> + <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> + <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i> + </span> + <span>{{ announcement.title }}</span> + <span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> + </div> - <div v-else-if="tab === 'drive'" class="_gaps"> - <MkFileListForAdmin :paginator="filesPaginator" viewMode="grid"/> - </div> + <div v-else-if="tab === 'drive'" class="_gaps"> + <MkFileListForAdmin :paginator="filesPaginator" viewMode="grid"/> + </div> - <div v-else-if="tab === 'chart'" class="_gaps_m"> - <div class="cmhjzshm"> - <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="per-user-notes">{{ i18n.ts.notes }}</option> - </MkSelect> - </div> - <div class="charts"> - <div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div> - <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> - <div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div> - <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> - </div> + <div v-else-if="tab === 'chart'" class="_gaps_m"> + <div class="cmhjzshm"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="per-user-notes">{{ i18n.ts.notes }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + <div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> </div> </div> + </div> - <div v-else-if="tab === 'raw'" class="_gaps_m"> - <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> - </MkObjectView> + <div v-else-if="tab === 'raw'" class="_gaps_m"> + <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> + </MkObjectView> - <MkObjectView tall :value="user"> - </MkObjectView> - </div> - </FormSuspense> + <MkObjectView tall :value="user"> + </MkObjectView> + </div> </div> </PageWithHeader> </template> @@ -224,7 +222,6 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkSelect from '@/components/MkSelect.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; @@ -232,11 +229,13 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { iAmAdmin, $i, iAmModerator } from '@/i.js'; +import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { Paginator } from '@/utility/paginator.js'; +const $i = ensureSignin(); + const props = withDefaults(defineProps<{ userId: string; initialTab?: string; @@ -244,18 +243,19 @@ const props = withDefaults(defineProps<{ initialTab: 'overview', }); +const result = await _fetch_(); + const tab = ref(props.initialTab); const chartSrc = ref('per-user-notes'); -const user = ref<null | Misskey.entities.UserDetailed>(); -const init = ref<ReturnType<typeof createFetcher>>(); -const info = ref<any>(); -const ips = ref<Misskey.entities.AdminGetUserIpsResponse | null>(null); +const user = ref(result.user); +const info = ref(result.info); +const ips = ref(result.ips); const ap = ref<any>(null); -const moderator = ref(false); -const silenced = ref(false); -const suspended = ref(false); -const isSystem = ref(false); -const moderationNote = ref(''); +const moderator = ref(info.value.isModerator); +const silenced = ref(info.value.isSilenced); +const suspended = ref(info.value.isSuspended); +const isSystem = ref(user.value.host == null && user.value.username.includes('.')); +const moderationNote = ref(info.value.moderationNote); const filesPaginator = markRaw(new Paginator('admin/drive/files', { limit: 10, computedParams: computed(() => ({ @@ -272,34 +272,37 @@ const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', status: announcementsStatus.value, })), })); -const expandedRoles = ref([]); +const expandedRoleIds = ref<(typeof info.value.roles[number]['id'])[]>([]); -function createFetcher() { - return () => Promise.all([misskeyApi('users/show', { +function _fetch_() { + return Promise.all([misskeyApi('users/show', { userId: props.userId, }), misskeyApi('admin/show-user', { userId: props.userId, }), iAmAdmin ? misskeyApi('admin/get-user-ips', { userId: props.userId, - }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { - user.value = _user; - info.value = _info; - ips.value = _ips; - moderator.value = info.value.isModerator; - silenced.value = info.value.isSilenced; - suspended.value = info.value.isSuspended; - moderationNote.value = info.value.moderationNote; - isSystem.value = user.value.host == null && user.value.username.includes('.'); - - watch(moderationNote, async () => { - await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); - await refreshUser(); - }); - }); + }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => ({ + user: _user, + info: _info, + ips: _ips, + })); } -function refreshUser() { - init.value = createFetcher(); +watch(moderationNote, async () => { + await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); + await refreshUser(); +}); + +async function refreshUser() { + const result = await _fetch_(); + user.value = result.user; + info.value = result.info; + ips.value = result.ips; + moderator.value = info.value.isModerator; + silenced.value = info.value.isSilenced; + suspended.value = info.value.isSuspended; + isSystem.value = user.value.host == null && user.value.username.includes('.'); + moderationNote.value = info.value.moderationNote; } async function updateRemoteUser() { @@ -456,7 +459,7 @@ async function assignRole() { refreshUser(); } -async function unassignRole(role, ev) { +async function unassignRole(role: typeof info.value.roles[number], ev: MouseEvent) { os.popupMenu([{ text: i18n.ts.unassign, icon: 'ti ti-x', @@ -468,11 +471,11 @@ async function unassignRole(role, ev) { }], ev.currentTarget ?? ev.target); } -function toggleRoleItem(role) { - if (expandedRoles.value.includes(role.id)) { - expandedRoles.value = expandedRoles.value.filter(x => x !== role.id); +function toggleRoleItem(role: typeof info.value.roles[number]) { + if (expandedRoleIds.value.includes(role.id)) { + expandedRoleIds.value = expandedRoleIds.value.filter(x => x !== role.id); } else { - expandedRoles.value.push(role.id); + expandedRoleIds.value.push(role.id); } } @@ -493,12 +496,6 @@ async function editAnnouncement(announcement) { }); } -watch(() => props.userId, () => { - init.value = createFetcher(); -}, { - immediate: true, -}); - watch(user, () => { misskeyApi('ap/get', { uri: user.value.uri ?? `${url}/users/${user.value.id}`, diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index c5baeda7b0..06a28db088 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -140,15 +140,15 @@ function toggleDayOfWeek(ad, index) { function add() { ads.value.unshift({ - id: null, + id: '', memo: '', place: 'square', priority: 'middle', ratio: 1, url: '', - imageUrl: null, - expiresAt: null, - startsAt: null, + imageUrl: '', + expiresAt: new Date().toISOString(), + startsAt: new Date().toISOString(), dayOfWeek: 0, }); } @@ -160,7 +160,7 @@ function remove(ad) { }).then(({ canceled }) => { if (canceled) return; ads.value = ads.value.filter(x => x !== ad); - if (ad.id == null) return; + if (ad.id === '') return; os.apiWithDialog('admin/ad/delete', { id: ad.id, }).then(() => { @@ -170,7 +170,7 @@ function remove(ad) { } function save(ad) { - if (ad.id == null) { + if (ad.id === '') { misskeyApi('admin/ad/create', { ...ad, expiresAt: new Date(ad.expiresAt).getTime(), @@ -207,7 +207,7 @@ function save(ad) { } function more() { - misskeyApi('admin/ad/list', { untilId: ads.value.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => { + misskeyApi('admin/ad/list', { untilId: ads.value.reduce((acc, ad) => ad.id !== '' ? ad : acc).id, publishing: publishing }).then(adsResponse => { if (adsResponse == null) return; ads.value = ads.value.concat(adsResponse.map(r => { const exdate = new Date(r.expiresAt); diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 6c580f87f1..7ed280358a 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -4,158 +4,161 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkFolder> - <template #icon><i class="ti ti-shield"></i></template> - <template #label>{{ i18n.ts.botProtection }}</template> - <template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template> - <template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template> - <template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template> - <template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template> - <template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template> - <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> - <template #footer> - <MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/> - </template> +<SearchMarker markerId="botProtection" :keywords="['bot', 'protection', 'captcha', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-shield"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.botProtection }}</SearchLabel></template> + <template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template> + <template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template> + <template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template> + <template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template> + <template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template> + <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> + <template v-if="botProtectionForm.modified.value" #footer> + <MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/> + </template> - <div class="_gaps_m"> - <MkRadios v-model="botProtectionForm.state.provider"> - <option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> - <option value="hcaptcha">hCaptcha</option> - <option value="mcaptcha">mCaptcha</option> - <option value="recaptcha">reCAPTCHA</option> - <option value="turnstile">Turnstile</option> - <option value="testcaptcha">testCaptcha</option> - </MkRadios> + <div class="_gaps_m"> + <MkRadios v-model="botProtectionForm.state.provider"> + <option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="mcaptcha">mCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + <option value="turnstile">Turnstile</option> + <option value="testcaptcha">testCaptcha</option> + </MkRadios> - <template v-if="botProtectionForm.state.provider === 'hcaptcha'"> - <MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> - </MkInput> - <MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> - </MkInput> - <FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey"> - <template #label>{{ i18n.ts._captcha.verify }}</template> - <MkCaptcha - v-model="captchaResult" - provider="hcaptcha" - :sitekey="botProtectionForm.state.hcaptchaSiteKey" - :secretKey="botProtectionForm.state.hcaptchaSecretKey" - /> - </FormSlot> - <MkInfo> - <div :class="$style.captchaInfoMsg"> - <div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> - <div> - <span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a> + <template v-if="botProtectionForm.state.provider === 'hcaptcha'"> + <MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> + </MkInput> + <MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> + </MkInput> + <FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey"> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha + v-model="captchaResult" + provider="hcaptcha" + :sitekey="botProtectionForm.state.hcaptchaSiteKey" + :secretKey="botProtectionForm.state.hcaptchaSecretKey" + /> + </FormSlot> + <MkInfo> + <div :class="$style.captchaInfoMsg"> + <div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> + <div> + <span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a> + </div> </div> - </div> - </MkInfo> - </template> + </MkInfo> + </template> - <template v-else-if="botProtectionForm.state.provider === 'mcaptcha'"> - <MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.mcaptchaSiteKey }}</template> - </MkInput> - <MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.mcaptchaSecretKey }}</template> - </MkInput> - <MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template> - </MkInput> - <FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl"> - <template #label>{{ i18n.ts._captcha.verify }}</template> - <MkCaptcha - v-model="captchaResult" - provider="mcaptcha" - :sitekey="botProtectionForm.state.mcaptchaSiteKey" - :secretKey="botProtectionForm.state.mcaptchaSecretKey" - :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl" - /> - </FormSlot> - </template> + <template v-else-if="botProtectionForm.state.provider === 'mcaptcha'"> + <MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.mcaptchaSiteKey }}</template> + </MkInput> + <MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.mcaptchaSecretKey }}</template> + </MkInput> + <MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template> + </MkInput> + <FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl"> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha + v-model="captchaResult" + provider="mcaptcha" + :sitekey="botProtectionForm.state.mcaptchaSiteKey" + :secretKey="botProtectionForm.state.mcaptchaSecretKey" + :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl" + /> + </FormSlot> + </template> - <template v-else-if="botProtectionForm.state.provider === 'recaptcha'"> - <MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> - </MkInput> - <MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> - </MkInput> - <FormSlot v-if="botProtectionForm.state.recaptchaSiteKey"> - <template #label>{{ i18n.ts._captcha.verify }}</template> - <MkCaptcha - v-model="captchaResult" - provider="recaptcha" - :sitekey="botProtectionForm.state.recaptchaSiteKey" - :secretKey="botProtectionForm.state.recaptchaSecretKey" - /> - </FormSlot> - <MkInfo> - <div :class="$style.captchaInfoMsg"> - <div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> - <div> - <span>ref: </span> - <a - href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do" - target="_blank" - >reCAPTCHA FAQ</a> + <template v-else-if="botProtectionForm.state.provider === 'recaptcha'"> + <MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> + </MkInput> + <MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> + </MkInput> + <FormSlot v-if="botProtectionForm.state.recaptchaSiteKey"> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha + v-model="captchaResult" + provider="recaptcha" + :sitekey="botProtectionForm.state.recaptchaSiteKey" + :secretKey="botProtectionForm.state.recaptchaSecretKey" + /> + </FormSlot> + <MkInfo> + <div :class="$style.captchaInfoMsg"> + <div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div> + <div> + <span>ref: </span> + <a + href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do" + target="_blank" + >reCAPTCHA FAQ</a> + </div> </div> - </div> - </MkInfo> - </template> + </MkInfo> + </template> - <template v-else-if="botProtectionForm.state.provider === 'turnstile'"> - <MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.turnstileSiteKey }}</template> - </MkInput> - <MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>{{ i18n.ts.turnstileSecretKey }}</template> - </MkInput> - <FormSlot v-if="botProtectionForm.state.turnstileSiteKey"> - <template #label>{{ i18n.ts._captcha.verify }}</template> - <MkCaptcha - v-model="captchaResult" - provider="turnstile" - :sitekey="botProtectionForm.state.turnstileSiteKey" - :secretKey="botProtectionForm.state.turnstileSecretKey" - /> - </FormSlot> - <MkInfo> - <div :class="$style.captchaInfoMsg"> - <div> - {{ i18n.ts._captcha.testSiteKeyMessage }} - </div> - <div> - <span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a> + <template v-else-if="botProtectionForm.state.provider === 'turnstile'"> + <MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSiteKey }}</template> + </MkInput> + <MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSecretKey }}</template> + </MkInput> + <FormSlot v-if="botProtectionForm.state.turnstileSiteKey"> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha + v-model="captchaResult" + provider="turnstile" + :sitekey="botProtectionForm.state.turnstileSiteKey" + :secretKey="botProtectionForm.state.turnstileSecretKey" + /> + </FormSlot> + <MkInfo> + <div :class="$style.captchaInfoMsg"> + <div> + {{ i18n.ts._captcha.testSiteKeyMessage }} + </div> + <div> + <span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a> + </div> </div> - </div> - </MkInfo> - </template> + </MkInfo> + </template> - <template v-else-if="botProtectionForm.state.provider === 'testcaptcha'"> - <MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo> - <FormSlot> - <template #label>{{ i18n.ts._captcha.verify }}</template> - <MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/> - </FormSlot> - </template> - </div> -</MkFolder> + <template v-else-if="botProtectionForm.state.provider === 'testcaptcha'"> + <MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo> + <FormSlot> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/> + </FormSlot> + </template> + </div> + </MkFolder> +</SearchMarker> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import type { ApiWithDialogCustomErrors } from '@/os.js'; import MkRadios from '@/components/MkRadios.vue'; import MkInput from '@/components/MkInput.vue'; import FormSlot from '@/components/form/slot.vue'; @@ -167,7 +170,6 @@ import { useForm } from '@/composables/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; -import type { ApiWithDialogCustomErrors } from '@/os.js'; const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index 19258216f6..f78a4f27bd 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -6,89 +6,137 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> + <SearchMarker path="/admin/branding" :label="i18n.ts.branding" :keywords="['branding']" icon="ti ti-paint"> <div class="_gaps_m"> - <MkInput v-model="iconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }}</template> - </MkInput> + <SearchMarker :keywords="['entrance', 'welcome', 'landing', 'front', 'home', 'page', 'style']"> + <MkRadios v-model="entrancePageStyle"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.entrancePageStyle }}</SearchLabel></template> + <option value="classic">Classic</option> + <option value="simple">Simple</option> + </MkRadios> + </SearchMarker> - <MkInput v-model="app192IconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> - <template #caption> - <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> - <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> - <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div> - </template> - </MkInput> + <SearchMarker :keywords="['timeline']"> + <MkSwitch v-model="showTimelineForVisitor"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.showTimelineForVisitor }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkInput v-model="app512IconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> - <template #caption> - <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> - <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> - <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div> - </template> - </MkInput> + <SearchMarker :keywords="['activity', 'activities']"> + <MkSwitch v-model="showActivitiesForVisitor"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.showActivitiesForVisitor }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkInput v-model="bannerUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.bannerUrl }}</template> - </MkInput> + <SearchMarker :keywords="['icon', 'image']"> + <MkInput v-model="iconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkInput v-model="backgroundImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.backgroundImageUrl }}</template> - </MkInput> + <SearchMarker :keywords="['icon', 'image']"> + <MkInput v-model="app192IconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</SearchLabel></template> + <template #caption> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> + <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> + <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div> + </template> + </MkInput> + </SearchMarker> - <MkInput v-model="notFoundImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.notFoundDescription }}</template> - </MkInput> + <SearchMarker :keywords="['icon', 'image']"> + <MkInput v-model="app512IconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</SearchLabel></template> + <template #caption> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> + <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> + <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div> + </template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.nothing }}</template> - </MkInput> + <SearchMarker :keywords="['banner', 'image']"> + <MkInput v-model="bannerUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.bannerUrl }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkInput v-model="serverErrorImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.somethingHappened }}</template> - </MkInput> + <SearchMarker :keywords="['background', 'image']"> + <MkInput v-model="backgroundImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.backgroundImageUrl }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkColorInput v-model="themeColor"> - <template #label>{{ i18n.ts.themeColor }}</template> - </MkColorInput> + <SearchMarker :keywords="['image']"> + <MkInput v-model="notFoundImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.notFoundDescription }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkTextarea v-model="defaultLightTheme"> - <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </MkTextarea> + <SearchMarker :keywords="['image']"> + <MkInput v-model="infoImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.nothing }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkTextarea v-model="defaultDarkTheme"> - <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </MkTextarea> + <SearchMarker :keywords="['image']"> + <MkInput v-model="serverErrorImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.somethingHappened }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkInput v-model="repositoryUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.repositoryUrl }}</template> - </MkInput> + <SearchMarker :keywords="['theme', 'color']"> + <MkColorInput v-model="themeColor"> + <template #label><SearchLabel>{{ i18n.ts.themeColor }}</SearchLabel></template> + </MkColorInput> + </SearchMarker> - <MkInput v-model="feedbackUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.feedbackUrl }}</template> - </MkInput> + <SearchMarker :keywords="['theme', 'default', 'light']"> + <MkTextarea v-model="defaultLightTheme"> + <template #label><SearchLabel>{{ i18n.ts.instanceDefaultLightTheme }}</SearchLabel></template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </MkTextarea> + </SearchMarker> - <MkTextarea v-model="manifestJsonOverride"> - <template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template> - </MkTextarea> + <SearchMarker :keywords="['theme', 'default', 'dark']"> + <MkTextarea v-model="defaultDarkTheme"> + <template #label><SearchLabel>{{ i18n.ts.instanceDefaultDarkTheme }}</SearchLabel></template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </MkTextarea> + </SearchMarker> + + <SearchMarker> + <MkInput v-model="repositoryUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.repositoryUrl }}</SearchLabel></template> + </MkInput> + </SearchMarker> + + <SearchMarker> + <MkInput v-model="feedbackUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label><SearchLabel>{{ i18n.ts.feedbackUrl }}</SearchLabel></template> + </MkInput> + </SearchMarker> + + <SearchMarker> + <MkTextarea v-model="manifestJsonOverride"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.manifestJsonOverride }}</SearchLabel></template> + </MkTextarea> + </SearchMarker> </div> - </FormSuspense> + </SearchMarker> </div> <template #footer> <div :class="$style.footer"> @@ -106,7 +154,6 @@ import JSON5 from 'json5'; import { host } from '@@/js/config.js'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { instance, fetchInstance } from '@/instance.js'; @@ -114,42 +161,36 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkColorInput from '@/components/MkColorInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; -const iconUrl = ref<string | null>(null); -const app192IconUrl = ref<string | null>(null); -const app512IconUrl = ref<string | null>(null); -const bannerUrl = ref<string | null>(null); -const backgroundImageUrl = ref<string | null>(null); -const themeColor = ref<string | null>(null); -const defaultLightTheme = ref<string | null>(null); -const defaultDarkTheme = ref<string | null>(null); -const serverErrorImageUrl = ref<string | null>(null); -const infoImageUrl = ref<string | null>(null); -const notFoundImageUrl = ref<string | null>(null); -const repositoryUrl = ref<string | null>(null); -const feedbackUrl = ref<string | null>(null); -const manifestJsonOverride = ref<string>('{}'); +const meta = await misskeyApi('admin/meta'); -async function init() { - const meta = await misskeyApi('admin/meta'); - iconUrl.value = meta.iconUrl; - app192IconUrl.value = meta.app192IconUrl; - app512IconUrl.value = meta.app512IconUrl; - bannerUrl.value = meta.bannerUrl; - backgroundImageUrl.value = meta.backgroundImageUrl; - themeColor.value = meta.themeColor; - defaultLightTheme.value = meta.defaultLightTheme; - defaultDarkTheme.value = meta.defaultDarkTheme; - serverErrorImageUrl.value = meta.serverErrorImageUrl; - infoImageUrl.value = meta.infoImageUrl; - notFoundImageUrl.value = meta.notFoundImageUrl; - repositoryUrl.value = meta.repositoryUrl; - feedbackUrl.value = meta.feedbackUrl; - manifestJsonOverride.value = meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t'); -} +const entrancePageStyle = ref(meta.clientOptions.entrancePageStyle ?? 'classic'); +const showTimelineForVisitor = ref(meta.clientOptions.showTimelineForVisitor ?? true); +const showActivitiesForVisitor = ref(meta.clientOptions.showActivitiesForVisitor ?? true); +const iconUrl = ref(meta.iconUrl); +const app192IconUrl = ref(meta.app192IconUrl); +const app512IconUrl = ref(meta.app512IconUrl); +const bannerUrl = ref(meta.bannerUrl); +const backgroundImageUrl = ref(meta.backgroundImageUrl); +const themeColor = ref(meta.themeColor); +const defaultLightTheme = ref(meta.defaultLightTheme); +const defaultDarkTheme = ref(meta.defaultDarkTheme); +const serverErrorImageUrl = ref(meta.serverErrorImageUrl); +const infoImageUrl = ref(meta.infoImageUrl); +const notFoundImageUrl = ref(meta.notFoundImageUrl); +const repositoryUrl = ref(meta.repositoryUrl); +const feedbackUrl = ref(meta.feedbackUrl); +const manifestJsonOverride = ref(meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t')); function save() { os.apiWithDialog('admin/update-meta', { + clientOptions: { + entrancePageStyle: entrancePageStyle.value, + showTimelineForVisitor: showTimelineForVisitor.value, + showActivitiesForVisitor: showActivitiesForVisitor.value, + }, iconUrl: iconUrl.value, app192IconUrl: app192IconUrl.value, app512IconUrl: app512IconUrl.value, diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index 17f2f8b593..8eb403f94c 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -6,48 +6,67 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> + <SearchMarker path="/admin/email-settings" :label="i18n.ts.emailServer" :keywords="['email']" icon="ti ti-mail"> <div class="_gaps_m"> - <MkSwitch v-model="enableEmail"> - <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template> - <template #caption>{{ i18n.ts.emailConfigInfo }}</template> - </MkSwitch> + <SearchMarker> + <MkSwitch v-model="enableEmail"> + <template #label><SearchLabel>{{ i18n.ts.enableEmail }}</SearchLabel> ({{ i18n.ts.recommended }})</template> + <template #caption><SearchText>{{ i18n.ts.emailConfigInfo }}</SearchText></template> + </MkSwitch> + </SearchMarker> <template v-if="enableEmail"> - <MkInput v-model="email" type="email"> - <template #label>{{ i18n.ts.emailAddress }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="email" type="email"> + <template #label><SearchLabel>{{ i18n.ts.emailAddress }}</SearchLabel></template> + </MkInput> + </SearchMarker> - <FormSection> - <template #label>{{ i18n.ts.smtpConfig }}</template> + <SearchMarker> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.smtpConfig }}</SearchLabel></template> - <div class="_gaps_m"> - <FormSplit :minWidth="280"> - <MkInput v-model="smtpHost"> - <template #label>{{ i18n.ts.smtpHost }}</template> - </MkInput> - <MkInput v-model="smtpPort" type="number"> - <template #label>{{ i18n.ts.smtpPort }}</template> - </MkInput> - </FormSplit> - <FormSplit :minWidth="280"> - <MkInput v-model="smtpUser"> - <template #label>{{ i18n.ts.smtpUser }}</template> - </MkInput> - <MkInput v-model="smtpPass" type="password"> - <template #label>{{ i18n.ts.smtpPass }}</template> - </MkInput> - </FormSplit> - <FormInfo>{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> - <MkSwitch v-model="smtpSecure"> - <template #label>{{ i18n.ts.smtpSecure }}</template> - <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> - </MkSwitch> - </div> - </FormSection> + <div class="_gaps_m"> + <FormSplit :minWidth="280"> + <SearchMarker> + <MkInput v-model="smtpHost"> + <template #label><SearchLabel>{{ i18n.ts.smtpHost }}</SearchLabel></template> + </MkInput> + </SearchMarker> + <SearchMarker> + <MkInput v-model="smtpPort" type="number"> + <template #label><SearchLabel>{{ i18n.ts.smtpPort }}</SearchLabel></template> + </MkInput> + </SearchMarker> + </FormSplit> + + <FormSplit :minWidth="280"> + <SearchMarker> + <MkInput v-model="smtpUser"> + <template #label><SearchLabel>{{ i18n.ts.smtpUser }}</SearchLabel></template> + </MkInput> + </SearchMarker> + <SearchMarker> + <MkInput v-model="smtpPass" type="password"> + <template #label><SearchLabel>{{ i18n.ts.smtpPass }}</SearchLabel></template> + </MkInput> + </SearchMarker> + </FormSplit> + + <FormInfo>{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> + + <SearchMarker> + <MkSwitch v-model="smtpSecure"> + <template #label><SearchLabel>{{ i18n.ts.smtpSecure }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.smtpSecureInfo }}</SearchText></template> + </MkSwitch> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> </template> </div> - </FormSuspense> + </SearchMarker> </div> <template #footer> <div :class="$style.footer"> @@ -67,7 +86,6 @@ import { ref, computed } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import FormInfo from '@/components/MkInfo.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; @@ -77,24 +95,15 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; -const enableEmail = ref<boolean>(false); -const email = ref<string | null>(null); -const smtpSecure = ref<boolean>(false); -const smtpHost = ref<string>(''); -const smtpPort = ref<number>(0); -const smtpUser = ref<string>(''); -const smtpPass = ref<string>(''); +const meta = await misskeyApi('admin/meta'); -async function init() { - const meta = await misskeyApi('admin/meta'); - enableEmail.value = meta.enableEmail; - email.value = meta.email; - smtpSecure.value = meta.smtpSecure; - smtpHost.value = meta.smtpHost; - smtpPort.value = meta.smtpPort; - smtpUser.value = meta.smtpUser; - smtpPass.value = meta.smtpPass; -} +const enableEmail = ref(meta.enableEmail); +const email = ref(meta.email); +const smtpSecure = ref(meta.smtpSecure); +const smtpHost = ref(meta.smtpHost); +const smtpPort = ref(meta.smtpPort); +const smtpUser = ref(meta.smtpUser); +const smtpPass = ref(meta.smtpPass); async function testEmail() { const { canceled, result: destination } = await os.inputText({ diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index 845fb12c5d..3a4eb8c3c7 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -6,36 +6,49 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> + <SearchMarker path="/admin/external-services" :label="i18n.ts.externalServices" :keywords="['external', 'services', 'thirdparty']" icon="ti ti-link"> <div class="_gaps_m"> - <MkFolder> - <template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template> + <SearchMarker v-slot="slotProps"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>Google Analytics</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> - <div class="_gaps_m"> - <MkInput v-model="googleAnalyticsMeasurementId"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>Measurement ID</template> - </MkInput> - <MkButton primary @click="save_googleAnalytics">Save</MkButton> - </div> - </MkFolder> + <div class="_gaps_m"> + <SearchMarker> + <MkInput v-model="googleAnalyticsMeasurementId"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>Measurement ID</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkFolder> - <template #label>DeepL Translation</template> + <MkButton primary @click="save_googleAnalytics">Save</MkButton> + </div> + </MkFolder> + </SearchMarker> - <div class="_gaps_m"> - <MkInput v-model="deeplAuthKey"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>DeepL Auth Key</template> - </MkInput> - <MkSwitch v-model="deeplIsPro"> - <template #label>Pro account</template> - </MkSwitch> - <MkButton primary @click="save_deepl">Save</MkButton> - </div> - </MkFolder> + <SearchMarker v-slot="slotProps"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>DeepL Translation</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker> + <MkInput v-model="deeplAuthKey"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>Auth Key</SearchLabel></template> + </MkInput> + </SearchMarker> + + <SearchMarker> + <MkSwitch v-model="deeplIsPro"> + <template #label><SearchLabel>Pro account</SearchLabel></template> + </MkSwitch> + </SearchMarker> + + <MkButton primary @click="save_deepl">Save</MkButton> + </div> + </MkFolder> + </SearchMarker> </div> - </FormSuspense> + </SearchMarker> </div> </PageWithHeader> </template> @@ -45,7 +58,6 @@ import { ref, computed } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; @@ -53,17 +65,11 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; -const deeplAuthKey = ref<string>(''); -const deeplIsPro = ref<boolean>(false); +const meta = await misskeyApi('admin/meta'); -const googleAnalyticsMeasurementId = ref<string>(''); - -async function init() { - const meta = await misskeyApi('admin/meta'); - deeplAuthKey.value = meta.deeplAuthKey ?? ''; - deeplIsPro.value = meta.deeplIsPro; - googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? ''; -} +const deeplAuthKey = ref(meta.deeplAuthKey ?? ''); +const deeplIsPro = ref(meta.deeplIsPro); +const googleAnalyticsMeasurementId = ref(meta.googleAnalyticsMeasurementId ?? ''); function save_deepl() { os.apiWithDialog('admin/update-meta', { diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index a87028f008..94994dc94c 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> </div> - <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> + <MkSuperMenu :def="menuDef" :searchIndex="searchIndex" :grid="narrow"></MkSuperMenu> </div> </div> </div> @@ -44,6 +44,9 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js'; import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { useRouter } from '@/router.js'; +import { genSearchIndexes } from '@/utility/inapp-search.js'; + +const searchIndex = await import('search-index:admin').then(({ searchIndexes }) => genSearchIndexes(searchIndexes)); const isEmpty = (x: string | null) => x == null || x === ''; @@ -324,12 +327,6 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); definePage(() => INFO.value); - -defineExpose({ - header: { - title: i18n.ts.controlPanel, - }, -}); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue index 659aa02b50..f96830e57a 100644 --- a/packages/frontend/src/pages/admin/job-queue.job.vue +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -98,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> <MkKeyValue v-if="job.progress != null && typeof job.progress === 'number' && job.progress > 0"> <template #key>Progress</template> - <template #value>{{ Math.floor(job.progress * 100) }}%</template> + <template #value>{{ Math.floor(job.progress) }}%</template> </MkKeyValue> </div> <MkFolder :withSpacer="false"> @@ -150,11 +150,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton><i class="ti ti-device-floppy"></i> Update</MkButton> </div> <div v-else-if="tab === 'result'"> - <MkCode :code="String(job.returnValue)"/> + <MkCode :code="JSON5.stringify(job.returnValue, null, '\t')" lang="json5"/> </div> <div v-else-if="tab === 'error'" class="_gaps_s"> <MkCode v-for="log in job.stacktrace" :code="log" lang="stacktrace"/> </div> + <div v-else-if="tab === 'logs'"> + <MkButton primary rounded @click="loadLogs()"><i class="ti ti-refresh"></i> Load logs</MkButton> + <div v-for="log in logs">{{ log }}</div> + </div> </MkFolder> </template> @@ -198,6 +202,7 @@ const emit = defineEmits<{ const tab = ref('info'); const editData = ref(JSON5.stringify(props.job.data, null, '\t')); const canEdit = true; +const logs = ref<string[]>([]); type TlType = TlEvent<{ type: 'created' | 'processed' | 'finished'; @@ -268,6 +273,10 @@ async function removeJob() { os.apiWithDialog('admin/queue/remove-job', { queue: props.queueType, jobId: props.job.id }); } +async function loadLogs() { + logs.value = await os.apiWithDialog('admin/queue/show-job-logs', { queue: props.queueType, jobId: props.job.id }); +} + // TODO // function moveJob() { // diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 819f229c10..435dd9c462 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -6,140 +6,171 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> + <SearchMarker path="/admin/moderation" :label="i18n.ts.moderation" :keywords="['moderation']" icon="ti ti-shield" :inlining="['serverRules']"> <div class="_gaps_m"> - <MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration"> - <template #label>{{ i18n.ts._serverSettings.openRegistration }}</template> - <template #caption> - <div>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.openRegistrationWarning }}</div> - </template> - </MkSwitch> + <SearchMarker :keywords="['open', 'registration']"> + <MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.openRegistration }}</SearchLabel></template> + <template #caption> + <div><SearchText>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</SearchText></div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <SearchText>{{ i18n.ts._serverSettings.openRegistrationWarning }}</SearchText></div> + </template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> - <template #label>{{ i18n.ts.emailRequiredForSignup }} ({{ i18n.ts.recommended }})</template> - </MkSwitch> + <SearchMarker :keywords="['email', 'required', 'signup']"> + <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> + <template #label><SearchLabel>{{ i18n.ts.emailRequiredForSignup }}</SearchLabel> ({{ i18n.ts.recommended }})</template> + </MkSwitch> + </SearchMarker> - <MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor"> - <template #label>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</template> - <option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option> - <option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option> - <option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option> - <template #caption> - <div>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</div> - </template> - </MkSelect> + <SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']"> + <MkSelect + v-model="ugcVisibilityForVisitor" :items="[{ + value: 'all', + label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, + }, { + value: 'local', + label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly + ' (' + i18n.ts.recommended + ')', + }, { + value: 'none', + label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, + }] as const" @update:modelValue="onChange_ugcVisibilityForVisitor" + > + <template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template> + <template #caption> + <div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</SearchText></div> + </template> + </MkSelect> + </SearchMarker> - <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> + <XServerRules/> - <MkFolder> - <template #icon><i class="ti ti-lock-star"></i></template> - <template #label>{{ i18n.ts.preservedUsernames }}</template> + <SearchMarker :keywords="['preserved', 'usernames']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-lock-star"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.preservedUsernames }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="preservedUsernames"> - <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_preservedUsernames">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="preservedUsernames"> + <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_preservedUsernames">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-message-exclamation"></i></template> - <template #label>{{ i18n.ts.sensitiveWords }}</template> + <SearchMarker :keywords="['sensitive', 'words']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-message-exclamation"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.sensitiveWords }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="sensitiveWords"> - <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> - </MkTextarea> - <MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="sensitiveWords"> + <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> + </MkTextarea> + <MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-message-x"></i></template> - <template #label>{{ i18n.ts.prohibitedWords }}</template> + <SearchMarker :keywords="['prohibited', 'words']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-message-x"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.prohibitedWords }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="prohibitedWords"> - <template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> - </MkTextarea> - <MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="prohibitedWords"> + <template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> + </MkTextarea> + <MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-user-x"></i></template> - <template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template> + <SearchMarker :keywords="['prohibited', 'name', 'user']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-user-x"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.prohibitedWordsForNameOfUser }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="prohibitedWordsForNameOfUser"> - <template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> - </MkTextarea> - <MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="prohibitedWordsForNameOfUser"> + <template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> + </MkTextarea> + <MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.hiddenTags }}</template> + <SearchMarker :keywords="['hidden', 'tags', 'hashtags']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.hiddenTags }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="hiddenTags"> - <template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_hiddenTags">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="hiddenTags"> + <template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_hiddenTags">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.silencedInstances }}</template> + <SearchMarker :keywords="['silenced', 'servers', 'hosts']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.silencedInstances }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="silencedHosts"> - <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_silencedHosts">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="silencedHosts"> + <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_silencedHosts">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.mediaSilencedInstances }}</template> + <SearchMarker :keywords="['media', 'silenced', 'servers', 'hosts']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.mediaSilencedInstances }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="mediaSilencedHosts"> - <template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_mediaSilencedHosts">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="mediaSilencedHosts"> + <template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_mediaSilencedHosts">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-ban"></i></template> - <template #label>{{ i18n.ts.blockedInstances }}</template> + <SearchMarker :keywords="['blocked', 'servers', 'hosts']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-ban"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.blockedInstances }}</SearchLabel></template> - <div class="_gaps"> - <MkTextarea v-model="blockedHosts"> - <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_blockedHosts">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="blockedHosts"> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_blockedHosts">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + </SearchMarker> </div> - </FormSuspense> + </SearchMarker> </div> </PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import XServerRules from './server-rules.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; @@ -150,32 +181,19 @@ import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; -const enableRegistration = ref<boolean>(false); -const emailRequiredForSignup = ref<boolean>(false); -const ugcVisibilityForVisitor = ref<string>('all'); -const sensitiveWords = ref<string>(''); -const prohibitedWords = ref<string>(''); -const prohibitedWordsForNameOfUser = ref<string>(''); -const hiddenTags = ref<string>(''); -const preservedUsernames = ref<string>(''); -const blockedHosts = ref<string>(''); -const silencedHosts = ref<string>(''); -const mediaSilencedHosts = ref<string>(''); +const meta = await misskeyApi('admin/meta'); -async function init() { - const meta = await misskeyApi('admin/meta'); - enableRegistration.value = !meta.disableRegistration; - emailRequiredForSignup.value = meta.emailRequiredForSignup; - ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor; - sensitiveWords.value = meta.sensitiveWords.join('\n'); - prohibitedWords.value = meta.prohibitedWords.join('\n'); - prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n'); - hiddenTags.value = meta.hiddenTags.join('\n'); - preservedUsernames.value = meta.preservedUsernames.join('\n'); - blockedHosts.value = meta.blockedHosts.join('\n'); - silencedHosts.value = meta.silencedHosts?.join('\n') ?? ''; - mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); -} +const enableRegistration = ref(!meta.disableRegistration); +const emailRequiredForSignup = ref(meta.emailRequiredForSignup); +const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor); +const sensitiveWords = ref(meta.sensitiveWords.join('\n')); +const prohibitedWords = ref(meta.prohibitedWords.join('\n')); +const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n')); +const hiddenTags = ref(meta.hiddenTags.join('\n')); +const preservedUsernames = ref(meta.preservedUsernames.join('\n')); +const blockedHosts = ref(meta.blockedHosts.join('\n')); +const silencedHosts = ref(meta.silencedHosts?.join('\n') ?? ''); +const mediaSilencedHosts = ref(meta.mediaSilencedHosts.join('\n')); async function onChange_enableRegistration(value: boolean) { if (value) { @@ -203,7 +221,7 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } -function onChange_ugcVisibilityForVisitor(value: string) { +function onChange_ugcVisibilityForVisitor(value: Misskey.entities.AdminUpdateMetaRequest['ugcVisibilityForVisitor']) { os.apiWithDialog('admin/update-meta', { ugcVisibilityForVisitor: value, }).then(() => { diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 6a6102749e..08bdc8d254 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -77,8 +77,8 @@ paginator.init(); const timeline = computed(() => { return paginator.items.value.map(x => ({ id: x.id, - timestamp: x.createdAt, - data: x, + timestamp: new Date(x.createdAt).getTime(), + data: x as Misskey.entities.ModerationLog, })); }); diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index 7a46ae41c6..d42c23a51e 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -6,70 +6,94 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> + <SearchMarker path="/admin/object-storage" :label="i18n.ts.objectStorage" :keywords="['objectStorage']" icon="ti ti-cloud"> <div class="_gaps_m"> - <MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch> + <SearchMarker> + <MkSwitch v-model="useObjectStorage"><SearchLabel>{{ i18n.ts.useObjectStorage }}</SearchLabel></MkSwitch> + </SearchMarker> <template v-if="useObjectStorage"> - <MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url"> - <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> - <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageBaseUrl }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStorageBaseUrlDesc }}</SearchText></template> + </MkInput> + </SearchMarker> - <MkInput v-model="objectStorageBucket"> - <template #label>{{ i18n.ts.objectStorageBucket }}</template> - <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageBucket"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageBucket }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStorageBucketDesc }}</SearchText></template> + </MkInput> + </SearchMarker> - <MkInput v-model="objectStoragePrefix"> - <template #label>{{ i18n.ts.objectStoragePrefix }}</template> - <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStoragePrefix"> + <template #label><SearchLabel>{{ i18n.ts.objectStoragePrefix }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStoragePrefixDesc }}</SearchText></template> + </MkInput> + </SearchMarker> - <MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'"> - <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> - <template #prefix>https://</template> - <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageEndpoint }}</SearchLabel></template> + <template #prefix>https://</template> + <template #caption><SearchText>{{ i18n.ts.objectStorageEndpointDesc }}</SearchText></template> + </MkInput> + </SearchMarker> - <MkInput v-model="objectStorageRegion"> - <template #label>{{ i18n.ts.objectStorageRegion }}</template> - <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageRegion"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageRegion }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStorageRegionDesc }}</SearchText></template> + </MkInput> + </SearchMarker> <FormSplit :minWidth="280"> - <MkInput v-model="objectStorageAccessKey"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>Access key</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageAccessKey"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>Access key</SearchLabel></template> + </MkInput> + </SearchMarker> - <MkInput v-model="objectStorageSecretKey" type="password"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>Secret key</template> - </MkInput> + <SearchMarker> + <MkInput v-model="objectStorageSecretKey" type="password"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>Secret key</SearchLabel></template> + </MkInput> + </SearchMarker> </FormSplit> - <MkSwitch v-model="objectStorageUseSSL"> - <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> - <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> - </MkSwitch> + <SearchMarker> + <MkSwitch v-model="objectStorageUseSSL"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageUseSSL }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStorageUseSSLDesc }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="objectStorageUseProxy"> - <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> - <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> - </MkSwitch> + <SearchMarker> + <MkSwitch v-model="objectStorageUseProxy"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageUseProxy }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.objectStorageUseProxyDesc }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="objectStorageSetPublicRead"> - <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> - </MkSwitch> + <SearchMarker> + <MkSwitch v-model="objectStorageSetPublicRead"> + <template #label><SearchLabel>{{ i18n.ts.objectStorageSetPublicRead }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="objectStorageS3ForcePathStyle"> - <template #label>s3ForcePathStyle</template> - <template #caption>{{ i18n.ts.s3ForcePathStyleDesc }}</template> - </MkSwitch> + <SearchMarker> + <MkSwitch v-model="objectStorageS3ForcePathStyle"> + <template #label><SearchLabel>s3ForcePathStyle</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts.s3ForcePathStyleDesc }}</SearchText></template> + </MkSwitch> + </SearchMarker> </template> </div> - </FormSuspense> + </SearchMarker> </div> <template #footer> <div :class="$style.footer"> @@ -94,36 +118,21 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; -const useObjectStorage = ref<boolean>(false); -const objectStorageBaseUrl = ref<string | null>(null); -const objectStorageBucket = ref<string | null>(null); -const objectStoragePrefix = ref<string | null>(null); -const objectStorageEndpoint = ref<string | null>(null); -const objectStorageRegion = ref<string | null>(null); -const objectStoragePort = ref<number | null>(null); -const objectStorageAccessKey = ref<string | null>(null); -const objectStorageSecretKey = ref<string | null>(null); -const objectStorageUseSSL = ref<boolean>(false); -const objectStorageUseProxy = ref<boolean>(false); -const objectStorageSetPublicRead = ref<boolean>(false); -const objectStorageS3ForcePathStyle = ref<boolean>(true); +const meta = await misskeyApi('admin/meta'); -async function init() { - const meta = await misskeyApi('admin/meta'); - useObjectStorage.value = meta.useObjectStorage; - objectStorageBaseUrl.value = meta.objectStorageBaseUrl; - objectStorageBucket.value = meta.objectStorageBucket; - objectStoragePrefix.value = meta.objectStoragePrefix; - objectStorageEndpoint.value = meta.objectStorageEndpoint; - objectStorageRegion.value = meta.objectStorageRegion; - objectStoragePort.value = meta.objectStoragePort; - objectStorageAccessKey.value = meta.objectStorageAccessKey; - objectStorageSecretKey.value = meta.objectStorageSecretKey; - objectStorageUseSSL.value = meta.objectStorageUseSSL; - objectStorageUseProxy.value = meta.objectStorageUseProxy; - objectStorageSetPublicRead.value = meta.objectStorageSetPublicRead; - objectStorageS3ForcePathStyle.value = meta.objectStorageS3ForcePathStyle; -} +const useObjectStorage = ref(meta.useObjectStorage); +const objectStorageBaseUrl = ref(meta.objectStorageBaseUrl); +const objectStorageBucket = ref(meta.objectStorageBucket); +const objectStoragePrefix = ref(meta.objectStoragePrefix); +const objectStorageEndpoint = ref(meta.objectStorageEndpoint); +const objectStorageRegion = ref(meta.objectStorageRegion); +const objectStoragePort = ref(meta.objectStoragePort); +const objectStorageAccessKey = ref(meta.objectStorageAccessKey); +const objectStorageSecretKey = ref(meta.objectStorageSecretKey); +const objectStorageUseSSL = ref(meta.objectStorageUseSSL); +const objectStorageUseProxy = ref(meta.objectStorageUseProxy); +const objectStorageSetPublicRead = ref(meta.objectStorageSetPublicRead); +const objectStorageS3ForcePathStyle = ref(meta.objectStorageS3ForcePathStyle); function save() { os.apiWithDialog('admin/update-meta', { diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts index 88747ef4ed..855fc6caf0 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts +++ b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts @@ -5,7 +5,7 @@ import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { commonHandlers } from '../../../.storybook/mocks.js'; import overview_ap_requests from './overview.ap-requests.vue'; export const Default = { diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index c28621b11e..e3021778e7 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -6,102 +6,163 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <div class="_gaps"> - <div class="_panel" style="padding: 16px;"> - <MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats"> - <template #label>{{ i18n.ts.enableServerMachineStats }}</template> - <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> - </MkSwitch> - </div> - - <div class="_panel" style="padding: 16px;"> - <MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration"> - <template #label>{{ i18n.ts.enableIdenticonGeneration }}</template> - <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> - </MkSwitch> - </div> + <SearchMarker path="/admin/performance" :label="i18n.ts.performance" :keywords="['performance']" icon="ti ti-bolt"> + <div class="_gaps"> + <SearchMarker> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats"> + <template #label><SearchLabel>{{ i18n.ts.enableServerMachineStats }}</SearchLabel></template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + </SearchMarker> - <div class="_panel" style="padding: 16px;"> - <MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser"> - <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> - <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> - </MkSwitch> - </div> + <SearchMarker> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration"> + <template #label><SearchLabel>{{ i18n.ts.enableIdenticonGeneration }}</SearchLabel></template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + </SearchMarker> - <div class="_panel" style="padding: 16px;"> - <MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances"> - <template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template> - <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> - </MkSwitch> - </div> + <SearchMarker> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser"> + <template #label><SearchLabel>{{ i18n.ts.enableChartsForRemoteUser }}</SearchLabel></template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + </SearchMarker> - <div class="_panel" style="padding: 16px;"> - <MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances"> - <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> - <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> - </MkSwitch> - </div> + <SearchMarker> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances"> + <template #label><SearchLabel>{{ i18n.ts.enableStatsForFederatedInstances }}</SearchLabel></template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + </SearchMarker> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-bolt"></i></template> - <template #label>Misskey® Fan-out Timeline Technology™ (FTT)</template> - <template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template> - <template v-else #suffix>Disabled</template> - <template v-if="fttForm.modified.value" #footer> - <MkFormFooter :form="fttForm"/> - </template> + <SearchMarker> + <div class="_panel" style="padding: 16px;"> + <MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances"> + <template #label><SearchLabel>{{ i18n.ts.enableChartsForFederatedInstances }}</SearchLabel></template> + <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> + </MkSwitch> + </div> + </SearchMarker> - <div class="_gaps"> - <MkSwitch v-model="fttForm.state.enableFanoutTimeline"> - <template #label>{{ i18n.ts.enable }}<span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption> - <div>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</div> - <div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div> + <SearchMarker> + <MkFolder :defaultOpen="true"> + <template #icon><SearchIcon><i class="ti ti-bolt"></i></SearchIcon></template> + <template #label><SearchLabel>Misskey® Fan-out Timeline Technology™ (FTT)</SearchLabel></template> + <template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + <template v-if="fttForm.modified.value" #footer> + <MkFormFooter :form="fttForm"/> </template> - </MkSwitch> - <template v-if="fttForm.state.enableFanoutTimeline"> - <MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback"> - <template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}<span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template> - </MkSwitch> + <div class="_gaps"> + <SearchMarker> + <MkSwitch v-model="fttForm.state.enableFanoutTimeline"> + <template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption> + <div><SearchText>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</SearchText></div> + <div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div> + </template> + </MkSwitch> + </SearchMarker> + + <template v-if="fttForm.state.enableFanoutTimeline"> + <SearchMarker :keywords="['db', 'database', 'fallback']"> + <MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}</SearchLabel><span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number"> - <template #label>perLocalUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> + <SearchMarker> + <MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number"> + <template #label><SearchLabel>perLocalUserUserTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> - <MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number"> - <template #label>perRemoteUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> + <SearchMarker> + <MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number"> + <template #label><SearchLabel>perRemoteUserUserTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> - <MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number"> - <template #label>perUserHomeTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> + <SearchMarker> + <MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number"> + <template #label><SearchLabel>perUserHomeTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> - <MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number"> - <template #label>perUserListTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> - </template> - </div> - </MkFolder> + <SearchMarker> + <MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number"> + <template #label><SearchLabel>perUserListTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> + </template> + </div> + </MkFolder> + </SearchMarker> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-bolt"></i></template> - <template #label>Misskey® Reactions Boost Technology™ (RBT)<span class="_beta">{{ i18n.ts.beta }}</span></template> - <template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template> - <template v-else #suffix>Disabled</template> - <template v-if="rbtForm.modified.value" #footer> - <MkFormFooter :form="rbtForm"/> - </template> + <SearchMarker> + <MkFolder :defaultOpen="true"> + <template #icon><SearchIcon><i class="ti ti-bolt"></i></SearchIcon></template> + <template #label><SearchLabel>Misskey® Reactions Boost Technology™ (RBT)</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + <template v-if="rbtForm.modified.value" #footer> + <MkFormFooter :form="rbtForm"/> + </template> + + <div class="_gaps_m"> + <SearchMarker> + <MkSwitch v-model="rbtForm.state.enableReactionsBuffering"> + <template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</SearchText></template> + </MkSwitch> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker> + <MkFolder :defaultOpen="true"> + <template #icon><SearchIcon><i class="ti ti-recycle"></i></SearchIcon></template> + <template #label><SearchLabel>Remote Notes Cleaning (仮)</SearchLabel></template> + <template v-if="remoteNotesCleaningForm.savedState.enableRemoteNotesCleaning" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + <template v-if="remoteNotesCleaningForm.modified.value" #footer> + <MkFormFooter :form="remoteNotesCleaningForm"/> + </template> - <div class="_gaps_m"> - <MkSwitch v-model="rbtForm.state.enableReactionsBuffering"> - <template #label>{{ i18n.ts.enable }}<span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</template> - </MkSwitch> - </div> - </MkFolder> - </div> + <div class="_gaps_m"> + <MkSwitch v-model="remoteNotesCleaningForm.state.enableRemoteNotesCleaning"> + <template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="remoteNotesCleaningForm.modifiedStates.enableRemoteNotesCleaning" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.remoteNotesCleaning_description }}</SearchText></template> + </MkSwitch> + + <template v-if="remoteNotesCleaningForm.state.enableRemoteNotesCleaning"> + <MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningExpiryDaysForEachNotes" type="number"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.remoteNotesCleaningExpiryDaysForEachNotes }}</SearchLabel> ({{ i18n.ts.inDays }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningExpiryDaysForEachNotes" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #suffix>{{ i18n.ts._time.day }}</template> + </MkInput> + + <MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningMaxProcessingDurationInMinutes" type="number"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.remoteNotesCleaningMaxProcessingDuration }}</SearchLabel> ({{ i18n.ts.inMinutes }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningMaxProcessingDurationInMinutes" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </template> + </div> + </MkFolder> + </SearchMarker> + </div> + </SearchMarker> </div> </PageWithHeader> </template> @@ -196,12 +257,25 @@ const rbtForm = useForm({ fetchInstance(true); }); +const remoteNotesCleaningForm = useForm({ + enableRemoteNotesCleaning: meta.enableRemoteNotesCleaning, + remoteNotesCleaningExpiryDaysForEachNotes: meta.remoteNotesCleaningExpiryDaysForEachNotes, + remoteNotesCleaningMaxProcessingDurationInMinutes: meta.remoteNotesCleaningMaxProcessingDurationInMinutes, +}, async (state) => { + await os.apiWithDialog('admin/update-meta', { + enableRemoteNotesCleaning: state.enableRemoteNotesCleaning, + remoteNotesCleaningExpiryDaysForEachNotes: state.remoteNotesCleaningExpiryDaysForEachNotes, + remoteNotesCleaningMaxProcessingDurationInMinutes: state.remoteNotesCleaningMaxProcessingDurationInMinutes, + }); + fetchInstance(true); +}); + const headerActions = computed(() => []); const headerTabs = computed(() => []); definePage(() => ({ - title: i18n.ts.other, - icon: 'ti ti-adjustments', + title: i18n.ts.performance, + icon: 'ti ti-bolt', })); </script> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index aabf64342e..9eba68022a 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -6,18 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div class="_gaps"> - <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;"> - <div>{{ relay.inbox }}</div> - <div style="margin: 8px 0;"> - <i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--MI_THEME-success);"></i> - <i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--MI_THEME-error);"></i> - <i v-else class="ti ti-clock" :class="$style.icon"></i> - <span>{{ i18n.ts._relayStatus[relay.status] }}</span> + <SearchMarker path="/admin/relays" :label="i18n.ts.relays" :keywords="['relays']" icon="ti ti-planet"> + <div class="_gaps"> + <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div style="margin: 8px 0;"> + <i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--MI_THEME-success);"></i> + <i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--MI_THEME-error);"></i> + <i v-else class="ti ti-clock" :class="$style.icon"></i> + <span>{{ i18n.ts._relayStatus[relay.status] }}</span> + </div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> </div> - <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> </div> - </div> + </SearchMarker> </div> </PageWithHeader> </template> @@ -39,7 +41,7 @@ async function addRelay() { type: 'url', placeholder: i18n.ts.inboxUrl, }); - if (canceled) return; + if (canceled || inbox == null) return; misskeyApi('admin/relays/add', { inbox, }).then((relay: any) => { diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index 1a903eedb9..b24b640527 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -72,12 +72,20 @@ async function save() { roleId: role.value.id, ...data.value, }); - router.push('/admin/roles/' + role.value.id); + router.push('/admin/roles/:id', { + params: { + id: role.value.id, + } + }); } else { const created = await os.apiWithDialog('admin/roles/create', { ...data.value, }); - router.push('/admin/roles/' + created.id); + router.push('/admin/roles/:id', { + params: { + id: created.id, + } + }); } } diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index c172e22688..bb96a1cde1 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -346,6 +346,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])"> + <template #label>{{ i18n.ts._role._options.canSearchUsers }}</template> + <template #suffix> + <span v-if="role.policies.canSearchUsers.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canSearchUsers.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canSearchUsers)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canSearchUsers.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canSearchUsers.value" :disabled="role.policies.canSearchUsers.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canSearchUsers.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])"> <template #label>{{ i18n.ts._role._options.canUseTranslator }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 1816aec21e..c6c3165828 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -88,7 +88,11 @@ const role = reactive(await misskeyApi('admin/roles/show', { })); function edit() { - router.push('/admin/roles/' + role.id + '/edit'); + router.push('/admin/roles/:id/edit', { + params: { + id: role.id, + } + }); } async function del() { diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index e78a4bbc11..efdf8620ef 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -122,6 +122,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])"> + <template #label>{{ i18n.ts._role._options.canSearchUsers }}</template> + <template #suffix>{{ policies.canSearchUsers ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canSearchUsers"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])"> <template #label>{{ i18n.ts._role._options.canUseTranslator }}</template> <template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 9e907a4469..27e35c7e69 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -6,115 +6,153 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <div class="_gaps_m"> - <XBotProtection/> + <SearchMarker path="/admin/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['botProtection']"> + <div class="_gaps_m"> + <XBotProtection/> - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> - <template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> - <template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> - <template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> - <template v-else #suffix>{{ i18n.ts.none }}</template> - <template v-if="sensitiveMediaDetectionForm.modified.value" #footer> - <MkFormFooter :form="sensitiveMediaDetectionForm"/> - </template> + <SearchMarker v-slot="slotProps" :keywords="['sensitive', 'media', 'detection']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.sensitiveMediaDetection }}</SearchLabel></template> + <template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> + <template v-if="sensitiveMediaDetectionForm.modified.value" #footer> + <MkFormFooter :form="sensitiveMediaDetectionForm"/> + </template> - <div class="_gaps_m"> - <span>{{ i18n.ts._sensitiveMediaDetection.description }}</span> + <div class="_gaps_m"> + <div><SearchText>{{ i18n.ts._sensitiveMediaDetection.description }}</SearchText></div> - <MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection"> - <option value="none">{{ i18n.ts.none }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.localOnly }}</option> - <option value="remote">{{ i18n.ts.remoteOnly }}</option> - </MkRadios> + <MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </MkRadios> - <MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`"> - <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> - <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> - </MkRange> + <SearchMarker :keywords="['sensitivity']"> + <MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`"> + <template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</SearchLabel></template> + <template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</SearchText></template> + </MkRange> + </SearchMarker> - <MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos"> - <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> - <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> - </MkSwitch> + <SearchMarker :keywords="['video', 'analyze']"> + <MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos"> + <template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically"> - <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> - <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> - </MkSwitch> + <SearchMarker :keywords="['flag', 'automatically']"> + <MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically"> + <template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }}</SearchLabel> ({{ i18n.ts.notRecommended }})</template> + <template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <!-- 現状 false positive が多すぎて実用に耐えない + <!-- 現状 false positive が多すぎて実用に耐えない <MkSwitch v-model="disallowUploadWhenPredictedAsPorn"> <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> </MkSwitch> --> - </div> - </MkFolder> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #label>Active Email Validation</template> - <template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template> - <template v-else #suffix>Disabled</template> - <template v-if="emailValidationForm.modified.value" #footer> - <MkFormFooter :form="emailValidationForm"/> - </template> + <SearchMarker v-slot="slotProps" :keywords="['email', 'validation']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>Active Email Validation</SearchLabel></template> + <template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + <template v-if="emailValidationForm.modified.value" #footer> + <MkFormFooter :form="emailValidationForm"/> + </template> - <div class="_gaps_m"> - <span>{{ i18n.ts.activeEmailValidationDescription }}</span> - <MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation"> - <template #label>Enable</template> - </MkSwitch> - <MkSwitch v-model="emailValidationForm.state.enableVerifymailApi"> - <template #label>Use Verifymail.io API</template> - </MkSwitch> - <MkInput v-model="emailValidationForm.state.verifymailAuthKey"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>Verifymail.io API Auth Key</template> - </MkInput> - <MkSwitch v-model="emailValidationForm.state.enableTruemailApi"> - <template #label>Use TrueMail API</template> - </MkSwitch> - <MkInput v-model="emailValidationForm.state.truemailInstance"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>TrueMail API Instance</template> - </MkInput> - <MkInput v-model="emailValidationForm.state.truemailAuthKey"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>TrueMail API Auth Key</template> - </MkInput> - </div> - </MkFolder> + <div class="_gaps_m"> + <div><SearchText>{{ i18n.ts.activeEmailValidationDescription }}</SearchText></div> - <MkFolder> - <template #label>Banned Email Domains</template> - <template v-if="bannedEmailDomainsForm.modified.value" #footer> - <MkFormFooter :form="bannedEmailDomainsForm"/> - </template> + <SearchMarker> + <MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation"> + <template #label><SearchLabel>Enable</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <div class="_gaps_m"> - <MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains"> - <template #label>Banned Email Domains List</template> - </MkTextarea> - </div> - </MkFolder> + <SearchMarker> + <MkSwitch v-model="emailValidationForm.state.enableVerifymailApi"> + <template #label><SearchLabel>Use Verifymail.io API</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkFolder> - <template #label>Log IP address</template> - <template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template> - <template v-else #suffix>Disabled</template> - <template v-if="ipLoggingForm.modified.value" #footer> - <MkFormFooter :form="ipLoggingForm"/> - </template> + <SearchMarker> + <MkInput v-model="emailValidationForm.state.verifymailAuthKey"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>Verifymail.io API Auth Key</SearchLabel></template> + </MkInput> + </SearchMarker> - <div class="_gaps_m"> - <MkSwitch v-model="ipLoggingForm.state.enableIpLogging"> - <template #label>Enable</template> - </MkSwitch> - </div> - </MkFolder> - </div> + <SearchMarker> + <MkSwitch v-model="emailValidationForm.state.enableTruemailApi"> + <template #label><SearchLabel>Use TrueMail API</SearchLabel></template> + </MkSwitch> + </SearchMarker> + + <SearchMarker> + <MkInput v-model="emailValidationForm.state.truemailInstance"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>TrueMail API Instance</SearchLabel></template> + </MkInput> + </SearchMarker> + + <SearchMarker> + <MkInput v-model="emailValidationForm.state.truemailAuthKey"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label><SearchLabel>TrueMail API Auth Key</SearchLabel></template> + </MkInput> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker v-slot="slotProps" :keywords="['banned', 'email', 'domains', 'blacklist']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>Banned Email Domains</SearchLabel></template> + <template v-if="bannedEmailDomainsForm.modified.value" #footer> + <MkFormFooter :form="bannedEmailDomainsForm"/> + </template> + + <div class="_gaps_m"> + <SearchMarker> + <MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains"> + <template #label><SearchLabel>Banned Email Domains List</SearchLabel></template> + </MkTextarea> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker v-slot="slotProps" :keywords="['log', 'ipAddress']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>Log IP address</SearchLabel></template> + <template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + <template v-if="ipLoggingForm.modified.value" #footer> + <MkFormFooter :form="ipLoggingForm"/> + </template> + + <div class="_gaps_m"> + <SearchMarker> + <MkSwitch v-model="ipLoggingForm.state.enableIpLogging"> + <template #label><SearchLabel>Enable</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </div> + </SearchMarker> </div> </PageWithHeader> </template> diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index 276a7590c4..d26f02b41c 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -4,10 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader :tabs="headerTabs"> - <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> +<SearchMarker markerId="serverRules" :keywords="['rules']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-checkbox"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.serverRules }}</SearchLabel></template> + <div class="_gaps_m"> - <div>{{ i18n.ts._serverRules.description }}</div> + <div><SearchText>{{ i18n.ts._serverRules.description }}</SearchText></div> + <Sortable v-model="serverRules" class="_gaps_m" @@ -33,8 +37,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> - </div> -</PageWithHeader> + </MkFolder> +</SearchMarker> </template> <script lang="ts" setup> @@ -42,9 +46,9 @@ import { defineAsyncComponent, ref, computed } from 'vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkFolder from '@/components/MkFolder.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -60,13 +64,6 @@ const save = async () => { const remove = (index: number): void => { serverRules.value.splice(index, 1); }; - -const headerTabs = computed(() => []); - -definePage(() => ({ - title: i18n.ts.serverRules, - icon: 'ti ti-checkbox', -})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index f6a2eb1c27..541ee7c0cd 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -6,288 +6,369 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <div class="_gaps_m"> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-info-circle"></i></template> - <template #label>{{ i18n.ts.info }}</template> - <template v-if="infoForm.modified.value" #footer> - <MkFormFooter :form="infoForm"/> - </template> + <SearchMarker path="/admin/settings" :label="i18n.ts.general" :keywords="['general', 'settings']" icon="ti ti-settings"> + <div class="_gaps_m"> + <SearchMarker v-slot="slotProps" :keywords="['information', 'meta']"> + <MkFolder :defaultOpen="true"> + <template #icon><SearchIcon><i class="ti ti-info-circle"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.info }}</SearchLabel></template> + <template v-if="infoForm.modified.value" #footer> + <MkFormFooter :form="infoForm"/> + </template> - <div class="_gaps"> - <MkInput v-model="infoForm.state.name"> - <template #label>{{ i18n.ts.instanceName }}<span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> + <div class="_gaps"> + <SearchMarker :keywords="['name']"> + <MkInput v-model="infoForm.state.name"> + <template #label><SearchLabel>{{ i18n.ts.instanceName }}</SearchLabel><span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoForm.state.shortName"> - <template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template> - </MkInput> + <SearchMarker :keywords="['shortName']"> + <MkInput v-model="infoForm.state.shortName"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.shortName }}</SearchLabel> ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.shortNameDescription }}</SearchText></template> + </MkInput> + </SearchMarker> - <MkTextarea v-model="infoForm.state.description"> - <template #label>{{ i18n.ts.instanceDescription }}<span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkTextarea> + <SearchMarker :keywords="['description']"> + <MkTextarea v-model="infoForm.state.description"> + <template #label><SearchLabel>{{ i18n.ts.instanceDescription }}</SearchLabel><span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkTextarea> + </SearchMarker> - <FormSplit :minWidth="300"> - <MkInput v-model="infoForm.state.maintainerName"> - <template #label>{{ i18n.ts.maintainerName }}<span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkInput> + <FormSplit :minWidth="300"> + <SearchMarker :keywords="['maintainer', 'name']"> + <MkInput v-model="infoForm.state.maintainerName"> + <template #label><SearchLabel>{{ i18n.ts.maintainerName }}</SearchLabel><span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoForm.state.maintainerEmail" type="email"> - <template #label>{{ i18n.ts.maintainerEmail }}<span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #prefix><i class="ti ti-mail"></i></template> - </MkInput> - </FormSplit> + <SearchMarker :keywords="['maintainer', 'email', 'contact']"> + <MkInput v-model="infoForm.state.maintainerEmail" type="email"> + <template #label><SearchLabel>{{ i18n.ts.maintainerEmail }}</SearchLabel><span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #prefix><i class="ti ti-mail"></i></template> + </MkInput> + </SearchMarker> + </FormSplit> - <MkInput v-model="infoForm.state.tosUrl" type="url"> - <template #label>{{ i18n.ts.tosUrl }}<span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #prefix><i class="ti ti-link"></i></template> - </MkInput> + <SearchMarker :keywords="['tos', 'termsOfService']"> + <MkInput v-model="infoForm.state.tosUrl" type="url"> + <template #label><SearchLabel>{{ i18n.ts.tosUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #prefix><i class="ti ti-link"></i></template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoForm.state.privacyPolicyUrl" type="url"> - <template #label>{{ i18n.ts.privacyPolicyUrl }}<span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #prefix><i class="ti ti-link"></i></template> - </MkInput> + <SearchMarker :keywords="['privacyPolicy']"> + <MkInput v-model="infoForm.state.privacyPolicyUrl" type="url"> + <template #label><SearchLabel>{{ i18n.ts.privacyPolicyUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #prefix><i class="ti ti-link"></i></template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoForm.state.inquiryUrl" type="url"> - <template #label>{{ i18n.ts._serverSettings.inquiryUrl }}<span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template> - <template #prefix><i class="ti ti-link"></i></template> - </MkInput> + <SearchMarker :keywords="['inquiry', 'contact']"> + <MkInput v-model="infoForm.state.inquiryUrl" type="url"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.inquiryUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</SearchText></template> + <template #prefix><i class="ti ti-link"></i></template> + </MkInput> + </SearchMarker> - <MkInput v-model="infoForm.state.repositoryUrl" type="url"> - <template #label>{{ i18n.ts.repositoryUrl }}<span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.repositoryUrlDescription }}</template> - <template #prefix><i class="ti ti-link"></i></template> - </MkInput> + <SearchMarker :keywords="['repository', 'url']"> + <MkInput v-model="infoForm.state.repositoryUrl" type="url"> + <template #label><SearchLabel>{{ i18n.ts.repositoryUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.repositoryUrlDescription }}</SearchText></template> + <template #prefix><i class="ti ti-link"></i></template> + </MkInput> + </SearchMarker> - <MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn> - {{ i18n.ts.repositoryUrlOrTarballRequired }} - </MkInfo> + <MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn> + {{ i18n.ts.repositoryUrlOrTarballRequired }} + </MkInfo> - <MkInput v-model="infoForm.state.impressumUrl" type="url"> - <template #label>{{ i18n.ts.impressumUrl }}<span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.impressumDescription }}</template> - <template #prefix><i class="ti ti-link"></i></template> - </MkInput> - </div> - </MkFolder> + <SearchMarker :keywords="['impressum', 'legalNotice']"> + <MkInput v-model="infoForm.state.impressumUrl" type="url"> + <template #label><SearchLabel>{{ i18n.ts.impressumUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.impressumDescription }}</SearchText></template> + <template #prefix><i class="ti ti-link"></i></template> + </MkInput> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker v-slot="slotProps" :keywords="['pinned', 'users']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-user-star"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.pinnedUsers }}</SearchLabel></template> + <template v-if="pinnedUsersForm.modified.value" #footer> + <MkFormFooter :form="pinnedUsersForm"/> + </template> - <MkFolder> - <template #icon><i class="ti ti-user-star"></i></template> - <template #label>{{ i18n.ts.pinnedUsers }}</template> - <template v-if="pinnedUsersForm.modified.value" #footer> - <MkFormFooter :form="pinnedUsersForm"/> - </template> + <MkTextarea v-model="pinnedUsersForm.state.pinnedUsers"> + <template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.pinnedUsersDescription }}</SearchText></template> + </MkTextarea> + </MkFolder> + </SearchMarker> - <MkTextarea v-model="pinnedUsersForm.state.pinnedUsers"> - <template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> - </MkTextarea> - </MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['serviceWorker']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-world-cog"></i></SearchIcon></template> + <template #label><SearchLabel>ServiceWorker</SearchLabel></template> + <template v-if="serviceWorkerForm.modified.value" #footer> + <MkFormFooter :form="serviceWorkerForm"/> + </template> - <MkFolder> - <template #icon><i class="ti ti-world-cog"></i></template> - <template #label>ServiceWorker</template> - <template v-if="serviceWorkerForm.modified.value" #footer> - <MkFormFooter :form="serviceWorkerForm"/> - </template> + <div class="_gaps"> + <SearchMarker> + <MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker"> + <template #label><SearchLabel>{{ i18n.ts.enableServiceworker }}</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.serviceworkerInfo }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <div class="_gaps"> - <MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker"> - <template #label>{{ i18n.ts.enableServiceworker }}<span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> - </MkSwitch> + <template v-if="serviceWorkerForm.state.enableServiceWorker"> + <SearchMarker> + <MkInput v-model="serviceWorkerForm.state.swPublicKey"> + <template #label><SearchLabel>Public key</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #prefix><i class="ti ti-key"></i></template> + </MkInput> + </SearchMarker> - <template v-if="serviceWorkerForm.state.enableServiceWorker"> - <MkInput v-model="serviceWorkerForm.state.swPublicKey"> - <template #label>Public key<span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #prefix><i class="ti ti-key"></i></template> - </MkInput> + <SearchMarker> + <MkInput v-model="serviceWorkerForm.state.swPrivateKey"> + <template #label><SearchLabel>Private key</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #prefix><i class="ti ti-key"></i></template> + </MkInput> + </SearchMarker> + </template> + </div> + </MkFolder> + </SearchMarker> - <MkInput v-model="serviceWorkerForm.state.swPrivateKey"> - <template #label>Private key<span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #prefix><i class="ti ti-key"></i></template> - </MkInput> - </template> - </div> - </MkFolder> + <SearchMarker v-slot="slotProps" :keywords="['ads']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-ad"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts._ad.adsSettings }}</SearchLabel></template> + <template v-if="adForm.modified.value" #footer> + <MkFormFooter :form="adForm"/> + </template> - <MkFolder> - <template #icon><i class="ti ti-ad"></i></template> - <template #label>{{ i18n.ts._ad.adsSettings }}</template> - <template v-if="adForm.modified.value" #footer> - <MkFormFooter :form="adForm"/> - </template> + <div class="_gaps"> + <div class="_gaps_s"> + <SearchMarker> + <MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number"> + <template #label><SearchLabel>{{ i18n.ts._ad.notesPerOneAd }}</SearchLabel><span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template> + </MkInput> + </SearchMarker> - <div class="_gaps"> - <div class="_gaps_s"> - <MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number"> - <template #label>{{ i18n.ts._ad.notesPerOneAd }}<span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template> - </MkInput> - <MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true"> - {{ i18n.ts._ad.adsTooClose }} - </MkInfo> - </div> - </div> - </MkFolder> + <MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true"> + {{ i18n.ts._ad.adsTooClose }} + </MkInfo> + </div> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-world-search"></i></template> - <template #label>{{ i18n.ts._urlPreviewSetting.title }}</template> - <template v-if="urlPreviewForm.modified.value" #footer> - <MkFormFooter :form="urlPreviewForm"/> - </template> + <SearchMarker v-slot="slotProps" :keywords="['url', 'preview']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-world-search"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.title }}</SearchLabel></template> + <template v-if="urlPreviewForm.modified.value" #footer> + <MkFormFooter :form="urlPreviewForm"/> + </template> - <div class="_gaps"> - <MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled"> - <template #label>{{ i18n.ts._urlPreviewSetting.enable }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template> - </MkSwitch> + <div class="_gaps"> + <SearchMarker> + <MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.enable }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkSwitch> + </SearchMarker> - <template v-if="urlPreviewForm.state.urlPreviewEnabled"> - <MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect"> - <template #label>{{ i18n.ts._urlPreviewSetting.allowRedirect }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template> - </MkSwitch> + <template v-if="urlPreviewForm.state.urlPreviewEnabled"> + <SearchMarker :keywords="['allow', 'redirect']"> + <MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.allowRedirect }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength"> - <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template> - </MkSwitch> + <SearchMarker :keywords="['contentLength']"> + <MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template> + </MkSwitch> + </SearchMarker> - <MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number"> - <template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template> - </MkInput> + <SearchMarker :keywords="['contentLength']"> + <MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template> + </MkInput> + </SearchMarker> - <MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number"> - <template #label>{{ i18n.ts._urlPreviewSetting.timeout }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template> - </MkInput> + <SearchMarker :keywords="['timeout']"> + <MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.timeout }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template> + </MkInput> + </SearchMarker> - <MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text"> - <template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template> - </MkInput> + <SearchMarker :keywords="['userAgent']"> + <MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.userAgent }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template> + </MkInput> + </SearchMarker> - <div> - <MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text"> - <template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template> - </MkInput> + <div> + <SearchMarker :keywords="['proxy']"> + <MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text"> + <template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template> + </MkInput> + </SearchMarker> - <div :class="$style.subCaption"> - {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} - <ul style="padding-left: 20px; margin: 4px 0"> - <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> - <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> - <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> - <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> - </ul> - </div> + <div :class="$style.subCaption"> + {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} + <ul style="padding-left: 20px; margin: 4px 0"> + <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> + <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> + <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> + <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> + </ul> + </div> + </div> + </template> </div> - </template> - </div> - </MkFolder> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-planet"></i></template> - <template #label>{{ i18n.ts.federation }}</template> - <template v-if="federationForm.savedState.federation === 'all'" #suffix>{{ i18n.ts.all }}</template> - <template v-else-if="federationForm.savedState.federation === 'specified'" #suffix>{{ i18n.ts.specifyHost }}</template> - <template v-else-if="federationForm.savedState.federation === 'none'" #suffix>{{ i18n.ts.none }}</template> - <template v-if="federationForm.modified.value" #footer> - <MkFormFooter :form="federationForm"/> - </template> + <SearchMarker v-slot="slotProps" :keywords="['federation']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-planet"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.federation }}</SearchLabel></template> + <template v-if="federationForm.savedState.federation === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="federationForm.savedState.federation === 'specified'" #suffix>{{ i18n.ts.specifyHost }}</template> + <template v-else-if="federationForm.savedState.federation === 'none'" #suffix>{{ i18n.ts.none }}</template> + <template v-if="federationForm.modified.value" #footer> + <MkFormFooter :form="federationForm"/> + </template> - <div class="_gaps"> - <MkRadios v-model="federationForm.state.federation"> - <template #label>{{ i18n.ts.behavior }}<span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="specified">{{ i18n.ts.specifyHost }}</option> - <option value="none">{{ i18n.ts.none }}</option> - </MkRadios> + <div class="_gaps"> + <SearchMarker> + <MkRadios v-model="federationForm.state.federation"> + <template #label><SearchLabel>{{ i18n.ts.behavior }}</SearchLabel><span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="specified">{{ i18n.ts.specifyHost }}</option> + <option value="none">{{ i18n.ts.none }}</option> + </MkRadios> + </SearchMarker> - <MkTextarea v-if="federationForm.state.federation === 'specified'" v-model="federationForm.state.federationHosts"> - <template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template> - </MkTextarea> + <SearchMarker :keywords="['hosts']"> + <MkTextarea v-if="federationForm.state.federation === 'specified'" v-model="federationForm.state.federationHosts"> + <template #label><SearchLabel>{{ i18n.ts.federationAllowedHosts }}</SearchLabel><span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template> + </MkTextarea> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-list"></i></template> - <template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template> - <template #footer> - <div class="_buttons"> - <MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - </div> - </template> + <SearchMarker :keywords="['suspended', 'software']"> + <MkFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template> + <template #footer> + <div class="_buttons"> + <MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + </div> + </template> - <div :class="$style.metadataRoot" class="_gaps_s"> - <MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo> - <div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem"> - <button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button> - <div :class="$style.dragItemForm"> - <FormSplit :minWidth="200"> - <MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName"> - </MkInput> - <MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version"> - </MkInput> - </FormSplit> - </div> - </div> - </div> - </MkFolder> + <div :class="$style.metadataRoot" class="_gaps_s"> + <MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo> + <div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem"> + <button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button> + <div :class="$style.dragItemForm"> + <FormSplit :minWidth="200"> + <MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName"> + </MkInput> + <MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version"> + </MkInput> + </FormSplit> + </div> + </div> + </div> + </MkFolder> + </SearchMarker> - <MkSwitch v-model="federationForm.state.signToActivityPubGet"> - <template #label>{{ i18n.ts._serverSettings.signToActivityPubGet }}<span v-if="federationForm.modifiedStates.signToActivityPubGet" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.signToActivityPubGet_description }}</template> - </MkSwitch> + <SearchMarker :keywords="['sign', 'get']"> + <MkSwitch v-model="federationForm.state.signToActivityPubGet"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.signToActivityPubGet }}</SearchLabel><span v-if="federationForm.modifiedStates.signToActivityPubGet" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.signToActivityPubGet_description }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="federationForm.state.proxyRemoteFiles"> - <template #label>{{ i18n.ts._serverSettings.proxyRemoteFiles }}<span v-if="federationForm.modifiedStates.proxyRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts._serverSettings.proxyRemoteFiles_description }}</template> - </MkSwitch> + <SearchMarker :keywords="['proxy', 'remote', 'files']"> + <MkSwitch v-model="federationForm.state.proxyRemoteFiles"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.proxyRemoteFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.proxyRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts._serverSettings.proxyRemoteFiles_description }}</SearchText></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="federationForm.state.allowExternalApRedirect"> - <template #label>{{ i18n.ts._serverSettings.allowExternalApRedirect }}<span v-if="federationForm.modifiedStates.allowExternalApRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption> - <div>{{ i18n.ts._serverSettings.allowExternalApRedirect_description }}</div> - <div>{{ i18n.ts.needToRestartServerToApply }}</div> - </template> - </MkSwitch> + <SearchMarker :keywords="['allow', 'external', 'redirect']"> + <MkSwitch v-model="federationForm.state.allowExternalApRedirect"> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.allowExternalApRedirect }}</SearchLabel><span v-if="federationForm.modifiedStates.allowExternalApRedirect" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption> + <div><SearchText>{{ i18n.ts._serverSettings.allowExternalApRedirect_description }}</SearchText></div> + <div>{{ i18n.ts.needToRestartServerToApply }}</div> + </template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="federationForm.state.cacheRemoteFiles"> - <template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template> - </MkSwitch> + <SearchMarker :keywords="['cache', 'remote', 'files']"> + <MkSwitch v-model="federationForm.state.cacheRemoteFiles"> + <template #label><SearchLabel>{{ i18n.ts.cacheRemoteFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.cacheRemoteFilesDescription }}</SearchText>{{ i18n.ts.youCanCleanRemoteFilesCache }}</template> + </MkSwitch> + </SearchMarker> - <template v-if="federationForm.state.cacheRemoteFiles"> - <MkSwitch v-model="federationForm.state.cacheRemoteSensitiveFiles"> - <template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template> - </MkSwitch> - </template> - </div> - </MkFolder> + <template v-if="federationForm.state.cacheRemoteFiles"> + <SearchMarker :keywords="['cache', 'remote', 'sensitive', 'files']"> + <MkSwitch v-model="federationForm.state.cacheRemoteSensitiveFiles"> + <template #label><SearchLabel>{{ i18n.ts.cacheRemoteSensitiveFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption><SearchText>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</SearchText></template> + </MkSwitch> + </SearchMarker> + </template> + </div> + </MkFolder> + </SearchMarker> - <MkFolder> - <template #icon><i class="ti ti-ghost"></i></template> - <template #label>{{ i18n.ts.proxyAccount }}</template> - <template v-if="proxyAccountForm.modified.value" #footer> - <MkFormFooter :form="proxyAccountForm"/> - </template> + <SearchMarker v-slot="slotProps" :keywords="['proxy', 'account']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #icon><SearchIcon><i class="ti ti-ghost"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.proxyAccount }}</SearchLabel></template> + <template v-if="proxyAccountForm.modified.value" #footer> + <MkFormFooter :form="proxyAccountForm"/> + </template> - <div class="_gaps"> - <MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo> + <div class="_gaps"> + <MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo> - <MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true"> - <template #label>{{ i18n.ts._profile.description }}</template> - <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> - </MkTextarea> - </div> - </MkFolder> - </div> + <SearchMarker :keywords="['description']"> + <MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true"> + <template #label><SearchLabel>{{ i18n.ts._profile.description }}</SearchLabel></template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </MkTextarea> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <MkButton primary @click="openSetupWizard"> + Open setup wizard + </MkButton> + </div> + </SearchMarker> </div> </PageWithHeader> </template> @@ -425,6 +506,20 @@ const proxyAccountForm = useForm({ fetchInstance(true); }); +async function openSetupWizard() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._serverSettings.restartServerSetupWizardConfirm_title, + text: i18n.ts._serverSettings.restartServerSetupWizardConfirm_text, + }); + if (canceled) return; + + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkServerSetupWizardDialog.vue').then(x => x.default), { + }, { + closed: () => dispose(), + }); +} + const headerTabs = computed(() => []); definePage(() => ({ diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index d5402f608c..0fd255d5f6 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -6,17 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 900px;"> - <div class="_gaps_m"> - <MkButton primary @click="onCreateWebhookClicked"> - <i class="ti ti-plus"></i> {{ i18n.ts._webhookSettings.createWebhook }} - </MkButton> + <SearchMarker path="/admin/system-webhook" label="SystemWebhook" :keywords="['webhook']" icon="ti ti-webhook"> + <div class="_gaps_m"> + <SearchMarker> + <MkButton primary @click="onCreateWebhookClicked"> + <i class="ti ti-plus"></i> <SearchLabel>{{ i18n.ts._webhookSettings.createWebhook }}</SearchLabel> + </MkButton> + </SearchMarker> - <FormSection> - <div class="_gaps"> - <XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/> - </div> - </FormSection> - </div> + <FormSection> + <div class="_gaps"> + <XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/> + </div> + </FormSection> + </div> + </SearchMarker> </div> </PageWithHeader> </template> diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index f9b870eda1..0bcfd28f67 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> </div> </div> - <MkError v-else-if="error" @retry="fetch()"/> + <MkError v-else-if="error" @retry="_fetch_()"/> <MkLoading v-else/> </Transition> </div> @@ -66,7 +66,7 @@ const announcement = ref<Misskey.entities.Announcement | null>(null); const error = ref<any>(null); const path = computed(() => props.announcementId); -function fetch() { +function _fetch_() { announcement.value = null; misskeyApi('announcements/show', { announcementId: props.announcementId, @@ -96,7 +96,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> { } } -watch(() => path.value, fetch, { immediate: true }); +watch(() => path.value, _fetch_, { immediate: true }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 7d2393dba5..9030fa0e29 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -37,21 +37,12 @@ const props = defineProps<{ const antenna = ref<Misskey.entities.Antenna | null>(null); const tlEl = useTemplateRef('tlEl'); -async function timetravel() { - const { canceled, result: date } = await os.inputDate({ - title: i18n.ts.date, - }); - if (canceled) return; - - tlEl.value.timetravel(date); -} - function settings() { - router.push(`/my/antennas/${props.antennaId}`); -} - -function focus() { - tlEl.value.focus(); + router.push('/my/antennas/:antennaId', { + params: { + antennaId: props.antennaId, + }, + }); } watch(() => props.antennaId, async () => { @@ -61,10 +52,6 @@ watch(() => props.antennaId, async () => { }, { immediate: true }); const headerActions = computed(() => antenna.value ? [{ - icon: 'ti ti-calendar-time', - text: i18n.ts.jumpToSpecifiedDate, - handler: timetravel, -}, { icon: 'ti ti-settings', text: i18n.ts.settings, handler: settings, diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index 7571696b84..f436fc72fa 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -68,6 +68,11 @@ function send() { function onEndpointChange() { misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => { + if (resp == null) { + body.value = '{}'; + return; + } + const endpointBody = {}; for (const p of resp.params) { endpointBody[p.name] = diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index 5b1fd1a386..1a0c9b36c4 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -44,11 +44,13 @@ const name = computed(() => { }); function cancel() { - misskeyApi('auth/deny', { - token: props.session.token, - }).then(() => { - emit('denied'); - }); + //misskeyApi('auth/deny', { + // token: props.session.token, + //}).then(() => { + // emit('denied'); + //}); + + emit('denied'); } function accept() { diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index ede0f268ee..7e13d0ab36 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -75,14 +75,15 @@ onMounted(async () => { if (!$i) return; try { - session.value = await misskeyApi('auth/session/show', { + const result = await misskeyApi('auth/session/show', { token: props.token, }); + session.value = result; // 既に連携していた場合 - if (session.value.app.isAuthorized) { + if (result.app.isAuthorized) { await misskeyApi('auth/accept', { - token: session.value.token, + token: result.token, }); accepted(); } else { diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 72281ea882..ce26a26109 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -92,7 +92,7 @@ const props = defineProps<{ }>(); const channel = ref<Misskey.entities.Channel | null>(null); -const name = ref<string | null>(null); +const name = ref<string>(''); const description = ref<string | null>(null); const bannerUrl = ref<string | null>(null); const bannerId = ref<string | null>(null); @@ -114,20 +114,22 @@ watch(() => bannerId.value, async () => { async function fetchChannel() { if (props.channelId == null) return; - channel.value = await misskeyApi('channels/show', { + const result = await misskeyApi('channels/show', { channelId: props.channelId, }); - name.value = channel.value.name; - description.value = channel.value.description; - bannerId.value = channel.value.bannerId; - bannerUrl.value = channel.value.bannerUrl; - isSensitive.value = channel.value.isSensitive; - pinnedNotes.value = channel.value.pinnedNoteIds.map(id => ({ + name.value = result.name; + description.value = result.description; + bannerId.value = result.bannerId; + bannerUrl.value = result.bannerUrl; + isSensitive.value = result.isSensitive; + pinnedNotes.value = result.pinnedNoteIds.map(id => ({ id, })); - color.value = channel.value.color; - allowRenoteToExternal.value = channel.value.allowRenoteToExternal; + color.value = result.color; + allowRenoteToExternal.value = result.allowRenoteToExternal; + + channel.value = result; } fetchChannel(); @@ -154,29 +156,36 @@ function save() { name: name.value, description: description.value, bannerId: bannerId.value, - pinnedNoteIds: pinnedNotes.value.map(x => x.id), color: color.value, isSensitive: isSensitive.value, allowRenoteToExternal: allowRenoteToExternal.value, - }; + } satisfies Misskey.entities.ChannelsCreateRequest; - if (props.channelId) { - params.channelId = props.channelId; - os.apiWithDialog('channels/update', params); + if (props.channelId != null) { + os.apiWithDialog('channels/update', { + ...params, + channelId: props.channelId, + pinnedNoteIds: pinnedNotes.value.map(x => x.id), + }); } else { os.apiWithDialog('channels/create', params).then(created => { - router.push(`/channels/${created.id}`); + router.push('/channels/:channelId', { + params: { + channelId: created.id, + }, + }); }); } } async function archive() { + if (props.channelId == null) return; + const { canceled } = await os.confirm({ type: 'warning', title: i18n.tsx.channelArchiveConfirmTitle({ name: name.value }), text: i18n.ts.channelArchiveConfirmDescription, }); - if (canceled) return; misskeyApi('channels/update', { diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 116aabaee2..9e1608f24d 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -112,7 +112,7 @@ const favorited = ref(false); const searchQuery = ref(''); const searchPaginator = shallowRef(); const searchKey = ref(''); -const featuredPaginator = markRaw(new Paginator('channels/featured', { +const featuredPaginator = markRaw(new Paginator('notes/featured', { limit: 10, computedParams: computed(() => ({ channelId: props.channelId, @@ -131,6 +131,8 @@ watch(() => props.channelId, async () => { channel.value = await misskeyApi('channels/show', { channelId: props.channelId, }); + if (channel.value == null) return; // TSを黙らすため + favorited.value = channel.value.isFavorited ?? false; if (favorited.value || channel.value.isFollowing) { tab.value = 'timeline'; @@ -147,7 +149,11 @@ watch(() => props.channelId, async () => { }, { immediate: true }); function edit() { - router.push(`/channels/${channel.value?.id}/edit`); + router.push('/channels/:channelId/edit', { + params: { + channelId: props.channelId, + }, + }); } function openPostForm() { diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 324e0c573a..1e7301d06d 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -110,6 +110,11 @@ async function search() { const type = searchType.value.toString().trim(); + if (type !== 'nameAndDescription' && type !== 'nameOnly') { + console.error(`Unrecognized search type: ${type}`); + return; + } + channelPaginator.value = markRaw(new Paginator('channels/search', { limit: 10, params: { diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index c5e8d0fdb6..613c4e4dcc 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.isMe]: isMe }]"> - <MkAvatar :class="$style.avatar" :user="message.fromUser!" :link="!isMe" :preview="false"/> + <MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="message.fromUser!" :link="!isMe" :preview="false"/> <div :class="[$style.body, message.file != null ? $style.fullWidth : null]" @contextmenu.stop="onContextmenu"> <div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName'] && message.fromUser != null" :user="message.fromUser"/></div> <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :fullWidth="message.file != null" :accented="isMe"> @@ -231,11 +231,14 @@ function showMenu(ev: MouseEvent, contextmenu = false) { } .avatar { - position: sticky; - top: calc(16px + var(--MI-stickyTop, 0px)); display: block; width: 50px; height: 50px; + + &.useSticky { + position: sticky; + top: calc(16px + var(--MI-stickyTop, 0px)); + } } @container (max-width: 450px) { diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue index a0853fb0c9..756bf8a342 100644 --- a/packages/frontend/src/pages/chat/home.home.vue +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -86,7 +86,11 @@ function start(ev: MouseEvent) { async function startUser() { // TODO: localOnly は連合に対応したら消す os.selectUser({ localOnly: true }).then(user => { - router.push(`/chat/user/${user.id}`); + router.push('/chat/user/:userId', { + params: { + userId: user.id, + } + }); }); } @@ -101,7 +105,11 @@ async function createRoom() { name: result, }); - router.push(`/chat/room/${room.id}`); + router.push('/chat/room/:roomId', { + params: { + roomId: room.id, + } + }); } async function search() { diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue index 3cbe186e9d..19d57ea205 100644 --- a/packages/frontend/src/pages/chat/home.invitations.vue +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -61,7 +61,11 @@ async function join(invitation: Misskey.entities.ChatRoomInvitation) { roomId: invitation.room.id, }); - router.push(`/chat/room/${invitation.room.id}`); + router.push('/chat/room/:roomId', { + params: { + roomId: invitation.room.id, + }, + }); } async function ignore(invitation: Misskey.entities.ChatRoomInvitation) { diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 1cb07017e9..0f306896c9 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -312,6 +312,7 @@ const headerActions = computed(() => [{ handler: add, }, { icon: 'ti ti-dots', + text: i18n.ts.more, handler: menu, }]); diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 1def215afc..e3cc1d988e 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -105,7 +105,7 @@ const folderHierarchy = computed(() => { }); const isImage = computed(() => file.value?.type.startsWith('image/')); -async function fetch() { +async function _fetch_() { fetching.value = true; file.value = await misskeyApi('drive/files/show', { @@ -119,7 +119,7 @@ async function fetch() { } function postThis() { - if (!file.value) return; + if (file.value == null) return; os.post({ initialFiles: [file.value], @@ -127,26 +127,28 @@ function postThis() { } function move() { - if (!file.value) return; + if (file.value == null) return; + + const f = file.value; selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { - fileId: file.value.id, + fileId: f.id, folderId: folder[0] ? folder[0].id : null, }).then(async () => { - await fetch(); + await _fetch_(); }); }); } function toggleSensitive() { - if (!file.value) return; + if (file.value == null) return; os.apiWithDialog('drive/files/update', { fileId: file.value.id, isSensitive: !file.value.isSensitive, }).then(async () => { - await fetch(); + await _fetch_(); }).catch(err => { os.alert({ type: 'error', @@ -157,7 +159,9 @@ function toggleSensitive() { } function rename() { - if (!file.value) return; + if (file.value == null) return; + + const f = file.value; os.inputText({ title: i18n.ts.renameFile, @@ -166,16 +170,18 @@ function rename() { }).then(({ canceled, result: name }) => { if (canceled) return; os.apiWithDialog('drive/files/update', { - fileId: file.value.id, + fileId: f.id, name: name, }).then(async () => { - await fetch(); + await _fetch_(); }); }); } async function describe() { - if (!file.value) return; + if (file.value == null) return; + + const f = file.value; const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), { default: file.value.comment ?? '', @@ -183,10 +189,10 @@ async function describe() { }, { done: caption => { os.apiWithDialog('drive/files/update', { - fileId: file.value.id, + fileId: f.id, comment: caption.length === 0 ? null : caption, }).then(async () => { - await fetch(); + await _fetch_(); }); }, closed: () => dispose(), @@ -194,7 +200,7 @@ async function describe() { } async function deleteFile() { - if (!file.value) return; + if (file.value == null) return; const { canceled } = await os.confirm({ type: 'warning', @@ -212,7 +218,7 @@ async function deleteFile() { } onMounted(async () => { - await fetch(); + await _fetch_(); }); </script> diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index b4fc4a46d9..201ce003f0 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -156,12 +156,9 @@ async function done() { isSensitive: isSensitive.value, localOnly: localOnly.value, roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id), + fileId: file.value ? file.value.id : undefined, }; - if (file.value) { - params.fileId = file.value.id; - } - if (props.emoji) { await os.apiWithDialog('admin/emoji/update', { id: props.emoji.id, @@ -177,7 +174,12 @@ async function done() { windowEl.value?.close(); } else { - const created = await os.apiWithDialog('admin/emoji/add', params); + if (params.fileId == null) return; + + const created = await os.apiWithDialog('admin/emoji/add', { + ...params, + fileId: params.fileId, // TSを黙らすため + }); emit('done', { created: created, diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 72f2a6813c..08f9f5e582 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> <div v-else> - <MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin"> + <MkFoldableSection :foldable="true" :expanded="false" class="_margin"> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> <div> @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> - <MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin"> + <MkFoldableSection v-if="tagUsersPaginator != null" :key="`${tag}`" class="_margin"> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> <MkUserList :paginator="tagUsersPaginator"/> </MkFoldableSection> @@ -78,22 +78,17 @@ const props = defineProps<{ }>(); const origin = ref('local'); -const tagsEl = useTemplateRef('tagsEl'); const tagsLocal = ref<Misskey.entities.Hashtag[]>([]); const tagsRemote = ref<Misskey.entities.Hashtag[]>([]); -watch(() => props.tag, () => { - if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null); -}); - -const tagUsersPaginator = markRaw(new Paginator('hashtags/users', { +const tagUsersPaginator = props.tag != null ? markRaw(new Paginator('hashtags/users', { limit: 30, params: { tag: props.tag, origin: 'combined', sort: '+follower', }, -})); +})) : null; const pinnedUsersPaginator = markRaw(new Paginator('pinned-users', { noPaging: true, diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index c4f6ddc33e..6416816e6c 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -26,18 +26,12 @@ import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - tag?: string; initialTab?: string; }>(), { initialTab: 'featured', }); const tab = ref(props.initialTab); -const tagsEl = useTemplateRef('tagsEl'); - -watch(() => props.tag, () => { - if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null); -}); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 4386209f7c..81b9d1cead 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -127,7 +127,7 @@ var results = [] // どれだけ巻き戻しているか var cursor = 0 -@do() { +@main() { if (cursor != 0) { results = results.slice(0, (cursor + 1)) cursor = 0 @@ -175,7 +175,7 @@ var cursor = 0 onClick: forward }, { text: "引き直す" - onClick: do + onClick: main }] }) Ui:C:postFormButton({ @@ -191,7 +191,7 @@ var cursor = 0 ]) } -do() +main() `; const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION} @@ -383,7 +383,7 @@ if (props.id) { const title = ref(flash.value?.title ?? 'New Play'); const summary = ref(flash.value?.summary ?? ''); -const permissions = ref(flash.value?.permissions ?? []); +const permissions = ref([]); // not implemented yet const visibility = ref<'private' | 'public'>(flash.value?.visibility ?? 'public'); const script = ref(flash.value?.script ?? PRESET_DEFAULT); @@ -412,9 +412,9 @@ function selectPreset(ev: MouseEvent) { } async function save() { - if (flash.value) { + if (flash.value != null) { os.apiWithDialog('flash/update', { - flashId: props.id, + flashId: flash.value.id, title: title.value, summary: summary.value, permissions: permissions.value, @@ -429,7 +429,11 @@ async function save() { script: script.value, visibility: visibility.value, }); - router.push('/play/' + created.id + '/edit'); + router.push('/play/:id/edit', { + params: { + id: created.id, + }, + }); } } @@ -444,6 +448,8 @@ function show() { } async function del() { + if (flash.value == null) return; + const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.deleteAreYouSure({ x: flash.value.title }), @@ -451,7 +457,7 @@ async function del() { if (canceled) return; await os.apiWithDialog('flash/delete', { - flashId: props.id, + flashId: flash.value.id, }); router.push('/play'); } @@ -464,6 +470,7 @@ definePage(() => ({ title: flash.value ? `${i18n.ts._play.edit}: ${flash.value.title}` : i18n.ts._play.new, })); </script> + <style lang="scss" module> .footer { backdrop-filter: var(--MI-blur, blur(15px)); diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 1c9cb92bc2..f318a9f817 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -63,11 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, onDeactivated, onUnmounted, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { Interpreter, Parser, values } from '@syuilo/aiscript'; import { url } from '@@/js/config.js'; import type { Ref } from 'vue'; import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; import type { MenuItem } from '@/types/menu.js'; +import type { Interpreter } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -180,8 +180,6 @@ async function unlike() { watch(() => props.id, fetchFlash, { immediate: true }); -const parser = new Parser(); - const started = ref(false); const aiscript = shallowRef<Interpreter | null>(null); const root = ref<AsUiRoot>(); @@ -196,9 +194,15 @@ async function run() { if (aiscript.value) aiscript.value.abort(); if (!flash.value) return; + const isLegacy = !flash.value.script.replaceAll(' ', '').startsWith('///@1.0.0'); + + const { Interpreter, Parser, values } = isLegacy ? (await import('@syuilo/aiscript-0-19-0') as any) : await import('@syuilo/aiscript'); + + const parser = new Parser(); + components.value = []; - aiscript.value = new Interpreter({ + const interpreter = new Interpreter({ ...createAiScriptEnv({ storageKey: 'flash:' + flash.value.id, }), @@ -217,6 +221,8 @@ async function run() { }, }); + aiscript.value = interpreter; + let ast; try { ast = parser.parse(flash.value.script); @@ -228,8 +234,8 @@ async function run() { return; } try { - await aiscript.value.exec(ast); - } catch (err) { + await interpreter.exec(ast); + } catch (err: any) { os.alert({ type: 'error', title: 'AiScript Error', @@ -366,6 +372,7 @@ definePage(() => ({ > .items { display: flex; + flex-wrap: wrap; justify-content: center; gap: 12px; padding: 16px; diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index 9c0078e15a..3fd462e0b9 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkTextarea> <div class="_gaps_s"> - <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : '' }"> <div class="name">{{ file.name }}</div> <button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button> </div> @@ -85,7 +85,11 @@ async function save() { fileIds: files.value.map(file => file.id), isSensitive: isSensitive.value, }); - router.push(`/gallery/${props.postId}`); + router.push('/gallery/:postId', { + params: { + postId: props.postId, + }, + }); } else { const created = await os.apiWithDialog('gallery/posts/create', { title: title.value, @@ -93,7 +97,11 @@ async function save() { fileIds: files.value.map(file => file.id), isSensitive: isSensitive.value, }); - router.push(`/gallery/${created.id}`); + router.push('/gallery/:postId', { + params: { + postId: created.id, + }, + }); } } diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index d02b72dd99..eab435c002 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -150,7 +150,11 @@ async function unlike() { } function edit() { - router.push(`/gallery/${post.value.id}/edit`); + router.push('/gallery/:postId/edit', { + params: { + postId: props.postId, + }, + }); } async function reportAbuse() { diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue index 1b3c6616cc..cad3b2a00a 100644 --- a/packages/frontend/src/pages/install-extensions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -80,7 +80,7 @@ function close_(): void { } } -async function fetch() { +async function _fetch_() { if (!url.value || !hash.value) { errorKV.value = { title: i18n.ts._externalResourceInstaller._errors._invalidParams.title, @@ -161,7 +161,7 @@ async function fetch() { }, raw: res.data, }; - } catch (err) { + } catch (err: any) { switch (err.message.toLowerCase()) { case 'this theme is already installed': errorKV.value = { @@ -229,7 +229,7 @@ async function install() { const urlParams = new URLSearchParams(window.location.search); url.value = urlParams.get('url'); hash.value = urlParams.get('hash'); -fetch(); +_fetch_(); definePage(() => ({ title: i18n.ts._externalResourceInstaller.title, diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 4be5fa447d..473207fe6e 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -198,7 +198,7 @@ if (iAmModerator) { }); } -async function fetch(): Promise<void> { +async function _fetch_(): Promise<void> { if (iAmAdmin) { meta.value = await misskeyApi('admin/meta'); } @@ -276,7 +276,7 @@ function refreshMetadata(): void { }); } -fetch(); +_fetch_(); const headerActions = computed(() => [{ text: `https://${props.host}`, diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index 1261428c1c..a52b562c7f 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -103,6 +103,7 @@ definePage(() => ({ icon: 'ti ti-list', })); </script> + <style lang="scss" module> .userItem { display: flex; diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue index c969473b19..182b2f703d 100644 --- a/packages/frontend/src/pages/lookup.vue +++ b/packages/frontend/src/pages/lookup.vue @@ -29,7 +29,7 @@ import MkButton from '@/components/MkButton.vue'; const state = ref<'fetching' | 'done'>('fetching'); -function fetch() { +function _fetch_() { const params = new URL(window.location.href).searchParams; // acctのほうはdeprecated @@ -44,12 +44,19 @@ function fetch() { if (uri.startsWith('https://')) { promise = misskeyApi('ap/show', { uri, - }); - promise.then(res => { + }).then(res => { if (res.type === 'User') { - mainRouter.replace(res.object.host ? `/@${res.object.username}@${res.object.host}` : `/@${res.object.username}`); + mainRouter.replace('/@:acct/:page?', { + params: { + acct: res.object.host != null ? `${res.object.username}@${res.object.host}` : res.object.username, + }, + }); } else if (res.type === 'Note') { - mainRouter.replace(`/notes/${res.object.id}`); + mainRouter.replace('/notes/:noteId/:initialTab?', { + params: { + noteId: res.object.id, + }, + }); } else { os.alert({ type: 'error', @@ -61,9 +68,12 @@ function fetch() { if (uri.startsWith('acct:')) { uri = uri.slice(5); } - promise = misskeyApi('users/show', Misskey.acct.parse(uri)); - promise.then(user => { - mainRouter.replace(user.host ? `/@${user.username}@${user.host}` : `/@${user.username}`); + promise = misskeyApi('users/show', Misskey.acct.parse(uri)).then(user => { + mainRouter.replace('/@:acct/:page?', { + params: { + acct: user.host != null ? `${user.username}@${user.host}` : user.username, + }, + }); }); } @@ -83,7 +93,7 @@ function goToMisskey(): void { window.location.href = '/'; } -fetch(); +_fetch_(); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 95a3108e3a..d7625a8a1c 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -30,11 +30,11 @@ import { antennasCache } from '@/cache.js'; const antennas = computed(() => antennasCache.value.value ?? []); -function fetch() { +function _fetch_() { antennasCache.fetch(); } -fetch(); +_fetch_(); const headerActions = computed(() => [{ asFullButton: true, @@ -42,7 +42,7 @@ const headerActions = computed(() => [{ text: i18n.ts.reload, handler: () => { antennasCache.delete(); - fetch(); + _fetch_(); }, }]); diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index fb31cd542c..43d5432f66 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="items.length > 0" class="_gaps"> <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> - <div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }})</span></div> - <MkAvatars :userIds="list.userIds" :limit="10"/> + <div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.tsx.nUsers({ n: `${list.userIds!.length}/${$i.policies['userEachUserListsLimit']}` }) }})</span></div> + <MkAvatars :userIds="list.userIds!" :limit="10"/> </MkA> </div> </div> @@ -40,20 +40,20 @@ const $i = ensureSignin(); const items = computed(() => userListsCache.value.value ?? []); -function fetch() { +function _fetch_() { userListsCache.fetch(); } -fetch(); +_fetch_(); async function create() { const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); - if (canceled) return; + if (canceled || name == null) return; await os.apiWithDialog('users/lists/create', { name: name }); userListsCache.delete(); - fetch(); + _fetch_(); } const headerActions = computed(() => [{ @@ -62,7 +62,7 @@ const headerActions = computed(() => [{ text: i18n.ts.reload, handler: () => { userListsCache.delete(); - fetch(); + _fetch_(); }, }]); @@ -74,7 +74,7 @@ definePage(() => ({ })); onActivated(() => { - fetch(); + _fetch_(); }); </script> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 6b5a797023..eb8e26be3b 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder defaultOpen> <template #label>{{ i18n.ts.members }}</template> - <template #caption>{{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }}</template> + <template #caption>{{ i18n.tsx.nUsers({ n: `${list.userIds!.length}/${$i.policies['userEachUserListsLimit']}` }) }}</template> <div class="_gaps_s"> <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 9de1ef099b..abd2a5d8a1 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="note"> <div v-if="showNext" class="_margin"> - <MkNotesTimeline :withControl="false" :pullToRefresh="false" class="" :paginator="showNext === 'channel' ? nextChannelPaginator : nextUserPaginator" :noGap="true"/> + <MkNotesTimeline direction="up" :withControl="false" :pullToRefresh="false" class="" :paginator="showNext === 'channel' ? nextChannelPaginator : nextUserPaginator" :noGap="true"/> </div> <div class="_margin"> @@ -81,7 +81,6 @@ const error = ref(); const prevUserPaginator = markRaw(new Paginator('users/notes', { limit: 10, initialId: props.noteId, - initialDirection: 'older', computedParams: computed(() => note.value ? ({ userId: note.value.userId, }) : undefined), @@ -99,7 +98,6 @@ const nextUserPaginator = markRaw(new Paginator('users/notes', { const prevChannelPaginator = markRaw(new Paginator('channels/timeline', { limit: 10, initialId: props.noteId, - initialDirection: 'older', computedParams: computed(() => note.value && note.value.channelId != null ? ({ channelId: note.value.channelId, }) : undefined), @@ -128,7 +126,7 @@ function fetchNote() { noteId: props.noteId, }).then(res => { note.value = res; - const appearNote = getAppearNote(res); + const appearNote = getAppearNote(res) ?? res; // 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) { misskeyApi('notes/clips', { diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index a8c1fb654c..71c957460c 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -31,7 +31,7 @@ import { Paginator } from '@/utility/paginator.js'; const tab = ref('all'); const includeTypes = ref<string[] | null>(null); -const excludeTypes = computed(() => includeTypes.value ? notificationTypes.filter(t => !includeTypes.value.includes(t)) : null); +const excludeTypes = computed(() => includeTypes.value ? notificationTypes.filter(t => !includeTypes.value!.includes(t)) : null); const mentionsPaginator = markRaw(new Paginator('notes/mentions', { limit: 10, @@ -71,7 +71,7 @@ const headerActions = computed(() => [tab.value === 'all' ? { text: i18n.ts.markAllAsRead, icon: 'ti ti-check', handler: () => { - os.apiWithDialog('notifications/mark-all-as-read'); + os.apiWithDialog('notifications/mark-all-as-read', {}); }, } : undefined].filter(x => x !== undefined)); diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 8a9b9a9b08..9fe03ae981 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -154,7 +154,11 @@ async function save() { pageId.value = created.id; currentName.value = name.value.trim(); - mainRouter.replace(`/pages/edit/${pageId.value}`); + mainRouter.replace('/pages/edit/:initPageId', { + params: { + initPageId: pageId.value, + }, + }); } } @@ -189,7 +193,11 @@ async function duplicate() { pageId.value = created.id; currentName.value = name.value.trim(); - mainRouter.push(`/pages/edit/${pageId.value}`); + mainRouter.push('/pages/edit/:initPageId', { + params: { + initPageId: pageId.value, + }, + }); } async function add() { diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index cd63e51fd5..5cb13a9c3f 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -267,7 +267,11 @@ function showMenu(ev: MouseEvent) { menuItems.push({ icon: 'ti ti-pencil', text: i18n.ts.edit, - action: () => router.push(`/pages/edit/${page.value.id}`), + action: () => router.push('/pages/edit/:initPageId', { + params: { + initPageId: page.value!.id, + }, + }), }); if ($i.pinnedPageId === page.value.id) { diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index 5c5bbfba39..827866ae2a 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -62,10 +62,10 @@ const props = defineProps<{ }>(); const scope = computed(() => props.path.split('/').slice(0, -1)); -const key = computed(() => props.path.split('/').at(-1)); +const key = computed(() => props.path.split('/').at(-1)!); const value = ref<any>(null); -const valueForEditor = ref<string | null>(null); +const valueForEditor = ref<string>(''); function fetchValue() { misskeyApi('i/registry/get-detail', { diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue index 3762dadd12..389438242e 100644 --- a/packages/frontend/src/pages/registry.vue +++ b/packages/frontend/src/pages/registry.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <div v-if="scopesWithDomain" class="_gaps_m"> - <FormSection v-for="domain in scopesWithDomain" :key="domain.domain"> + <FormSection v-for="domain in scopesWithDomain" :key="domain.domain ?? 'system'"> <template #label>{{ domain.domain ? domain.domain.toUpperCase() : i18n.ts.system }}</template> <div class="_gaps_s"> <FormLink v-for="scope in domain.scopes" :to="`/registry/keys/${domain.domain ?? '@'}/${scope.join('/')}`" class="_monospace">{{ scope.length === 0 ? '(root)' : scope.join('/') }}</FormLink> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 9e2069f0e5..53077c34b0 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -34,6 +34,7 @@ const props = defineProps<{ const password = ref(''); async function save() { + if (props.token == null) return; await os.apiWithDialog('reset-password', { token: props.token, password: password.value, diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index e4d921b8d2..0ae374649d 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -168,7 +168,11 @@ function startGame(game: Misskey.entities.ReversiGameDetailed) { playbackRate: 1, }); - router.push(`/reversi/g/${game.id}`); + router.push('/reversi/g/:gameId', { + params: { + gameId: game.id, + }, + }); } async function matchHeatbeat() { diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 751a67190a..d73363d058 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -82,10 +82,10 @@ const logs = ref<{ text: string; print: boolean; }[]>([]); -const root = ref<AsUiRoot>(); +const root = ref<AsUiRoot | undefined>(); const components = ref<Ref<AsUiComponent>[]>([]); const uiKey = ref(0); -const uiInspectorOpenedComponents = ref(new Map<string, boolean>); +const uiInspectorOpenedComponents = ref(new WeakMap<AsUiComponent | Ref<AsUiComponent>, boolean>); const saved = miLocalStorage.getItem('scratchpad'); if (saved) { @@ -186,11 +186,13 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); const showns = computed(() => { + if (root.value == null) return new Set<string>(); const result = new Set<string>(); (function addChildrenToResult(c: AsUiComponent) { result.add(c.id); - if (c.children) { - const childComponents = components.value.filter(v => c.children.includes(v.value.id)); + const children = c.children; + if (children) { + const childComponents = components.value.filter(v => children.includes(v.value.id)); for (const child of childComponents) { addChildrenToResult(child.value); } diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index f19c1e7efb..fb34d592a6 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -264,10 +264,18 @@ async function search() { const res = await apLookup(searchParams.value.query); if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); + router.push('/@:acct/:page?', { + params: { + acct: `${res.object.username}@${res.object.host}`, + }, + }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); + router.push('/notes/:noteId/:initialTab?', { + params: { + noteId: res.object.id, + }, + }); } return; @@ -282,7 +290,7 @@ async function search() { text: i18n.ts.lookupConfirm, }); if (!confirm.canceled) { - router.push(`/${searchParams.value.query}`); + router.pushByPath(`/${searchParams.value.query}`); return; } } @@ -293,7 +301,11 @@ async function search() { text: i18n.ts.openTagPageConfirm, }); if (!confirm.canceled) { - router.push(`/tags/${encodeURIComponent(searchParams.value.query.substring(1))}`); + router.push('/tags/:tag', { + params: { + tag: searchParams.value.query.substring(1), + }, + }); return; } } diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index bd67d41a80..5110fca10c 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -77,10 +77,18 @@ async function search() { const res = await promise; if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); + router.push('/@:acct/:page?', { + params: { + acct: `${res.object.username}@${res.object.host}`, + }, + }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); + router.push('/notes/:noteId/:initialTab?', { + params: { + noteId: res.object.id, + }, + }); } return; @@ -95,7 +103,7 @@ async function search() { text: i18n.ts.lookupConfirm, }); if (!confirm.canceled) { - router.push(`/${query}`); + router.pushByPath(`/${query}`); return; } } @@ -106,7 +114,11 @@ async function search() { text: i18n.ts.openTagPageConfirm, }); if (!confirm.canceled) { - router.push(`/user-tags/${encodeURIComponent(query.substring(1))}`); + router.push('/user-tags/:tag', { + params: { + tag: query.substring(1), + }, + }); return; } } diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index b6d21a4616..8d2bf9eb42 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -15,16 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'user'" class="_spacer" style="--MI_SPACER-w: 800px;"> - <XUser v-bind="props"/> + <div v-if="usersSearchAvailable"> + <XUser v-bind="props"/> + </div> + <div v-else> + <MkInfo warn>{{ i18n.ts.usersSearchNotAvailable }}</MkInfo> + </div> </div> </PageWithHeader> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, toRef } from 'vue'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { notesSearchAvailable } from '@/utility/check-permissions.js'; +import { notesSearchAvailable, usersSearchAvailable } from '@/utility/check-permissions.js'; import MkInfo from '@/components/MkInfo.vue'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 2f639cd090..ca404b43c4 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -20,12 +20,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-shield-lock"></i></template> <template #label><SearchLabel>{{ i18n.ts.totp }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.totpDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.totpDescription }}</SearchText></template> <template #suffix><i v-if="$i.twoFactorEnabled" class="ti ti-check" style="color: var(--MI_THEME-success)"></i></template> <div v-if="$i.twoFactorEnabled" class="_gaps_s"> <div v-text="i18n.ts._2fa.alreadyRegistered"/> - <template v-if="$i.securityKeysList.length > 0"> + <template v-if="$i.securityKeysList!.length > 0"> <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> </template> @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else> <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> - <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> + <MkFolder v-for="key in $i.securityKeysList!" :key="key.id"> <template #label>{{ key.name }}</template> <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> <div class="_buttons"> @@ -72,9 +72,9 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['password', 'less', 'key', 'passkey', 'login', 'signin']"> - <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)"> + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList!.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)"> <template #label><SearchLabel>{{ i18n.ts.passwordLessLogin }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.passwordLessLoginDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.passwordLessLoginDescription }}</SearchText></template> </MkSwitch> </SearchMarker> </div> diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue index 5a00d7a9d7..c75667b06b 100644 --- a/packages/frontend/src/pages/settings/account-data.vue +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/account-data" :label="i18n.ts._settings.accountData" :keywords="['import', 'export', 'data', 'archive']" icon="ti ti-package"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/package_3d.png" color="#ff9100"> - <SearchKeyword>{{ i18n.ts._settings.accountDataBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.accountDataBanner }}</SearchText> </MkFeatureBanner> <div class="_gaps_s"> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 2fd0a021da..764ec72652 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -11,7 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only <!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>--> </div> - <MkUserCardMini v-for="x in accounts" :key="x[0] + x[1].id" :user="x[1]" :class="$style.user" @click.prevent="menu(x[0], x[1], $event)"/> + <template v-for="x in accounts" :key="x.host + x.id"> + <MkUserCardMini v-if="x.user" :user="x.user" :class="$style.user" @click.prevent="showMenu(x.host, x.id, $event)"/> + </template> </div> </SearchMarker> </template> @@ -24,29 +26,29 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { $i } from '@/i.js'; -import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; +import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccounts } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { prefer } from '@/preferences.js'; -const accounts = prefer.r.accounts; +const accounts = await getAccounts(); function refreshAllAccounts() { // TODO } -function menu(host: string, account: Misskey.entities.UserDetailed, ev: MouseEvent) { +function showMenu(host: string, id: string, ev: MouseEvent) { let menu: MenuItem[]; menu = [{ text: i18n.ts.switch, icon: 'ti ti-switch-horizontal', - action: () => switchAccount(host, account.id), + action: () => switchAccount(host, id), }, { text: i18n.ts.remove, icon: 'ti ti-trash', - action: () => removeAccount(host, account.id), + action: () => removeAccount(host, id), }]; os.popupMenu(menu, ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 5f51a5e079..54e214241b 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #label>{{ token.name }}</template> <template #caption>{{ token.description }}</template> - <template #suffix><MkTime :time="token.lastUsedAt"/></template> + <template v-if="token.lastUsedAt != null" #suffix><MkTime :time="token.lastUsedAt"/></template> <template #footer> <MkButton danger @click="revoke(token)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </template> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>{{ i18n.ts.installedDate }}</template> <template #value><MkTime :time="token.createdAt" :mode="'detail'"/></template> </MkKeyValue> - <MkKeyValue oneline> + <MkKeyValue v-if="token.lastUsedAt != null" oneline> <template #key>{{ i18n.ts.lastUsedDate }}</template> <template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template> </MkKeyValue> diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue index c58cd57c65..d3d642f156 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.vue @@ -17,13 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.decorations"> <XDecoration v-for="(avatarDecoration, i) in $i.avatarDecorations" - :decoration="avatarDecorations.find(d => d.id === avatarDecoration.id)" + :decoration="avatarDecorations.find(d => d.id === avatarDecoration.id) ?? { id: '', url: '', name: '?', roleIdsThatCanBeUsedThisDecoration: [] }" :angle="avatarDecoration.angle" :flipH="avatarDecoration.flipH" :offsetX="avatarDecoration.offsetX" :offsetY="avatarDecoration.offsetY" :active="true" - @click="openDecoration(avatarDecoration, i)" + @click="openAttachedDecoration(i)" /> </div> @@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, defineAsyncComponent, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XDecoration from './avatar-decoration.decoration.vue'; +import XDialog from './avatar-decoration.dialog.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -68,14 +69,24 @@ misskeyApi('get-avatar-decorations').then(_avatarDecorations => { loading.value = false; }); -async function openDecoration(avatarDecoration, index?: number) { - const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration.dialog.vue').then(x => x.default), { +function openAttachedDecoration(index: number) { + openDecoration(avatarDecorations.value.find(d => d.id === $i.avatarDecorations[index].id) ?? { id: '', url: '', name: '?', roleIdsThatCanBeUsedThisDecoration: [] }, index); +} + +async function openDecoration(avatarDecoration: { + id: string; + url: string; + name: string; + roleIdsThatCanBeUsedThisDecoration: string[]; +}, index?: number) { + const { dispose } = os.popup(XDialog, { decoration: avatarDecoration, - usingIndex: index, + usingIndex: index ?? null, }, { 'attach': async (payload) => { const decoration = { id: avatarDecoration.id, + url: avatarDecoration.url, angle: payload.angle, flipH: payload.flipH, offsetX: payload.offsetX, @@ -90,13 +101,14 @@ async function openDecoration(avatarDecoration, index?: number) { 'update': async (payload) => { const decoration = { id: avatarDecoration.id, + url: avatarDecoration.url, angle: payload.angle, flipH: payload.flipH, offsetX: payload.offsetX, offsetY: payload.offsetY, }; const update = [...$i.avatarDecorations]; - update[index] = decoration; + update[index!] = decoration; await os.apiWithDialog('i/update', { avatarDecorations: update, }); @@ -104,7 +116,7 @@ async function openDecoration(avatarDecoration, index?: number) { }, 'detach': async () => { const update = [...$i.avatarDecorations]; - update.splice(index, 1); + update.splice(index!, 1); await os.apiWithDialog('i/update', { avatarDecorations: update, }); diff --git a/packages/frontend/src/pages/settings/connect.vue b/packages/frontend/src/pages/settings/connect.vue index 1e701096c5..28579b915f 100644 --- a/packages/frontend/src/pages/settings/connect.vue +++ b/packages/frontend/src/pages/settings/connect.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/connect" :label="i18n.ts._settings.serviceConnection" :keywords="['app', 'service', 'connect', 'webhook', 'api', 'token']" icon="ti ti-link"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/link_3d.png" color="#ff0088"> - <SearchKeyword>{{ i18n.ts._settings.serviceConnectionBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.serviceConnectionBanner }}</SearchText> </MkFeatureBanner> <SearchMarker :keywords="['api', 'app', 'token', 'accessToken']"> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index 9b0e04860e..83a188b2cb 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -7,6 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo> + <FormInfo v-if="isSafeMode" warn>{{ i18n.ts.customCssIsDisabledBecauseSafeMode }}</FormInfo> + <MkCodeEditor v-model="localCustomCss" manualSave lang="css"> <template #label>CSS</template> </MkCodeEditor> @@ -17,6 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import FormInfo from '@/components/MkInfo.vue'; +import { isSafeMode } from '@@/js/config.js'; import * as os from '@/os.js'; import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index ae882d1ee2..7a19b0495b 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -97,8 +97,8 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; -import { reloadAsk } from '@/utility/reload-ask.js'; import { selectFile } from '@/utility/drive.js'; +import { suggestReload } from '@/utility/reload-suggest.js'; const navWindow = prefer.model('deck.navWindow'); const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); @@ -109,8 +109,8 @@ const menuPosition = prefer.model('deck.menuPosition'); const navbarPosition = prefer.model('deck.navbarPosition'); const wallpaper = prefer.model('deck.wallpaper'); -watch(wallpaper, async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +watch(wallpaper, () => { + suggestReload(); }); function setWallpaper(ev: MouseEvent) { diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue index b466f35fc5..bb91d5e212 100644 --- a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -43,7 +43,7 @@ async function edit() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), { preset: deepClone(props.preset), }, { - ok: (preset: WatermarkPreset) => { + ok: (preset) => { emit('updatePreset', preset); }, closed: () => dispose(), diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 1b99f6dea5..cfa4df18fc 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/drive" :label="i18n.ts.drive" :keywords="['drive']" icon="ti ti-cloud"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/cloud_3d.png" color="#0059ff"> - <SearchKeyword>{{ i18n.ts._settings.driveBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.driveBanner }}</SearchText> </MkFeatureBanner> <SearchMarker :keywords="['capacity', 'usage']"> @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="keepOriginalFilename"> <MkSwitch v-model="keepOriginalFilename"> <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']"> <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> <template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> - <template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchText></template> </MkSwitch> </SearchMarker> </div> diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index fb8f51041e..469a3c2f1c 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -74,7 +74,7 @@ import { instance } from '@/instance.js'; const $i = ensureSignin(); -const emailAddress = ref($i.email); +const emailAddress = ref($i.email ?? ''); const onChangeReceiveAnnouncementEmail = (v) => { misskeyApi('i/update', { diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 61e3ca8b6c..250c1735be 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div> <div><button class="_textButton" @click="enableAutoBackup">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipAutoBackup">{{ i18n.ts.skip }}</button></div> </MkInfo> - <MkSuperMenu :def="menuDef" :grid="narrow" :searchIndex="SETTING_INDEX"></MkSuperMenu> + <MkSuperMenu :def="menuDef" :grid="narrow" :searchIndex="searchIndex"></MkSuperMenu> </div> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> @@ -42,12 +42,12 @@ import { instance } from '@/instance.js'; import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import * as os from '@/os.js'; import { useRouter } from '@/router.js'; -import { searchIndexes } from '@/utility/settings-search-index.js'; import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js'; import { store } from '@/store.js'; import { signout } from '@/signout.js'; +import { genSearchIndexes } from '@/utility/inapp-search.js'; -const SETTING_INDEX = searchIndexes; // TODO: lazy load +const searchIndex = await import('search-index:settings').then(({ searchIndexes }) => genSearchIndexes(searchIndexes)); const indexInfo = { title: i18n.ts.settings, @@ -188,6 +188,8 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ }]); onMounted(() => { + if (el.value == null) return; // TSを黙らすため + ro.observe(el.value); narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; @@ -198,6 +200,8 @@ onMounted(() => { }); onActivated(() => { + if (el.value == null) return; // TSを黙らすため + narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; if (!narrow.value && currentPage.value?.route.name == null) { @@ -215,7 +219,7 @@ watch(router.currentRef, (to) => { } }); -const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); +const emailNotConfigured = computed(() => $i && instance.enableEmail && ($i.email == null || !$i.emailVerified)); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 6ca313da81..6fd9f07a47 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/mute-block" :label="i18n.ts.muteAndBlock" icon="ti ti-ban" :keywords="['mute', 'block']"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/prohibited_3d.png" color="#ff2600"> - <SearchKeyword>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchText> </MkFeatureBanner> <div class="_gaps_s"> @@ -159,8 +159,6 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> </div> @@ -189,10 +187,10 @@ import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { reloadAsk } from '@/utility/reload-ask.js'; import { prefer } from '@/preferences.js'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { Paginator } from '@/utility/paginator.js'; +import { suggestReload } from '@/utility/reload-suggest.js'; const $i = ensureSignin(); @@ -216,8 +214,8 @@ const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); watch([ showSoftWordMutedWord, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +], () => { + suggestReload(); }); async function unrenoteMute(user, ev) { diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index e57195c8a2..f7c634b42e 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -64,7 +64,6 @@ import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { store } from '@/store.js'; -import { reloadAsk } from '@/utility/reload-ask.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; @@ -92,7 +91,7 @@ async function addItem() { value: '-', text: i18n.ts.divider, }], }); - if (canceled) return; + if (canceled || item == null) return; items.value = [...items.value, { id: genId(), type: item, diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 4e8d88ab74..84ecc23e84 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/notifications" :label="i18n.ts.notifications" :keywords="['notifications']" icon="ti ti-bell"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00"> - <SearchKeyword>{{ i18n.ts._settings.notificationsBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.notificationsBanner }}</SearchText> </MkFeatureBanner> <FormSection first> @@ -43,9 +43,9 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> <FormSection> <div class="_gaps_s"> - <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> - <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> - <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> + <MkButton @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</MkButton> + <MkButton @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</MkButton> + <MkButton @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</MkButton> </div> </FormSection> <FormSection> @@ -55,11 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPushNotificationAllowButton ref="allowButton"/> <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> - <template #caption> - <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> - <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> - </I18n> - </template> + <template #caption>{{ i18n.ts.sendPushNotificationReadMessageCaption }}</template> </MkSwitch> </div> </FormSection> @@ -76,6 +72,7 @@ import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -96,7 +93,7 @@ const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadM const userLists = await misskeyApi('users/lists/list'); async function readAllNotifications() { - await os.apiWithDialog('notifications/mark-all-as-read'); + await os.apiWithDialog('notifications/mark-all-as-read', {}); } async function updateReceiveConfig(type: typeof notificationTypes[number], value: NotificationConfig) { @@ -134,7 +131,7 @@ async function flushNotification() { if (canceled) return; - os.apiWithDialog('notifications/flush'); + os.apiWithDialog('notifications/flush', {}); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index ac432e9f32..730cce183a 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -75,7 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> - <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount"><SearchKeyword>{{ i18n.ts._accountDelete.requestAccountDelete }}</SearchKeyword></MkButton> + <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount"><SearchText>{{ i18n.ts._accountDelete.requestAccountDelete }}</SearchText></MkButton> <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> </div> </MkFolder> @@ -99,6 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="enableFolderPageView"> <template #label>Enable folder page view</template> </MkSwitch> + <MkSwitch v-model="enableHapticFeedback"> + <template #label>Enable haptic feedback</template> + </MkSwitch> </div> </MkFolder> </SearchMarker> @@ -128,9 +131,11 @@ SPDX-License-Identifier: AGPL-3.0-only <hr> - <MkButton @click="readAllChatMessages">Read all chat messages</MkButton> + <template v-if="$i.policies.chatAvailability !== 'unavailable'"> + <MkButton @click="readAllChatMessages">Read all chat messages</MkButton> - <hr> + <hr> + </template> <FormSlot> <MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton> @@ -155,13 +160,13 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { reloadAsk } from '@/utility/reload-ask.js'; import FormSection from '@/components/form/section.vue'; import { prefer } from '@/preferences.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import { signout } from '@/signout.js'; import { migrateOldSettings } from '@/pref-migrate.js'; import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js'; +import { suggestReload } from '@/utility/reload-suggest.js'; const $i = ensureSignin(); @@ -171,9 +176,10 @@ const skipNoteRender = prefer.model('skipNoteRender'); const devMode = prefer.model('devMode'); const stackingRouterView = prefer.model('experimental.stackingRouterView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); +const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback'); -watch(skipNoteRender, async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +watch(skipNoteRender, () => { + suggestReload(); }); async function deleteAccount() { diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index 22b53b4b96..8ed7f2a7b6 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -40,7 +40,7 @@ async function install() { code.value = null; router.push('/settings/plugin'); - } catch (err) { + } catch (err: any) { os.alert({ type: 'error', title: 'Install failed', diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 16d5947ad2..7c6ce90e7e 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -7,10 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin', 'addon', 'extension']" icon="ti ti-plug"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/electric_plug_3d.png" color="#ffbb00"> - <SearchKeyword>{{ i18n.ts._settings.pluginBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.pluginBanner }}</SearchText> </MkFeatureBanner> - <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> + <MkInfo v-if="isSafeMode" warn>{{ i18n.ts.pluginsAreDisabledBecauseSafeMode }}</MkInfo> + + <FormLink v-else to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> <FormSection> <template #label>{{ i18n.ts.manage }}</template> @@ -103,10 +105,12 @@ import MkCode from '@/components/MkCode.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js'; import { prefer } from '@/preferences.js'; +import { isSafeMode } from '@@/js/config.js'; import * as os from '@/os.js'; const plugins = prefer.r.plugins; diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 0e400778aa..fdf2373bfc 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/preferences" :label="i18n.ts.preferences" :keywords="['general', 'preferences']" icon="ti ti-adjustments"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/gear_3d.png" color="#00ff9d"> - <SearchKeyword>{{ i18n.ts._settings.preferencesBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.preferencesBanner }}</SearchText> </MkFeatureBanner> <div class="_gaps_s"> @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['realtimemode']"> <MkSwitch v-model="realtimeMode"> <template #label><i class="ti ti-bolt"></i> <SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts._settings.realtimeMode_description }}</SearchText></template> </MkSwitch> </SearchMarker> @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="pollingInterval"> <MkRange v-model="pollingInterval" :min="1" :max="3" :step="1" easing :showTicks="true" :textConverter="(v) => v === 1 ? i18n.ts.low : v === 2 ? i18n.ts.middle : v === 3 ? i18n.ts.high : ''"> <template #label><SearchLabel>{{ i18n.ts._settings.contentsUpdateFrequency }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description }}</SearchKeyword><br><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description2 }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts._settings.contentsUpdateFrequency_description }}</SearchText><br><SearchText>{{ i18n.ts._settings.contentsUpdateFrequency_description2 }}</SearchText></template> <template #prefix><i class="ti ti-player-play"></i></template> <template #suffix><i class="ti ti-player-track-next"></i></template> </MkRange> @@ -165,7 +165,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="collapseRenotes"> <MkSwitch v-model="collapseRenotes"> <template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.collapseRenotesDescription }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -449,7 +449,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/mens_room_3d.png" color="#0011ff"> - <SearchKeyword>{{ i18n.ts._settings.accessibilityBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.accessibilityBanner }}</SearchText> </MkFeatureBanner> <div class="_gaps_s"> @@ -477,6 +477,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['tabs', 'tabbar', 'bottom', 'under']"> + <MkPreferenceContainer k="showPageTabBarBottom"> + <MkSwitch v-model="showPageTabBarBottom"> + <template #label><SearchLabel>{{ i18n.ts._settings.showPageTabBarBottom }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['swipe', 'horizontal', 'tab']"> <MkPreferenceContainer k="enableHorizontalSwipe"> <MkSwitch v-model="enableHorizontalSwipe"> @@ -489,7 +497,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="enablePullToRefresh"> <MkSwitch v-model="enablePullToRefresh"> <template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts._settings.enablePullToRefresh_description }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts._settings.enablePullToRefresh_description }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -571,7 +579,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="animation"> <MkSwitch :modelValue="!reduceAnimation" @update:modelValue="v => reduceAnimation = !v"> <template #label><SearchLabel>{{ i18n.ts._settings.uiAnimations }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.turnOffToImprovePerformance }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -580,7 +588,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="useBlurEffect"> <MkSwitch v-model="useBlurEffect"> <template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.turnOffToImprovePerformance }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -589,7 +597,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="useBlurEffectForModal"> <MkSwitch v-model="useBlurEffectForModal"> <template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.turnOffToImprovePerformance }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -598,7 +606,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="enableHighQualityImagePlaceholders"> <MkSwitch v-model="enableHighQualityImagePlaceholders"> <template #label><SearchLabel>{{ i18n.ts._settings.enableHighQualityImagePlaceholders }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.turnOffToImprovePerformance }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -607,7 +615,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPreferenceContainer k="useStickyIcons"> <MkSwitch v-model="useStickyIcons"> <template #label><SearchLabel>{{ i18n.ts._settings.useStickyIcons }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.turnOffToImprovePerformance }}</SearchText></template> </MkSwitch> </MkPreferenceContainer> </SearchMarker> @@ -795,7 +803,6 @@ import MkInfo from '@/components/MkInfo.vue'; import { store } from '@/store.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { reloadAsk } from '@/utility/reload-ask.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -807,6 +814,7 @@ import { claimAchievement } from '@/utility/achievements.js'; import { instance } from '@/instance.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; +import { suggestReload } from '@/utility/reload-suggest.js'; const $i = ensureSignin(); @@ -866,6 +874,7 @@ const animatedMfm = prefer.model('animatedMfm'); const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); const keepScreenOn = prefer.model('keepScreenOn'); const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); +const showPageTabBarBottom = prefer.model('showPageTabBarBottom'); const enablePullToRefresh = prefer.model('enablePullToRefresh'); const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); const contextMenu = prefer.model('contextMenu'); @@ -877,8 +886,6 @@ const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); }); watch(fontSize, () => { @@ -925,11 +932,12 @@ watch([ useSystemFont, makeEveryTextElementsSelectable, enableHorizontalSwipe, + showPageTabBarBottom, enablePullToRefresh, reduceAnimation, showAvailableReactionsFirstInNote, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +], () => { + suggestReload(); }); const emojiIndexLangs = ['en-US', 'ja-JP', 'ja-JP_hira'] as const; @@ -1023,7 +1031,6 @@ function testNotification(): void { const notification: Misskey.entities.Notification = { id: genId(), createdAt: new Date().toUTCString(), - isRead: false, type: 'test', }; diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 4e6425667e..54a6c0af82 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/privacy" :label="i18n.ts.privacy" :keywords="['privacy']" icon="ti ti-lock-open"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/unlocked_3d.png" color="#aeff00"> - <SearchKeyword>{{ i18n.ts._settings.privacyBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.privacyBanner }}</SearchText> </MkFeatureBanner> <SearchMarker :keywords="['follow', 'lock']"> <MkSwitch v-model="isLocked" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.makeFollowManuallyApprove }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.lockedAccountInfo }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.lockedAccountInfo }}</SearchText></template> </MkSwitch> </SearchMarker> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['reaction', 'public']"> <MkSwitch v-model="publicReactions" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.makeReactionsPublic }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.makeReactionsPublicDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.makeReactionsPublicDescription }}</SearchText></template> </MkSwitch> </SearchMarker> @@ -53,28 +53,28 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['online', 'status']"> <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.hideOnlineStatus }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.hideOnlineStatusDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.hideOnlineStatusDescription }}</SearchText></template> </MkSwitch> </SearchMarker> <SearchMarker :keywords="['crawle', 'index', 'search']"> <MkSwitch v-model="noCrawle" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.noCrawle }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.noCrawleDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.noCrawleDescription }}</SearchText></template> </MkSwitch> </SearchMarker> <SearchMarker :keywords="['crawle', 'ai']"> <MkSwitch v-model="preventAiLearning" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.preventAiLearning }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.preventAiLearningDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.preventAiLearningDescription }}</SearchText></template> </MkSwitch> </SearchMarker> <SearchMarker :keywords="['explore']"> <MkSwitch v-model="isExplorable" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.makeExplorable }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.makeExplorableDescription }}</SearchKeyword></template> + <template #caption><SearchText>{{ i18n.ts.makeExplorableDescription }}</SearchText></template> </MkSwitch> </SearchMarker> @@ -125,17 +125,21 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> </MkSelect> - <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore"> - <option :value="-3600">{{ i18n.ts.oneHour }}</option> - <option :value="-86400">{{ i18n.ts.oneDay }}</option> - <option :value="-259200">{{ i18n.ts.threeDays }}</option> - <option :value="-604800">{{ i18n.ts.oneWeek }}</option> - <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> - <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> - <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore_selection"> + <option v-for="preset in makeNotesFollowersOnlyBefore_presets" :value="preset.value">{{ preset.label }}</option> + <option value="custom">{{ i18n.ts.custom }}</option> </MkSelect> <MkInput + v-if="makeNotesFollowersOnlyBefore_type === 'relative' && makeNotesFollowersOnlyBefore_isCustomMode" + v-model="makeNotesFollowersOnlyBefore_customMonths" + type="number" + :min="1" + > + <template #suffix>{{ i18n.ts._time.month }}</template> + </MkInput> + + <MkInput v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" type="date" @@ -146,7 +150,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <template #caption> - <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</SearchKeyword></div> + <div><SearchText>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</SearchText></div> </template> </FormSlot> </SearchMarker> @@ -156,23 +160,35 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</SearchLabel></template> <div class="_gaps_s"> - <MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> - <option :value="null">{{ i18n.ts.none }}</option> - <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> - <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + <MkSelect + :items="[{ + value: null, + label: i18n.ts.none + }, { + value: 'relative', + label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod + }, { + value: 'absolute', + label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime + }] as const" :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null" + > </MkSelect> - <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore"> - <option :value="-3600">{{ i18n.ts.oneHour }}</option> - <option :value="-86400">{{ i18n.ts.oneDay }}</option> - <option :value="-259200">{{ i18n.ts.threeDays }}</option> - <option :value="-604800">{{ i18n.ts.oneWeek }}</option> - <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> - <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> - <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore_selection"> + <option v-for="preset in makeNotesHiddenBefore_presets" :value="preset.value">{{ preset.label }}</option> + <option value="custom">{{ i18n.ts.custom }}</option> </MkSelect> <MkInput + v-if="makeNotesHiddenBefore_type === 'relative' && makeNotesHiddenBefore_isCustomMode" + v-model="makeNotesHiddenBefore_customMonths" + type="number" + :min="1" + > + <template #suffix>{{ i18n.ts._time.month }}</template> + </MkInput> + + <MkInput v-if="makeNotesHiddenBefore_type === 'absolute'" :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" type="date" @@ -183,7 +199,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <template #caption> - <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</SearchKeyword></div> + <div><SearchText>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</SearchText></div> </template> </FormSlot> </SearchMarker> @@ -241,6 +257,37 @@ const makeNotesFollowersOnlyBefore_type = computed(() => { } }); +const makeNotesFollowersOnlyBefore_presets = [ + { label: i18n.ts.oneHour, value: -3600 }, + { label: i18n.ts.oneDay, value: -86400 }, + { label: i18n.ts.threeDays, value: -259200 }, + { label: i18n.ts.oneWeek, value: -604800 }, + { label: i18n.ts.oneMonth, value: -2592000 }, + { label: i18n.ts.threeMonths, value: -7776000 }, + { label: i18n.ts.oneYear, value: -31104000 }, +]; + +const makeNotesFollowersOnlyBefore_isCustomMode = ref( + makeNotesFollowersOnlyBefore.value != null && + makeNotesFollowersOnlyBefore.value < 0 && + !makeNotesFollowersOnlyBefore_presets.some((preset) => preset.value === makeNotesFollowersOnlyBefore.value), +); + +const makeNotesFollowersOnlyBefore_selection = computed({ + get: () => makeNotesFollowersOnlyBefore_isCustomMode.value ? 'custom' : makeNotesFollowersOnlyBefore.value, + set(value) { + makeNotesFollowersOnlyBefore_isCustomMode.value = value === 'custom'; + if (value !== 'custom') makeNotesFollowersOnlyBefore.value = value; + }, +}); + +const makeNotesFollowersOnlyBefore_customMonths = computed({ + get: () => makeNotesFollowersOnlyBefore.value ? Math.abs(makeNotesFollowersOnlyBefore.value) / (30 * 24 * 60 * 60) : null, + set(value) { + if (value != null && value > 0) makeNotesFollowersOnlyBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60; + }, +}); + const makeNotesHiddenBefore_type = computed(() => { if (makeNotesHiddenBefore.value == null) { return null; @@ -251,6 +298,37 @@ const makeNotesHiddenBefore_type = computed(() => { } }); +const makeNotesHiddenBefore_presets = [ + { label: i18n.ts.oneHour, value: -3600 }, + { label: i18n.ts.oneDay, value: -86400 }, + { label: i18n.ts.threeDays, value: -259200 }, + { label: i18n.ts.oneWeek, value: -604800 }, + { label: i18n.ts.oneMonth, value: -2592000 }, + { label: i18n.ts.threeMonths, value: -7776000 }, + { label: i18n.ts.oneYear, value: -31104000 }, +]; + +const makeNotesHiddenBefore_isCustomMode = ref( + makeNotesHiddenBefore.value != null && + makeNotesHiddenBefore.value < 0 && + !makeNotesHiddenBefore_presets.some((preset) => preset.value === makeNotesHiddenBefore.value), +); + +const makeNotesHiddenBefore_selection = computed({ + get: () => makeNotesHiddenBefore_isCustomMode.value ? 'custom' : makeNotesHiddenBefore.value, + set(value) { + makeNotesHiddenBefore_isCustomMode.value = value === 'custom'; + if (value !== 'custom') makeNotesHiddenBefore.value = value; + }, +}); + +const makeNotesHiddenBefore_customMonths = computed({ + get: () => makeNotesHiddenBefore.value ? Math.abs(makeNotesHiddenBefore.value) / (30 * 24 * 60 * 60) : null, + set(value) { + if (value != null && value > 0) makeNotesHiddenBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60; + }, +}); + watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => { save(); }); diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ce7f31cd23..4816a6e33b 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/profile" :label="i18n.ts.profile" :keywords="['profile']" icon="ti ti-user"> <div class="_gaps_m"> <div class="_panel"> - <div :class="$style.banner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div :class="$style.banner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : '' }"> <div :class="$style.bannerEdit"> <SearchMarker :keywords="['banner', 'change']"> <MkButton primary rounded @click="changeBanner"><SearchLabel>{{ i18n.ts._profile.changeBanner }}</SearchLabel></MkButton> @@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="profile.followedMessage" :max="200" manualSave :mfmPreview="false"> <template #label><SearchLabel>{{ i18n.ts._profile.followedMessage }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> <template #caption> - <div><SearchKeyword>{{ i18n.ts._profile.followedMessageDescription }}</SearchKeyword></div> + <div><SearchText>{{ i18n.ts._profile.followedMessageDescription }}</SearchText></div> <div>{{ i18n.ts._profile.followedMessageDescriptionForLockedAccount }}</div> </template> </MkInput> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index 2562993be3..bc77c1f0af 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00"> - <SearchKeyword>{{ i18n.ts._settings.securityBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText> </MkFeatureBanner> <SearchMarker :keywords="['password']"> @@ -24,30 +24,34 @@ SPDX-License-Identifier: AGPL-3.0-only <X2fa/> - <FormSection> - <template #label>{{ i18n.ts.signinHistory }}</template> - <MkPagination :paginator="paginator" withControl> - <template #default="{items}"> - <div> - <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> - <header> - <i v-if="item.success" class="ti ti-check icon succ"></i> - <i v-else class="ti ti-circle-x icon fail"></i> - <code class="ip _monospace">{{ item.ip }}</code> - <MkTime :time="item.createdAt" class="time"/> - </header> + <SearchMarker :keywords="['signin', 'login', 'history', 'log']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template> + <MkPagination :paginator="paginator" withControl> + <template #default="{items}"> + <div> + <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> + <header> + <i v-if="item.success" class="ti ti-check icon succ"></i> + <i v-else class="ti ti-circle-x icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> </div> - </div> - </template> - </MkPagination> - </FormSection> + </template> + </MkPagination> + </FormSection> + </SearchMarker> - <FormSection> - <FormSlot> - <MkButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</MkButton> - <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> - </FormSlot> - </FormSection> + <SearchMarker :keywords="['regenerate', 'refresh', 'reset', 'token']"> + <FormSection> + <FormSlot> + <MkButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> <SearchLabel>{{ i18n.ts.regenerateLoginToken }}</SearchLabel></MkButton> + <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> + </FormSlot> + </FormSection> + </SearchMarker> </div> </SearchMarker> </template> @@ -76,14 +80,14 @@ async function change() { type: 'password', autocomplete: 'new-password', }); - if (canceled2) return; + if (canceled2 || newPassword == null) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, type: 'password', autocomplete: 'new-password', }); - if (canceled3) return; + if (canceled3 || newPassword2 == null) return; if (newPassword !== newPassword2) { os.alert({ diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 590db19bca..ea5b347525 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/settings/sounds" :label="i18n.ts.sounds" :keywords="['sounds']" icon="ti ti-music"> <div class="_gaps_m"> <MkFeatureBanner icon="/client-assets/speaker_high_volume_3d.png" color="#ff006f"> - <SearchKeyword>{{ i18n.ts._settings.soundsBanner }}</SearchKeyword> + <SearchText>{{ i18n.ts._settings.soundsBanner }}</SearchText> </MkFeatureBanner> <SearchMarker :keywords="['mute']"> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index dbb640123a..561d31148f 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="statusbar.props.shuffle"> <template #label>{{ i18n.ts.shuffle }}</template> </MkSwitch> - <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" :min="1"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> <template v-else-if="statusbar.type === 'federation'"> - <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" :min="1"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> @@ -104,7 +104,7 @@ const props = defineProps<{ userLists: Misskey.entities.UserList[] | null; }>(); -const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))); +const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))!); watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index ac95279402..f79357c361 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkCodeEditor> <div class="_buttons"> - <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" inline @click="() => previewTheme(installThemeCode!)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" primary inline @click="() => install(installThemeCode!)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -39,7 +39,7 @@ async function install(code: string): Promise<void> { }); installThemeCode.value = null; router.push('/settings/theme'); - } catch (err) { + } catch (err: any) { switch (err.message.toLowerCase()) { case 'this theme is already installed': os.alert({ diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index fcd0b293e0..e972184278 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts._theme.code }}</template> <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template> </MkTextarea> - <MkButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + <MkButton v-if="!builtinThemes.some(t => t.id == selectedTheme!.id)" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> </template> </div> </template> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index accb1ccc55..beae1224e4 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -35,7 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <div class="_gaps"> + <MkInfo v-if="isSafeMode" warn>{{ i18n.ts.themeIsDefaultBecauseSafeMode }}</MkInfo> + + <div v-else class="_gaps"> <template v-if="!store.r.darkMode.value"> <SearchMarker :keywords="['light', 'theme']"> <MkFolder :defaultOpen="true" :max-height="500"> @@ -203,6 +205,7 @@ import { computed, ref, watch } from 'vue'; import JSON5 from 'json5'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import { isSafeMode } from '@@/js/config.js'; import type { Theme } from '@/theme.js'; import * as os from '@/os.js'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -210,6 +213,7 @@ import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkThemePreview from '@/components/MkThemePreview.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { store } from '@/store.js'; @@ -271,6 +275,7 @@ async function toggleDarkMode() { const value = !store.r.darkMode.value; if (syncDeviceDarkMode.value) { const { canceled } = await os.confirm({ + type: 'question', text: i18n.tsx.switchDarkModeManuallyWhenSyncEnabledConfirm({ x: i18n.ts.syncDeviceDarkMode }), }); if (canceled) return; diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 877d2deb90..a92d3dc457 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -135,7 +135,7 @@ async function del(): Promise<void> { webhookId: props.webhookId, }); - router.push('/settings/webhook'); + router.push('/settings/connect'); } async function test(type: Misskey.entities.UserWebhook['on'][number]): Promise<void> { @@ -149,10 +149,8 @@ async function test(type: Misskey.entities.UserWebhook['on'][number]): Promise<v }); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars const headerActions = computed(() => []); -// eslint-disable-next-line @typescript-eslint/no-unused-vars const headerTabs = computed(() => []); definePage(() => ({ diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index e853f967cb..5f36e6bcfc 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -40,6 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -61,7 +62,7 @@ const event_reaction = ref(true); const event_mention = ref(true); async function create(): Promise<void> { - const events = []; + const events = [] as Misskey.entities.UserWebhook['on']; if (event_follow.value) events.push('follow'); if (event_followed.value) events.push('followed'); if (event_note.value) events.push('note'); diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue index 15954ccc82..8a907c9066 100644 --- a/packages/frontend/src/pages/signup-complete.vue +++ b/packages/frontend/src/pages/signup-complete.vue @@ -51,7 +51,7 @@ function submit() { os.alert({ type: 'error', title: i18n.ts.somethingHappened, - text: i18n.ts.signupPendingError, + text: i18n.ts.emailVerificationFailedError, }); }); } diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index b5a4503b68..047e68f583 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -52,7 +52,7 @@ async function post() { const headerActions = computed(() => [{ icon: 'ti ti-dots', - label: i18n.ts.more, + text: i18n.ts.more, handler: (ev: MouseEvent) => { os.popupMenu([{ text: i18n.ts.embed, diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index d1be9e38b7..af3891ac8e 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -11,12 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.backgroundColor }}</template> <div class="cwepdizn-colors"> <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> <div class="preview" :style="{ background: color.forPreview }"></div> </button> </div> <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> <div class="preview" :style="{ background: color.forPreview }"></div> </button> </div> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.accentColor }}</template> <div class="cwepdizn-colors"> <div class="row"> - <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> + <button v-for="color in accentColors" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> <div class="preview" :style="{ background: color }"></div> </button> </div> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.textColor }}</template> <div class="cwepdizn-colors"> <div class="row"> - <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> + <button v-for="color in fgColors" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> </button> </div> @@ -75,17 +75,17 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, ref, computed } from 'vue'; import { toUnicode } from 'punycode.js'; import tinycolor from 'tinycolor2'; -import { genId } from '@/utility/id.js'; import JSON5 from 'json5'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; import { host } from '@@/js/config.js'; import type { Theme } from '@/theme.js'; +import { genId } from '@/utility/id.js'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { $i } from '@/i.js'; +import { ensureSignin } from '@/i.js'; import { addTheme, applyTheme } from '@/theme.js'; import * as os from '@/os.js'; import { store } from '@/store.js'; @@ -94,6 +94,8 @@ import { useLeaveGuard } from '@/composables/use-leave-guard.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; +const $i = ensureSignin(); + const bgColors = [ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, { color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' }, @@ -123,12 +125,15 @@ const fgColors = [ { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, ]; -const theme = ref<Partial<Theme>>({ +const theme = ref<Theme>({ + id: genId(), + name: 'untitled', + author: `@${$i.username}@${toUnicode(host)}`, base: 'light', props: lightTheme.props, }); const description = ref<string | null>(null); -const themeCode = ref<string | null>(null); +const themeCode = ref<string>(''); const changed = ref(false); useLeaveGuard(changed); @@ -194,7 +199,6 @@ async function saveAs() { theme.value.id = genId(); theme.value.name = name; - theme.value.author = `@${$i.username}@${toUnicode(host)}`; if (description.value) theme.value.desc = description.value; await addTheme(theme.value); applyTheme(theme.value); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index b783f7ee0b..f72549df07 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true"> +<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true" :canOmitTitle="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <MkTip v-if="isBasicTimeline(src)" :k="`tl.${src}`" style="margin-bottom: var(--MI-margin);"> {{ i18n.ts._timelineDescription[src] }} @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="tlComponent" :key="src + withRenotes + withReplies + onlyFiles + withSensitive" :class="$style.tl" - :src="src.split(':')[0]" + :src="(src.split(':')[0] as (BasicTimelineType | 'list'))" :list="src.split(':')[1]" :withRenotes="withRenotes" :withReplies="withReplies" @@ -45,8 +45,6 @@ import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { prefer } from '@/preferences.js'; -provide('shouldOmitHeaderTitle', true); - const tlComponent = useTemplateRef('tlComponent'); type TimelinePageSrc = BasicTimelineType | `list:${string}`; @@ -105,9 +103,11 @@ const withSensitive = computed<boolean>({ set: (x) => saveTlFilter('withSensitive', x), }); +const showFixedPostForm = prefer.model('showFixedPostForm'); + async function chooseList(ev: MouseEvent): Promise<void> { const lists = await userListsCache.fetch(); - const items: MenuItem[] = [ + const items: (MenuItem | undefined)[] = [ ...lists.map(list => ({ type: 'link' as const, text: list.name, @@ -121,12 +121,12 @@ async function chooseList(ev: MouseEvent): Promise<void> { to: '/my/lists', }, ]; - os.popupMenu(items, ev.currentTarget ?? ev.target); + os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target); } async function chooseAntenna(ev: MouseEvent): Promise<void> { const antennas = await antennasCache.fetch(); - const items: MenuItem[] = [ + const items: (MenuItem | undefined)[] = [ ...antennas.map(antenna => ({ type: 'link' as const, text: antenna.name, @@ -141,12 +141,12 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { to: '/my/antennas', }, ]; - os.popupMenu(items, ev.currentTarget ?? ev.target); + os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target); } async function chooseChannel(ev: MouseEvent): Promise<void> { const channels = await favoritedChannelsCache.fetch(); - const items: MenuItem[] = [ + const items: (MenuItem | undefined)[] = [ ...channels.map(channel => { const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null; const hasUnreadNote = (lastReadedAt && channel.lastNotedAt) ? Date.parse(channel.lastNotedAt) > lastReadedAt : !!(!lastReadedAt && channel.lastNotedAt); @@ -166,7 +166,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { to: '/channels', }, ]; - os.popupMenu(items, ev.currentTarget ?? ev.target); + os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target); } function saveSrc(newSrc: TimelinePageSrc): void { @@ -190,19 +190,6 @@ function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) { } } -async function timetravel(): Promise<void> { - const { canceled, result: date } = await os.inputDate({ - title: i18n.ts.date, - }); - if (canceled) return; - - tlComponent.value.timetravel(date); -} - -function focus(): void { - tlComponent.value.focus(); -} - function switchTlIfNeeded() { if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) { src.value = availableBasicTimelines()[0]; @@ -217,49 +204,54 @@ onActivated(() => { }); const headerActions = computed(() => { - const tmp = [ - { - icon: 'ti ti-dots', - text: i18n.ts.options, - handler: (ev) => { - const menuItems: MenuItem[] = []; - - menuItems.push({ - type: 'switch', - icon: 'ti ti-repeat', - text: i18n.ts.showRenotes, - ref: withRenotes, - }); + const items = [{ + icon: 'ti ti-dots', + text: i18n.ts.options, + handler: (ev) => { + const menuItems: MenuItem[] = []; - if (isBasicTimeline(src.value) && hasWithReplies(src.value)) { - menuItems.push({ - type: 'switch', - icon: 'ti ti-messages', - text: i18n.ts.showRepliesToOthersInTimeline, - ref: withReplies, - disabled: onlyFiles, - }); - } + menuItems.push({ + type: 'switch', + icon: 'ti ti-repeat', + text: i18n.ts.showRenotes, + ref: withRenotes, + }); + if (isBasicTimeline(src.value) && hasWithReplies(src.value)) { menuItems.push({ type: 'switch', - icon: 'ti ti-eye-exclamation', - text: i18n.ts.withSensitive, - ref: withSensitive, - }, { - type: 'switch', - icon: 'ti ti-photo', - text: i18n.ts.fileAttachedOnly, - ref: onlyFiles, - disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false, + icon: 'ti ti-messages', + text: i18n.ts.showRepliesToOthersInTimeline, + ref: withReplies, + disabled: onlyFiles, }); + } - os.popupMenu(menuItems, ev.currentTarget ?? ev.target); - }, + menuItems.push({ + type: 'switch', + icon: 'ti ti-eye-exclamation', + text: i18n.ts.withSensitive, + ref: withSensitive, + }, { + type: 'switch', + icon: 'ti ti-photo', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false, + }, { + type: 'divider', + }, { + type: 'switch', + text: i18n.ts.showFixedPostForm, + ref: showFixedPostForm, + }); + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); }, - ]; + }]; + if (deviceKind === 'desktop') { - tmp.unshift({ + items.unshift({ icon: 'ti ti-refresh', text: i18n.ts.reload, handler: (ev: Event) => { @@ -267,7 +259,8 @@ const headerActions = computed(() => { }, }); } - return tmp; + + return items; }); const headerTabs = computed(() => [...(prefer.r.pinnedUserLists.value.map(l => ({ diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index f166495258..57a85a0be7 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -42,7 +42,11 @@ watch(() => props.listId, async () => { }, { immediate: true }); function settings() { - router.push(`/my/lists/${props.listId}`); + router.push('/my/lists/:listId', { + params: { + listId: props.listId, + } + }); } const headerActions = computed(() => list.value ? [{ diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index f2a5ad8e75..882b45080e 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -36,13 +36,15 @@ const props = defineProps<{ const chartEl = useTemplateRef('chartEl'); const legendEl = useTemplateRef('legendEl'); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 30; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); async function renderChart() { + if (chartEl.value == null) return; + if (chartInstance) { chartInstance.destroy(); } diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index ddde84ef25..39c9fd7950 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -36,13 +36,15 @@ const props = defineProps<{ const chartEl = useTemplateRef('chartEl'); const legendEl = useTemplateRef('legendEl'); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 50; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); async function renderChart() { + if (chartEl.value == null) return; + if (chartInstance) { chartInstance.destroy(); } diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index 34e1fe3abf..9e1b92058b 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -36,13 +36,15 @@ const props = defineProps<{ const chartEl = useTemplateRef('chartEl'); const legendEl = useTemplateRef('legendEl'); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 30; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); async function renderChart() { + if (chartEl.value == null) return; + if (chartInstance) { chartInstance.destroy(); } diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue index 6bb1360d42..c383b9b7bd 100644 --- a/packages/frontend/src/pages/user/follow-list.vue +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <MkPagination v-slot="{items}" :paginator="type === 'following' ? followingPaginator : followersPaginator" withControl> <div :class="$style.users"> - <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :user="user"/> + <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee! : x.follower!)" :key="user.id" :user="user"/> </div> </MkPagination> </div> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index ed3ae6a2aa..6933d64214 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserName class="name" :user="user" :nowrap="true"/> <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> - <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <span v-if="user.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot"><i class="ti ti-robot"></i></span> <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} </button> @@ -43,8 +43,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserName :user="user" :nowrap="false" class="name"/> <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> - <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> - <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <span v-if="user.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot"><i class="ti ti-robot"></i></span> </div> </div> <div v-if="user.followedMessage != null" class="followedMessage"> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFukidashi> </div> <div v-if="user.roles.length > 0" class="roles"> - <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> + <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }"> <MkA v-adaptive-bg :to="`/roles/${role.id}`"> <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> {{ role.name }} @@ -228,7 +228,7 @@ const bannerEl = ref<null | HTMLElement>(null); const memoTextareaEl = ref<null | HTMLElement>(null); const memoDraft = ref(props.user.memo); const isEditingMemo = ref(false); -const moderationNote = ref(props.user.moderationNote); +const moderationNote = ref(props.user.moderationNote ?? ''); const editModerationNote = ref(false); watch(moderationNote, async () => { @@ -249,7 +249,7 @@ const style = computed(() => { }); const age = computed(() => { - return calcAge(props.user.birthday); + return props.user.birthday ? calcAge(props.user.birthday) : NaN; }); function menu(ev: MouseEvent) { diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index 45bc35067b..210021618e 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -34,7 +34,7 @@ const props = withDefaults(defineProps<{ limit: 50, }); -const chartSrc = ref('per-user-notes'); +const chartSrc = ref<'per-user-notes' | 'per-user-pv'>('per-user-notes'); function showMenu(ev: MouseEvent) { os.popupMenu([{ diff --git a/packages/frontend/src/pages/user/raw.vue b/packages/frontend/src/pages/user/raw.vue index f0e675b913..145ef5dd92 100644 --- a/packages/frontend/src/pages/user/raw.vue +++ b/packages/frontend/src/pages/user/raw.vue @@ -48,7 +48,7 @@ import FormSection from '@/components/form/section.vue'; import MkObjectView from '@/components/MkObjectView.vue'; const props = defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserDetailed & { isModerator?: boolean; }; }>(); const moderator = computed(() => props.user.isModerator ?? false); diff --git a/packages/frontend/src/pages/verify-email.vue b/packages/frontend/src/pages/verify-email.vue new file mode 100644 index 0000000000..daf3a0c4c6 --- /dev/null +++ b/packages/frontend/src/pages/verify-email.vue @@ -0,0 +1,122 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithAnimBg> + <div :class="$style.formContainer"> + <form :class="$style.form" class="_panel" @submit.prevent="submit()"> + <div :class="$style.banner"> + <i class="ti ti-mail-check"></i> + </div> + <Transition + mode="out-in" + :enterActiveClass="$style.transition_enterActive" + :leaveActiveClass="$style.transition_leaveActive" + :enterFromClass="$style.transition_enterFrom" + :leaveToClass="$style.transition_leaveTo" + > + <div v-if="!succeeded" key="input" class="_gaps_m" style="padding: 32px;"> + <div :class="$style.mainText">{{ i18n.tsx.clickToFinishEmailVerification({ ok: i18n.ts.gotIt }) }}</div> + <div> + <MkButton gradate large rounded type="submit" :disabled="submitting" style="margin: 0 auto;"> + {{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/> + </MkButton> + </div> + </div> + <div v-else key="success" class="_gaps_m" style="padding: 32px;"> + <div :class="$style.mainText">{{ i18n.ts.emailVerified }}</div> + <div> + <MkButton large rounded link to="/" linkBehavior="browser" style="margin: 0 auto;"> + {{ i18n.ts.goToMisskey }} + </MkButton> + </div> + </div> + </Transition> + </form> + </div> +</PageWithAnimBg> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const submitting = ref(false); +const succeeded = ref(false); + +const props = defineProps<{ + code: string; +}>(); + +function submit() { + if (submitting.value) return; + submitting.value = true; + + misskeyApi('verify-email', { + code: props.code, + }).then(() => { + succeeded.value = true; + submitting.value = false; + }).catch(() => { + submitting.value = false; + + os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + text: i18n.ts.emailVerificationFailedError, + }); + }); +} +</script> + +<style lang="scss" module> +.transition_enterActive, +.transition_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.formContainer { + min-height: 100svh; + padding: 32px 32px 64px 32px; + box-sizing: border-box; + display: flex; + align-items: center; +} + +.form { + position: relative; + display: block; + margin: 0 auto; + z-index: 10; + border-radius: var(--MI-radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + width: 100%; + max-width: 500px; +} + +.banner { + padding: 16px; + text-align: center; + font-size: 26px; + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); +} + +.mainText { + text-align: center; +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.classic.vue index c2cf937c71..cddec3332e 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.classic.vue @@ -53,7 +53,7 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string misskeyApiGet('federation/instances', { sort: '+pubSub', limit: 20, - blocked: 'false', + blocked: false, }).then(_instances => { instances.value = _instances; }); diff --git a/packages/frontend/src/pages/welcome.entrance.simple.vue b/packages/frontend/src/pages/welcome.entrance.simple.vue new file mode 100644 index 0000000000..c2a2420e50 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.simple.vue @@ -0,0 +1,69 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="meta" :class="$style.root"> + <MkFeaturedPhotos :class="$style.bg"/> + <div :class="$style.logoWrapper"> + <div :class="$style.poweredBy">Powered by</div> + <img :src="misskeysvg" :class="$style.misskey"/> + </div> + <div :class="$style.contents"> + <MkVisitorDashboard/> + </div> +</div> +</template> + +<script lang="ts" setup> +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import misskeysvg from '/client-assets/misskey.svg'; +import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; +import { instance as meta } from '@/instance.js'; +</script> + +<style lang="scss" module> +.root { + height: 100cqh; + overflow: auto; + overscroll-behavior: contain; +} + +.bg { + position: fixed; + top: 0; + right: 0; + width: 80vw; // 100%からshapeの幅を引いている + height: 100vh; +} + +.logoWrapper { + position: fixed; + top: 36px; + left: 36px; + flex: auto; + color: #fff; + user-select: none; + pointer-events: none; +} + +.poweredBy { + margin-bottom: 2px; +} + +.misskey { + width: 120px; + + @media (max-width: 450px) { + width: 100px; + } +} + +.contents { + position: relative; + width: min(430px, calc(100% - 32px)); + margin: auto; + padding: 100px 0 100px 0; +} +</style> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 3e2d086858..393ba98d30 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -87,7 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ i18n.ts._serverSetupWizard.settingsYouMakeHereCanBeChangedLater }}</div> </div> - <MkServerSetupWizard :token="token" @finished="onWizardFinished"/> + <Suspense> + <template #default> + <MkServerSetupWizard :token="token" @finished="onWizardFinished"/> + </template> + <template #fallback> + <MkLoading/> + </template> + </Suspense> <MkButton rounded style="margin: 0 auto;" @click="skipSettings"> {{ i18n.ts._serverSetupWizard.skipSettings }} diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index d3e571c053..2b70996252 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="instance"> <XSetup v-if="instance.requireSetup"/> - <XEntrance v-else/> + <XEntranceClassic v-else-if="(instance.clientOptions.entrancePageStyle ?? 'classic') === 'classic'"/> + <XEntranceSimple v-else/> </div> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XSetup from './welcome.setup.vue'; -import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@@/js/config.js'; +import XSetup from './welcome.setup.vue'; +import XEntranceClassic from './welcome.entrance.classic.vue'; +import XEntranceSimple from './welcome.entrance.simple.vue'; import { definePage } from '@/page.js'; import { fetchInstance } from '@/instance.js'; diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index d6007a27ed..346e275575 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -3,18 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref, defineAsyncComponent } from 'vue'; -import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; +import { ref } from 'vue'; import { compareVersions } from 'compare-versions'; -import { genId } from '@/utility/id.js'; +import { isSafeMode } from '@@/js/config.js'; import * as Misskey from 'misskey-js'; -import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import type { Parser, Interpreter, values } from '@syuilo/aiscript'; +import type { FormWithDefault } from '@/utility/form.js'; +import { genId } from '@/utility/id.js'; import { store } from '@/store.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; -import type { FormWithDefault } from '@/utility/form.js'; export type Plugin = { installId: string; @@ -38,7 +38,13 @@ export type AiScriptPluginMeta = { config?: Record<string, any>; }; -const parser = new Parser(); +let _parser: Parser | null = null; + +async function getParser(): Promise<Parser> { + const { Parser } = await import('@syuilo/aiscript'); + _parser ??= new Parser(); + return _parser; +} export function isSupportedAiScriptVersion(version: string): boolean { try { @@ -53,6 +59,8 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> throw new Error('code is required'); } + const { Interpreter, utils } = await import('@syuilo/aiscript'); + const lv = utils.getLangVersion(code); if (lv == null) { throw new Error('No language version annotation found'); @@ -62,6 +70,7 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> let ast; try { + const parser = await getParser(); ast = parser.parse(code); } catch (err) { throw new Error('Aiscript syntax error'); @@ -224,14 +233,17 @@ function addPluginHandler<K extends keyof HandlerDef>(installId: Plugin['install } export function launchPlugins() { - for (const plugin of prefer.s.plugins) { + return Promise.all(prefer.s.plugins.map(plugin => { if (plugin.active) { - launchPlugin(plugin.installId); + return launchPlugin(plugin.installId); + } else { + return Promise.resolve(); } - } + })); } async function launchPlugin(id: Plugin['installId']): Promise<void> { + if (isSafeMode) return; const plugin = prefer.s.plugins.find(x => x.installId === id); if (!plugin) return; @@ -253,7 +265,10 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> { await authorizePlugin(plugin); - const aiscript = new Interpreter(createPluginEnv({ + const { Interpreter, utils } = await import('@syuilo/aiscript'); + const { aiScriptReadline } = await import('@/aiscript/api.js'); + + const aiscript = new Interpreter(await createPluginEnv({ plugin: plugin, storageKey: 'plugins:' + plugin.installId, }), { @@ -278,7 +293,8 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> { pluginContexts.set(plugin.installId, aiscript); - aiscript.exec(parser.parse(plugin.src)).then( + const parser = await getParser(); + await aiscript.exec(parser.parse(plugin.src)).then( () => { console.info('Plugin installed:', plugin.name, 'v' + plugin.version); systemLog('Plugin started'); @@ -334,9 +350,12 @@ export function changePluginActive(plugin: Plugin, active: boolean) { } } -function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> { +async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Promise<Record<string, values.Value>> { const id = opts.plugin.installId; + const { utils, values } = await import('@syuilo/aiscript'); + const { createAiScriptEnv } = await import('@/aiscript/api.js'); + const config = new Map<string, values.Value>(); for (const [k, v] of Object.entries(opts.plugin.config ?? {})) { config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); @@ -392,8 +411,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s 'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => { utils.assertFunction(handler); addPluginHandler(id, 'note_view_interruptor', { - handler: withContext(ctx => async (note) => { - return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + handler: withContext(ctx => (note) => { + return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(note)])); }), }); }), diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index a83a3153d0..702d9a4acf 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -32,6 +32,8 @@ export type SoundStore = { volume: number; }; +type OmitStrict<T, K extends keyof T> = T extends any ? Pick<T, Exclude<keyof T, K>> : never; + // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) export const PREF_DEF = definePreferences({ @@ -381,8 +383,11 @@ export const PREF_DEF = definePreferences({ showAvailableReactionsFirstInNote: { default: false, }, + showPageTabBarBottom: { + default: false, + }, plugins: { - default: [] as Plugin[], + default: [] as (OmitStrict<Plugin, 'config'> & { config: Record<string, any> })[], mergeStrategy: (a, b) => { const sameIdExists = a.some(x => b.some(y => x.installId === y.installId)); if (sameIdExists) throw new Error(); @@ -495,4 +500,7 @@ export const PREF_DEF = definePreferences({ 'experimental.enableFolderPageView': { default: false, }, + 'experimental.enableHapticFeedback': { + default: false, + }, }); diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 0389cf612a..d26d590851 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -109,10 +109,11 @@ export function definePreferences<T extends Record<string, unknown>>(x: { } export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> { - if (typeof PREF_DEF[k].default === 'function') { // factory - return PREF_DEF[k].default(); + const _default = PREF_DEF[k as string].default; + if (typeof _default === 'function') { // factory + return _default(); } else { - return PREF_DEF[k].default; + return _default; } } @@ -468,6 +469,8 @@ export class PreferencesManager { return local; } else if (choice === 'merge') { return mergedValue!; + } else { // TSを黙らすため + return undefined; } } diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index 5e0e6f7286..e25e0fe161 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -203,6 +203,9 @@ export const ROUTE_DEF = [{ path: '/signup-complete/:code', component: page(() => import('@/pages/signup-complete.vue')), }, { + path: '/verify-email/:code', + component: page(() => import('@/pages/verify-email.vue')), +}, { path: '/announcements', component: page(() => import('@/pages/announcements.vue')), }, { @@ -492,10 +495,6 @@ export const ROUTE_DEF = [{ name: 'performance', component: page(() => import('@/pages/admin/performance.vue')), }, { - path: '/server-rules', - name: 'server-rules', - component: page(() => import('@/pages/admin/server-rules.vue')), - }, { path: '/invites', name: 'invites', component: page(() => import('@/pages/admin/invites.vue')), @@ -603,4 +602,4 @@ export const ROUTE_DEF = [{ }, { path: '/:(*)', component: page(() => import('@/pages/not-found.vue')), -}] satisfies RouteDef[]; +}] as const satisfies RouteDef[]; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 97ca63f50d..b1c1708915 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -20,7 +20,7 @@ export function createRouter(fullPath: string): Router { export const mainRouter = createRouter(window.location.pathname + window.location.search + window.location.hash); window.addEventListener('popstate', (event) => { - mainRouter.replace(window.location.pathname + window.location.search + window.location.hash); + mainRouter.replaceByPath(window.location.pathname + window.location.search + window.location.hash); }); mainRouter.addListener('push', ctx => { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e9402cfb70..750ca69133 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -106,7 +106,7 @@ export const store = markRaw(new Pizzax('base', { }, accountInfos: { where: 'device', - default: {} as Record<string, Misskey.entities.User>, // host/userId, user + default: {} as Record<string, Misskey.entities.MeDetailed>, // host/userId, user }, enablePreferencesAutoCloudBackup: { diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index ebd2b7e48c..c98b0a4953 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -37,11 +37,6 @@ html { color: var(--MI_THEME-fg); accent-color: var(--MI_THEME-accent); - &, * { - scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; - scrollbar-width: thin; - } - &.f-1 { font-size: 15px; } @@ -64,12 +59,6 @@ html { } } -html._themeChangingFallback_ { - &, * { - transition: background 0.5s ease, border 0.5s ease !important; - } -} - html._themeChanging_ { view-transition-name: theme-changing; } @@ -97,7 +86,11 @@ html::selection { 100% { opacity: 0; } +} +html, body, main, div { + scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; + scrollbar-width: thin; } html, diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts index e48eb04103..4d03b1d0e9 100644 --- a/packages/frontend/src/theme.ts +++ b/packages/frontend/src/theme.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +// TODO: (可能な部分を)sharedに抽出して frontend-embed と共通化 + import { ref, nextTick } from 'vue'; import tinycolor from 'tinycolor2'; import lightTheme from '@@/themes/_light.json5'; @@ -23,6 +25,7 @@ export type Theme = { author: string; desc?: string; base?: 'dark' | 'light'; + kind?: 'dark' | 'light'; // legacy props: Record<string, string>; codeHighlighter?: { base: BundledTheme; @@ -137,9 +140,10 @@ export function applyTheme(theme: Theme, persist = true) { } if (deepEqual(currentTheme, theme)) return; - currentTheme = theme; + // リアクティビティ解除 + currentTheme = deepClone(theme); - if (window.document.startViewTransition != null && prefer.s.animation) { + if (window.document.startViewTransition != null) { window.document.documentElement.classList.add('_themeChanging_'); window.document.startViewTransition(async () => { applyThemeInternal(theme, persist); @@ -150,15 +154,9 @@ export function applyTheme(theme: Theme, persist = true) { globalEvents.emit('themeChanged'); }); } else { - // TODO: ViewTransition API が主要ブラウザで対応したら消す - window.document.documentElement.classList.add('_themeChangingFallback_'); - timeout = window.setTimeout(() => { - window.document.documentElement.classList.remove('_themeChangingFallback_'); - // 色計算など再度行えるようにクライアント全体に通知 - globalEvents.emit('themeChanged'); - }, 500); - applyThemeInternal(theme, persist); + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); } } @@ -170,16 +168,21 @@ export function compile(theme: Theme): Record<string, string> { return getColor(theme.props[val]); } else if (val[0] === ':') { // func const parts = val.split('<'); - const func = parts.shift().substring(1); - const arg = parseFloat(parts.shift()); - const color = getColor(parts.join('<')); + const funcTxt = parts.shift(); + const argTxt = parts.shift(); + + if (funcTxt && argTxt) { + const func = funcTxt.substring(1); + const arg = parseFloat(argTxt); + const color = getColor(parts.join('<')); - switch (func) { - case 'darken': return color.darken(arg); - case 'lighten': return color.lighten(arg); - case 'alpha': return color.setAlpha(arg); - case 'hue': return color.spin(arg); - case 'saturate': return color.saturate(arg); + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } } } diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 6f2f08cb6d..e0bf135ef8 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -18,7 +18,7 @@ export type MenuAction = (ev: MouseEvent) => void; export interface MenuButton { type?: 'button'; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; indicate?: boolean; danger?: boolean; @@ -38,14 +38,14 @@ export interface MenuDivider extends MenuBase { export interface MenuLabel extends MenuBase { type: 'label'; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; } export interface MenuLink extends MenuBase { type: 'link'; to: string; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; indicate?: boolean; avatar?: Misskey.entities.User; @@ -57,7 +57,7 @@ export interface MenuA extends MenuBase { target?: string; download?: string; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; indicate?: boolean; } @@ -74,7 +74,7 @@ export interface MenuSwitch extends MenuBase { type: 'switch'; ref: Ref<boolean>; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; disabled?: boolean | Ref<boolean>; } @@ -82,7 +82,7 @@ export interface MenuSwitch extends MenuBase { export interface MenuRadio extends MenuBase { type: 'radio'; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>; options: MenuRadioOptionsDef; @@ -92,7 +92,7 @@ export interface MenuRadio extends MenuBase { export interface MenuRadioOption extends MenuBase { type: 'radioOption'; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; action: MenuAction; active?: boolean | ComputedRef<boolean>; } @@ -106,7 +106,7 @@ export interface MenuComponent<T extends Component = any> extends MenuBase { export interface MenuParent extends MenuBase { type: 'parent'; text: Text; - caption?: Text; + caption?: Text | null | undefined | ComputedRef<null | undefined>; icon?: string; children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]); } diff --git a/packages/frontend/src/types/post-form.ts b/packages/frontend/src/types/post-form.ts index 10e68d2d4a..90c8605f31 100644 --- a/packages/frontend/src/types/post-form.ts +++ b/packages/frontend/src/types/post-form.ts @@ -8,7 +8,14 @@ import * as Misskey from 'misskey-js'; export interface PostFormProps { reply?: Misskey.entities.Note | null; renote?: Misskey.entities.Note | null; - channel?: Misskey.entities.Channel | null; // TODO + channel?: { + id: string; + name: string; + color: string; + isSensitive: boolean; + allowRenoteToExternal: boolean; + userId: string | null; + } | null; mention?: Misskey.entities.User; specified?: Misskey.entities.UserDetailed; initialText?: string; diff --git a/packages/frontend/src/ui/_common_/PreferenceRestore.vue b/packages/frontend/src/ui/_common_/PreferenceRestore.vue index 5fd9f5e44b..b9d54cddc6 100644 --- a/packages/frontend/src/ui/_common_/PreferenceRestore.vue +++ b/packages/frontend/src/ui/_common_/PreferenceRestore.vue @@ -48,10 +48,6 @@ function skip() { .title { padding: 0 10px; font-weight: bold; - - &:empty { - display: none; - } } .body { diff --git a/packages/frontend/src/ui/_common_/ReloadSuggestion.vue b/packages/frontend/src/ui/_common_/ReloadSuggestion.vue new file mode 100644 index 0000000000..8fcfe0b12f --- /dev/null +++ b/packages/frontend/src/ui/_common_/ReloadSuggestion.vue @@ -0,0 +1,71 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <span :class="$style.icon"> + <i class="ti ti-info-circle"></i> + </span> + <span :class="$style.title">{{ i18n.ts.reloadRequiredToApplySettings }}</span> + <span :class="$style.body"><button class="_textButton" style="color: var(--MI_THEME-fgOnAccent);" @click="reload">{{ i18n.ts.reload }}</button> | <button class="_textButton" style="color: var(--MI_THEME-fgOnAccent);" @click="skip">{{ i18n.ts.skip }}</button></span> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; +import { shouldSuggestReload } from '@/utility/reload-suggest.js'; +import { unisonReload } from '@/utility/unison-reload.js'; + +function reload() { + unisonReload(); +} + +function skip() { + shouldSuggestReload.value = false; +} +</script> + +<style lang="scss" module> +.root { + --height: 24px; + font-size: 0.85em; + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + background: var(--MI_THEME-accent); + color: var(--MI_THEME-fgOnAccent); +} + +.icon { + margin-left: 10px; + animation: blink 2s infinite; +} + +.title { + padding: 0 10px; + font-weight: bold; + animation: blink 2s infinite; +} + +.body { + min-width: 0; + flex: 1; + overflow: clip; + white-space: nowrap; + text-overflow: ellipsis; +} + +@keyframes blink { + 0% { opacity: 1; } + 10% { opacity: 1; } + 50% { opacity: 0; } + 90% { opacity: 1; } + 100% { opacity: 1; } +} +</style> diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index f9af8e1ee7..c299e1b1c1 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div v-if="$i" :class="$style.root"> <MkA v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')" :key="announcement.id" diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 9872937d21..a9ad36c97a 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -12,7 +12,7 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; function toolsMenuItems(): MenuItem[] { - return [{ + const items: MenuItem[] = [{ type: 'link', to: '/scratchpad', text: i18n.ts.scratchpad, @@ -27,17 +27,27 @@ function toolsMenuItems(): MenuItem[] { to: '/clicker', text: '🍪👈', icon: 'ti ti-cookie', - }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { - type: 'link', - to: '/custom-emojis-manager', - text: i18n.ts.manageCustomEmojis, - icon: 'ti ti-icons', - } : undefined, ($i && ($i.isAdmin || $i.policies.canManageAvatarDecorations)) ? { - type: 'link', - to: '/avatar-decorations', - text: i18n.ts.manageAvatarDecorations, - icon: 'ti ti-sparkles', - } : undefined]; + }]; + + if ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) { + items.push({ + type: 'link', + to: '/custom-emojis-manager', + text: i18n.ts.manageCustomEmojis, + icon: 'ti ti-icons', + }); + } + + if ($i && ($i.isAdmin || $i.policies.canManageAvatarDecorations)) { + items.push({ + type: 'link' as const, + to: '/avatar-decorations', + text: i18n.ts.manageAvatarDecorations, + icon: 'ti ti-sparkles', + }); + } + + return items; } export function openInstanceMenu(ev: MouseEvent) { diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index da20d23cfd..37c95f2db2 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -94,6 +94,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="dev" id="devTicker"><span style="animation: dev-ticker-blink 2s infinite;">DEV BUILD</span></div> <div v-if="$i && $i.isBot" id="botWarn"><span style="animation: dev-ticker-blink 2s infinite;">{{ i18n.ts.loggedInAsBot }}</span></div> + +<div v-if="isSafeMode" id="safemodeWarn"> + <span style="animation: dev-ticker-blink 2s infinite;">{{ i18n.ts.safeModeEnabled }}</span> + <button class="_textButton" style="pointer-events: all;" @click="exitSafeMode">{{ i18n.ts.turnItOff }}</button> +</div> </template> <script lang="ts" setup> @@ -101,7 +106,10 @@ import { defineAsyncComponent, ref, TransitionGroup } from 'vue'; import * as Misskey from 'misskey-js'; import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; +import { isSafeMode } from '@@/js/config.js'; import { popups } from '@/os.js'; +import { unisonReload } from '@/utility/unison-reload.js'; +import { miLocalStorage } from '@/local-storage.js'; import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; import * as sound from '@/utility/sound.js'; import { $i } from '@/i.js'; @@ -144,6 +152,13 @@ function onNotification(notification: Misskey.entities.Notification, isClient = sound.playMisskeySfx('notification'); } +function exitSafeMode() { + miLocalStorage.removeItem('isSafeMode'); + const url = new URL(window.location.href); + url.searchParams.delete('safemode'); + unisonReload(url.toString()); +} + if ($i) { if (store.s.realtimeMode) { const connection = useStream().useChannel('main'); @@ -396,7 +411,7 @@ if ($i) { width: 100%; height: max-content; text-align: center; - z-index: 2147483647; + z-index: 2147483646; color: #ff0; background: rgba(0, 0, 0, 0.5); padding: 4px 7px; @@ -405,6 +420,11 @@ if ($i) { user-select: none; } +#safemodeWarn { + @extend #botWarn; + z-index: 2147483647; +} + #devTicker { position: fixed; bottom: 0; diff --git a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue index e2993230be..7ab8a45f51 100644 --- a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue +++ b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue @@ -86,7 +86,7 @@ watch(rootEl, () => { box-sizing: border-box; background: var(--MI_THEME-navBg); color: var(--MI_THEME-navFg); - box-shadow: 0px 0px 6px 6px #0000000f; + border-top: solid 0.5px var(--MI_THEME-divider); } .item { diff --git a/packages/frontend/src/ui/_common_/navbar-h.vue b/packages/frontend/src/ui/_common_/navbar-h.vue index 688e195ce6..a78bdd52d1 100644 --- a/packages/frontend/src/ui/_common_/navbar-h.vue +++ b/packages/frontend/src/ui/_common_/navbar-h.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="body"> <div class="left"> <button v-click-anime class="item _button instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" draggable="false"/> + <img :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/> </button> <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact> <i class="ti ti-home ti-fw"></i> @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> + <MkA v-if="$i && ($i.isAdmin || $i.isModerator)" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-dashboard ti-fw"></i> </MkA> <button v-click-anime class="item _button" @click="more"> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> <i class="ti ti-settings ti-fw"></i> </MkA> - <button v-click-anime class="item _button account" @click="openAccountMenu"> + <button v-if="$i" v-click-anime class="item _button account" @click="openAccountMenu"> <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/> </button> <div class="post" @click="os.post()"> @@ -57,6 +57,7 @@ import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; import { $i } from '@/i.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; const WINDOW_THRESHOLD = 1400; @@ -72,8 +73,11 @@ const otherNavItemIndicated = computed<boolean>(() => { }); async function more(ev: MouseEvent) { + const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target); + if (!target) return; + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), { - anchorElement: ev.currentTarget ?? ev.target, + anchorElement: target, anchor: { x: 'center', y: 'bottom' }, }, { closed: () => dispose(), diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 28913a54ab..4c43bf2b3b 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.body"> <div :class="$style.top"> <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> + <img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> <button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> <i class="ti ti-bolt ti-fw"></i> diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index 7248e8826b..079f1f92bb 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only mode="default" > <MkMarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> - <span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor : null }"> + <span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor ?? '' : '' }"> <img :class="$style.icon" :src="getInstanceIcon(instance)" alt=""/> <MkA :to="`/instance-info/${instance.host}`" :class="$style.host" class="_monospace"> {{ instance.host }} @@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ @@ -44,7 +44,7 @@ const props = defineProps<{ marqueeDuration?: number; marqueeReverse?: boolean; oneByOneInterval?: number; - refreshIntervalSec?: number; + refreshIntervalSec: number; }>(); const instances = ref<Misskey.entities.FederationInstance[]>([]); diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 7db0d5267d..202972ed46 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -29,18 +29,18 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { useInterval } from '@@/js/use-interval.js'; +import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { shuffle } from '@/utility/shuffle.js'; const props = defineProps<{ - url?: string; + url: string; shuffle?: boolean; display?: 'marquee' | 'oneByOne'; marqueeDuration?: number; marqueeReverse?: boolean; oneByOneInterval?: number; - refreshIntervalSec?: number; + refreshIntervalSec: number; }>(); const items = ref<Misskey.entities.FetchRssResponse['items']>([]); diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 13139a1064..8a754e6e64 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <MkMarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> <span v-for="note in notes" :key="note.id" :class="$style.item"> - <img :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/> + <img v-if="note.user.avatarUrl" :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/> <MkA :class="$style.text" :to="notePage(note)"> <Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true"/> </MkA> @@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import MkMarqueeText from '@/components/MkMarqueeText.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; @@ -45,7 +45,7 @@ const props = defineProps<{ marqueeDuration?: number; marqueeReverse?: boolean; oneByOneInterval?: number; - refreshIntervalSec?: number; + refreshIntervalSec: number; }>(); const notes = ref<Misskey.entities.Note[]>([]); diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index 1459881ba1..63918fbe2f 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -43,7 +43,7 @@ export function swInject() { if (mainRouter.currentRoute.value.path === ev.data.url) { return window.scroll({ top: 0, behavior: 'smooth' }); } - return mainRouter.push(ev.data.url); + return mainRouter.pushByPath(ev.data.url); default: return; } diff --git a/packages/frontend/src/ui/_common_/titlebar.vue b/packages/frontend/src/ui/_common_/titlebar.vue index c62b13b73a..1b9d47ec40 100644 --- a/packages/frontend/src/ui/_common_/titlebar.vue +++ b/packages/frontend/src/ui/_common_/titlebar.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.title"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> + <img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> <span :class="$style.instanceTitle">{{ instance.name ?? host }}</span> </div> <div :class="$style.controls"> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index d2b163a38f..9f6d8267f7 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -10,9 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.nonTitlebarArea"> <XSidebar v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'left'"/> - <div :class="[$style.main, { [$style.withWallpaper]: withWallpaper, [$style.withSidebarAndTitlebar]: !isMobile && prefer.r['deck.navbarPosition'].value === 'left' && prefer.r.showTitlebar.value }]" :style="{ backgroundImage: prefer.s['deck.wallpaper'] != null ? `url(${ prefer.s['deck.wallpaper'] })` : null }"> + <div :class="[$style.main, { [$style.withWallpaper]: withWallpaper, [$style.withSidebarAndTitlebar]: !isMobile && prefer.r['deck.navbarPosition'].value === 'left' && prefer.r.showTitlebar.value }]" :style="{ backgroundImage: prefer.s['deck.wallpaper'] != null ? `url(${ prefer.s['deck.wallpaper'] })` : '' }"> <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'top'"/> + <XReloadSuggestion v-if="shouldSuggestReload"/> + <XPreferenceRestore v-if="shouldSuggestRestoreBackup"/> <XAnnouncements v-if="$i"/> <XStatusBars/> <div :class="$style.columnsWrapper"> @@ -81,12 +83,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref, useTemplateRef } from 'vue'; -import { genId } from '@/utility/id.js'; import XCommon from './_common_/common.vue'; +import { genId } from '@/utility/id.js'; import XSidebar from '@/ui/_common_/navbar.vue'; import XNavbarH from '@/ui/_common_/navbar-h.vue'; import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import XTitlebar from '@/ui/_common_/titlebar.vue'; +import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; +import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue'; import * as os from '@/os.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; @@ -105,6 +109,8 @@ import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XChatColumn from '@/ui/deck/chat-column.vue'; import { mainRouter } from '@/router.js'; import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; +import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; +import { shouldSuggestReload } from '@/utility/reload-suggest.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 8de894ee88..0042882728 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span> + <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.antenna }}</span> </template> <MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/> @@ -35,18 +35,13 @@ const props = defineProps<{ const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); -const antennaName = ref<string | null>(null); onMounted(() => { if (props.column.antennaId == null) { setAntenna(); - } -}); - -watch([() => props.column.name, () => props.column.antennaId], () => { - if (!props.column.name && props.column.antennaId) { + } else if (props.column.timelineNameCache == null) { misskeyApi('antennas/show', { antennaId: props.column.antennaId }) - .then(value => antennaName.value = value.name); + .then(value => updateColumn(props.column.id, { timelineNameCache: value.name })); } }); @@ -77,6 +72,7 @@ async function setAntenna() { antennasCache.delete(); updateColumn(props.column.id, { antennaId: newAntenna.id, + timelineNameCache: newAntenna.name, }); }, closed: () => { @@ -88,6 +84,7 @@ async function setAntenna() { updateColumn(props.column.id, { antennaId: antenna.id, + timelineNameCache: antenna.name, }); } diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 3439a2a56e..c02499e2d7 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || channel?.name || i18n.ts._deck._columns.channel }}</span> + <i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.channel }}</span> </template> <template v-if="column.channelId"> @@ -46,13 +46,9 @@ const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, onMounted(() => { if (props.column.channelId == null) { setChannel(); - } -}); - -watch([() => props.column.name, () => props.column.channelId], () => { - if (!props.column.name && props.column.channelId) { + } else if (!props.column.name && props.column.channelId) { misskeyApi('channels/show', { channelId: props.column.channelId }) - .then(value => channel.value = value); + .then(value => updateColumn(props.column.id, { timelineNameCache: value.name })); } }); @@ -72,7 +68,7 @@ async function setChannel() { if (canceled || chosenChannel == null) return; updateColumn(props.column.id, { channelId: chosenChannel.id, - name: chosenChannel.name, + timelineNameCache: chosenChannel.name, }); } diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 5b7390b1b2..5c5891ece8 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span> + <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.list }}</span> </template> <MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/> @@ -36,18 +36,13 @@ const props = defineProps<{ const timeline = useTemplateRef('timeline'); const withRenotes = ref(props.column.withRenotes ?? true); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); -const listName = ref<string | null>(null); onMounted(() => { if (props.column.listId == null) { setList(); - } -}); - -watch([() => props.column.name, () => props.column.listId], () => { - if (!props.column.name && props.column.listId) { + } else if (props.column.timelineNameCache == null) { misskeyApi('users/lists/show', { listId: props.column.listId }) - .then(value => listName.value = value.name); + .then(value => updateColumn(props.column.id, { timelineNameCache: value.name })); } }); @@ -89,10 +84,12 @@ async function setList() { updateColumn(props.column.id, { listId: res.id, + timelineNameCache: res.name, }); } else { updateColumn(props.column.id, { listId: list.id, + timelineNameCache: list.name, }); } } diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index ff00dfa6e0..0aafeb56d7 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }"> <template #header> - <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span> + <i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.roleTimeline }}</span> </template> <MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> @@ -33,18 +33,13 @@ const props = defineProps<{ const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); -const roleName = ref<string | null>(null); onMounted(() => { if (props.column.roleId == null) { setRole(); - } -}); - -watch([() => props.column.name, () => props.column.roleId], () => { - if (!props.column.name && props.column.roleId) { + } else if (props.column.timelineNameCache == null) { misskeyApi('roles/show', { roleId: props.column.roleId }) - .then(value => roleName.value = value.name); + .then(value => updateColumn(props.column.id, { timelineNameCache: value.name })); } }); @@ -64,6 +59,7 @@ async function setRole() { if (canceled || role == null) return; updateColumn(props.column.id, { roleId: role.id, + timelineNameCache: role.name, }); } diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index f8793d7c75..727fe08989 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="[$style.contents, !isMobile && prefer.r.showTitlebar.value ? $style.withSidebarAndTitlebar : null]" @contextmenu.stop="onContextmenu"> <div> + <XReloadSuggestion v-if="shouldSuggestReload"/> <XPreferenceRestore v-if="shouldSuggestRestoreBackup"/> <XAnnouncements v-if="$i"/> <XStatusBars :class="$style.statusbars"/> @@ -38,6 +39,7 @@ import XCommon from './_common_/common.vue'; import type { PageMetadata } from '@/page.js'; import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; +import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue'; import XTitlebar from '@/ui/_common_/titlebar.vue'; import XSidebar from '@/ui/_common_/navbar.vue'; import * as os from '@/os.js'; @@ -50,6 +52,7 @@ import { mainRouter } from '@/router.js'; import { prefer } from '@/preferences.js'; import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; import { DI } from '@/di.js'; +import { shouldSuggestReload } from '@/utility/reload-suggest.js'; const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); diff --git a/packages/frontend/src/unicode-emoji-indexes/en-US.json b/packages/frontend/src/unicode-emoji-indexes/en-US.json index 4d8b040ad2..bd8271b911 100644 --- a/packages/frontend/src/unicode-emoji-indexes/en-US.json +++ b/packages/frontend/src/unicode-emoji-indexes/en-US.json @@ -44,6 +44,8 @@ "😑": ["face", "indifferent", "-_-", "meh", "deadpan"], "😒": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"], "🙄": ["face", "eyeroll", "frustrated"], + "🙂↔️": ["face", "head", "horizontally", "no", "shake", "shaking"], + "🙂↕️": ["face", "head", "nod", "shaking", "vertically", "yes"], "🤔": ["face", "hmmm", "think", "consider"], "🤥": ["face", "lie", "pinocchio"], "🤭": ["face", "whoops", "shock", "surprise"], @@ -76,6 +78,7 @@ "😥": ["face", "phew", "sweat", "nervous"], "🤤": ["face"], "😪": ["face", "tired", "rest", "nap"], + "": ["face", "bags", "bored", "exhausted", "eyes", "fatigued", "late", "sleepy", "tired", "weary"], "😓": ["face", "hot", "sad", "tired", "exercise"], "🥵": ["face", "feverish", "heat", "red", "sweating"], "🥶": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"], @@ -92,10 +95,11 @@ "🥴": ["face", "dizzy", "intoxicated", "tipsy", "wavy"], "🥱": ["face", "tired", "yawning"], "😴": ["face", "tired", "sleepy", "night", "zzz"], + "👁️🗨️": ["balloon", "bubble", "eye", "speech", "witness"], "💤": ["sleepy", "tired", "dream"], - "😶🌫️": [], - "😮💨": [], - "😵💫": [], + "😶🌫️": ["face", "absentminded", "clouds", "fog", "head"], + "😮💨": ["face", "blow", "blowing", "exhale", "exhaling", "exhausted", "gasp", "groan", "relief", "sigh", "smiley", "smoke", "whisper", "whistle"], + "😵💫": ["face", "confused", "dizzy", "eyes", "hypnotized", "omg", "smiley", "spiral", "trouble", "whoa", "woah", "woozy"], "🫠": ["disappear", "dissolve", "liquid", "melt", "toketa"], "🫢": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"], "🫣": ["captivated", "peep", "stare", "chunibyo"], @@ -159,19 +163,19 @@ "🤞": ["good", "lucky"], "🖖": ["hand", "fingers", "spock", "star trek"], "✍": ["lower_left_ballpoint_pen", "stationery", "write", "compose"], - "🫰": [], - "🫱": [], - "🫲": [], - "🫳": [], - "🫴": [], - "🫵": [], + "🫰": ["<3", "crossed", "expensive", "finger", "hand", "heart", "index", "love", "money", "snap", "thumb"], + "🫱": ["hand", "handshake", "hold", "reach", "right", "rightward", "rightwards", "shake"], + "🫲": ["hand", "handshake", "hold", "left", "leftward", "leftwards", "reach", "shake"], + "🫳": ["dismiss", "down", "drop", "dropped", "hand", "palm", "pick", "shoo", "up"], + "🫴": ["beckon", "catch", "come", "hand", "hold", "know", "lift", "me", "offer", "palm", "tell"], + "🫵": ["at", "finger", "hand", "index", "pointing", "poke", "viewer", "you"], "🫶": ["moemoekyun"], "🤏": ["hand", "fingers"], "🤌": ["hand", "fingers"], "🤳": ["camera", "phone"], "💅": ["beauty", "manicure", "finger", "fashion", "nail"], "👄": ["mouth", "kiss"], - "🫦": [], + "🫦": ["anxious", "bite", "biting", "fear", "flirt", "flirting", "kiss", "lip", "lipstick", "nervous", "sexy", "uncomfortable", "worried", "worry"], "🦷": ["teeth", "dentist"], "👅": ["mouth", "playful"], "👂": ["face", "hear", "sound", "listen"], @@ -180,11 +184,12 @@ "👁": ["face", "look", "see", "watch", "stare"], "👀": ["look", "watch", "stalk", "peek", "see"], "🧠": ["smart", "intelligent"], - "🫀": [], - "🫁": [], + "🫀": ["anatomical", "beat", "cardiology", "heart", "heartbeat", "organ", "pulse", "real", "red"], + "🫁": ["breath", "breathe", "exhalation", "inhalation", "lung", "lungs", "organ", "respiration"], "👤": ["user", "person", "human"], "👥": ["user", "person", "human", "group", "team"], "🗣": ["user", "person", "human", "sing", "say", "talk"], + "": ["clue", "crime", "detective", "fingerprint", "forensics", "identity", "mystery", "print", "safety", "trace"], "👶": ["child", "boy", "girl", "toddler"], "🧒": ["gender-neutral", "young"], "👦": ["man", "male", "guy", "teenager"], @@ -199,6 +204,7 @@ "👩🦰": ["woman", "female", "girl", "ginger", "redhead"], "👨🦰": ["man", "male", "boy", "guy", "ginger", "redhead"], "👱♀️": ["woman", "female", "girl", "blonde", "person"], + "👱♂️": ["blond", "blond-haired", "hair", "man"], "👱": ["man", "male", "boy", "blonde", "guy", "person"], "🧑🦳": ["gray", "old", "white"], "👩🦳": ["woman", "female", "girl", "gray", "old", "white"], @@ -207,20 +213,27 @@ "👩🦲": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"], "👨🦲": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"], "🧔": ["person", "bewhiskered"], + "🧔♀️": ["beard", "bearded", "whiskers", "woman"], + "🧔♂️": ["beard", "bearded", "man", "whiskers"], "🧓": ["human", "elder", "senior", "gender-neutral"], "👴": ["human", "male", "men", "old", "elder", "senior"], "👵": ["human", "female", "women", "lady", "old", "elder", "senior"], "👲": ["male", "boy", "chinese"], "🧕": ["female", "hijab", "mantilla", "tichel"], "👳♀️": ["female", "indian", "hinduism", "arabs", "woman"], + "👳♂️": ["man", "turban", "wearing"], "👳": ["male", "indian", "hinduism", "arabs"], "👮♀️": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"], + "👮♂️": ["apprehend", "arrest", "citation", "cop", "law", "man", "officer", "over", "police", "pulled", "undercover"], "👮": ["man", "police", "law", "legal", "enforcement", "arrest", "911"], "👷♀️": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"], + "👷♂️": ["build", "construction", "fix", "hardhat", "hat", "man", "rebuild", "remodel", "repair", "work", "worker"], "👷": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"], "💂♀️": ["uk", "gb", "british", "female", "royal", "woman"], + "💂♂️": ["buckingham", "guard", "helmet", "london", "man", "palace"], "💂": ["uk", "gb", "british", "male", "guy", "royal"], "🕵️♀️": ["human", "spy", "detective", "female", "woman"], + "🕵️♂️": ["detective", "man", "sleuth", "spy"], "🕵": ["human", "spy", "detective"], "🧑⚕️": ["doctor", "nurse", "therapist", "healthcare", "human"], "👩⚕️": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"], @@ -270,102 +283,152 @@ "🧑⚖️": ["justice", "court", "human"], "👩⚖️": ["justice", "court", "woman", "human"], "👨⚖️": ["justice", "court", "man", "human"], + "🦸": ["good", "hero", "superhero", "superpower"], "🦸♀️": ["woman", "female", "good", "heroine", "superpowers"], "🦸♂️": ["man", "male", "good", "hero", "superpowers"], + "🦹": ["bad", "criminal", "evil", "superpower", "supervillain", "villain"], "🦹♀️": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"], "🦹♂️": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"], "🤶": ["woman", "female", "xmas", "mother christmas"], "🧑🎄": ["xmas", "christmas"], "🎅": ["festival", "man", "male", "xmas", "father christmas"], - "🥷": [], + "🥷": ["assassin", "fight", "fighter", "hidden", "ninja", "person", "secret", "skills", "sly", "soldier", "stealth", "war"], + "🧙": ["fantasy", "mage", "magic", "play", "sorcerer", "sorceress", "sorcery", "spell", "summon", "witch", "wizard"], "🧙♀️": ["woman", "female", "mage", "witch"], "🧙♂️": ["man", "male", "mage", "sorcerer"], + "🧝": ["elf", "elves", "enchantment", "fantasy", "folklore", "magic", "magical", "myth"], "🧝♀️": ["woman", "female"], "🧝♂️": ["man", "male"], + "🧛": ["blood", "dracula", "fangs", "halloween", "scary", "supernatural", "teeth", "undead", "vampire"], "🧛♀️": ["woman", "female"], "🧛♂️": ["man", "male", "dracula"], + "🧟": ["apocalypse", "dead", "halloween", "horror", "scary", "undead", "walking", "zombie"], "🧟♀️": ["woman", "female", "undead", "walking dead"], "🧟♂️": ["man", "male", "dracula", "undead", "walking dead"], + "🧞": ["djinn", "fantasy", "genie", "jinn", "lamp", "myth", "rub", "wishes"], "🧞♀️": ["woman", "female"], "🧞♂️": ["man", "male"], + "🧜": ["creature", "fairytale", "folklore", "merperson", "ocean", "sea", "siren", "trident"], "🧜♀️": ["woman", "female", "merwoman", "ariel"], "🧜♂️": ["man", "male", "triton"], + "🧚": ["fairy", "fairytale", "fantasy", "myth", "person", "pixie", "tale", "wings"], "🧚♀️": ["woman", "female"], "🧚♂️": ["man", "male"], "👼": ["heaven", "wings", "halo"], - "🧌": [], + "🧌": ["fairy", "fantasy", "monster", "tale", "troll", "trolling"], "🤰": ["baby"], - "🫃": [], - "🫄": [], - "🫅": [], + "🫃": ["belly", "bloated", "full", "man", "overeat", "pregnant"], + "🫄": ["belly", "bloated", "full", "overeat", "person", "pregnant", "stuffed"], + "🫅": ["crown", "monarch", "noble", "person", "regal", "royal", "royalty"], "🤱": ["nursing", "baby"], - "👩🍼": [], - "👨🍼": [], - "🧑🍼": [], + "👩🍼": ["baby", "feed", "feeding", "mom", "mother", "nanny", "newborn", "nursing", "woman"], + "👨🍼": ["baby", "dad", "father", "feed", "feeding", "man", "nanny", "newborn", "nursing"], + "🧑🍼": ["baby", "feed", "feeding", "nanny", "newborn", "nursing", "parent"], "👸": ["girl", "woman", "female", "blond", "crown", "royal", "queen"], "🤴": ["boy", "man", "male", "crown", "royal", "king"], "👰": ["couple", "marriage", "wedding", "woman", "bride"], - "👰": ["couple", "marriage", "wedding", "woman", "bride"], - "🤵": ["couple", "marriage", "wedding", "groom"], + "👰♀️": ["bride", "veil", "wedding", "woman"], + "👰♂️": ["man", "veil", "wedding"], "🤵": ["couple", "marriage", "wedding", "groom"], + "🤵♀️": ["formal", "tuxedo", "wedding", "woman"], + "🤵♂️": ["formal", "groom", "man", "tuxedo", "wedding"], + "🏃➡️": ["facing", "fast", "hurry", "marathon", "move", "person", "quick", "race", "racing", "right", "run", "rush", "speed"], "🏃♀️": ["woman", "walking", "exercise", "race", "running", "female"], "🏃": ["man", "walking", "exercise", "race", "running"], + "🏃♀️➡️": ["facing", "fast", "hurry", "marathon", "move", "quick", "race", "racing", "right", "run", "rush", "speed", "woman"], + "🏃♂️": ["fast", "hurry", "man", "marathon", "move", "quick", "race", "racing", "run", "rush", "speed"], + "🏃♂️➡️": ["facing", "fast", "hurry", "man", "marathon", "move", "quick", "race", "racing", "right", "run", "rush", "speed"], + "🚶➡️": ["amble", "facing", "gait", "hike", "man", "pace", "pedestrian", "person", "right", "stride", "stroll", "walk", "walking"], "🚶♀️": ["human", "feet", "steps", "woman", "female"], "🚶": ["human", "feet", "steps"], + "🚶♀️➡️": ["amble", "facing", "gait", "hike", "man", "pace", "pedestrian", "right", "stride", "stroll", "walk", "walking", "woman"], + "🚶♂️": ["amble", "gait", "hike", "man", "pace", "pedestrian", "stride", "stroll", "walk", "walking"], + "🚶♂️➡️": ["amble", "facing", "gait", "hike", "man", "pace", "pedestrian", "right", "stride", "stroll", "walk", "walking"], "💃": ["female", "girl", "woman", "fun"], "🕺": ["male", "boy", "fun", "dancer"], "👯": ["female", "bunny", "women", "girls"], + "👯♀️": ["bestie", "bff", "bunny", "counterpart", "dancer", "double", "ear", "identical", "pair", "party", "partying", "people", "soulmate", "twin", "twinsies", "women"], "👯♂️": ["male", "bunny", "men", "boys"], "👫": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"], "🧑🤝🧑": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"], "👬": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"], "👭": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"], - "🫂": [], + "🫂": ["comfort", "embrace", "farewell", "friendship", "goodbye", "hello", "hug", "hugging", "love", "people", "thanks"], "🙇♀️": ["woman", "female", "girl"], "🙇": ["man", "male", "boy"], + "🙇♂️": ["apology", "ask", "beg", "bow", "bowing", "favor", "forgive", "gesture", "man", "meditate", "meditation", "pity", "regret", "sorry"], + "🤦": ["again", "bewilder", "disbelief", "exasperation", "facepalm", "no", "not", "oh", "omg", "person", "shock", "smh"], "🤦♂️": ["man", "male", "boy", "disbelief"], "🤦♀️": ["woman", "female", "girl", "disbelief"], "🤷": ["woman", "female", "girl", "confused", "indifferent", "doubt"], + "🤷♀️": ["doubt", "dunno", "guess", "idk", "ignorance", "indifference", "knows", "maybe", "shrug", "shrugging", "whatever", "who", "woman"], "🤷♂️": ["man", "male", "boy", "confused", "indifferent", "doubt"], "💁": ["female", "girl", "woman", "human", "information"], + "💁♀️": ["fetch", "flick", "flip", "gossip", "hand", "sarcasm", "sarcastic", "sassy", "seriously", "tipping", "whatever", "woman"], "💁♂️": ["male", "boy", "man", "human", "information"], "🙅": ["female", "girl", "woman", "nope"], + "🙅♀️": ["forbidden", "gesture", "hand", "no", "not", "prohibit", "woman"], "🙅♂️": ["male", "boy", "man", "nope"], "🙆": ["women", "girl", "female", "pink", "human", "woman"], + "🙆♀️": ["exercise", "gesture", "gesturing", "hand", "ok", "omg", "woman"], "🙆♂️": ["men", "boy", "male", "blue", "human", "man"], "🙋": ["female", "girl", "woman"], + "🙋♀️": ["gesture", "hand", "here", "know", "me", "pick", "question", "raise", "raising", "woman"], "🙋♂️": ["male", "boy", "man"], "🙎": ["female", "girl", "woman"], + "🙎♀️": ["disappointed", "downtrodden", "frown", "grimace", "pouting", "scowl", "sulk", "upset", "whine", "woman"], "🙎♂️": ["male", "boy", "man"], "🙍": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"], + "🙍♀️": ["annoyed", "disappointed", "disgruntled", "disturbed", "frown", "frowning", "frustrated", "gesture", "irritated", "upset", "woman"], "🙍♂️": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"], "💇": ["female", "girl", "woman"], + "💇♀️": ["barber", "beauty", "chop", "cosmetology", "cut", "groom", "hair", "haircut", "parlor", "person", "shears", "style", "woman"], "💇♂️": ["male", "boy", "man"], "💆": ["female", "girl", "woman", "head"], + "💆♀️": ["face", "getting", "headache", "massage", "relax", "relaxing", "salon", "soothe", "spa", "tension", "therapy", "treatment", "woman"], "💆♂️": ["male", "boy", "man", "head"], + "🧖": ["day", "luxurious", "pamper", "person", "relax", "room", "sauna", "spa", "steam", "steambath", "unwind"], "🧖♀️": ["female", "woman", "spa", "steamroom", "sauna"], "🧖♂️": ["male", "man", "spa", "steamroom", "sauna"], + "🧏": ["accessibility", "deaf", "ear", "gesture", "hear", "person"], "🧏♀️": ["woman", "female"], "🧏♂️": ["man", "male"], + "🧍": ["person", "stand", "standing"], "🧍♀️": ["woman", "female"], "🧍♂️": ["man", "male"], + "🧎": ["kneel", "kneeling", "knees", "person"], + "🧎➡️": ["facing", "kneel", "kneeling", "knees", "person", "right"], "🧎♀️": ["woman", "female"], + "🧎♀️➡️": ["facing", "kneel", "kneeling", "knees", "right", "woman"], "🧎♂️": ["man", "male"], + "🧎♂️➡️": ["facing", "kneel", "kneeling", "knees", "man", "right"], "🧑🦯": ["accessibility", "blind"], + "🧑🦯➡️": ["accessibility", "blind", "cane", "facing", "person", "probing", "right", "white"], "👩🦯": ["woman", "female", "accessibility", "blind"], + "👩🦯➡️": ["accessibility", "blind", "cane", "facing", "probing", "right", "white", "woman"], "👨🦯": ["man", "male", "accessibility", "blind"], + "👨🦯➡️": ["accessibility", "blind", "cane", "facing", "man", "probing", "right", "white"], "🧑🦼": ["accessibility"], + "🧑🦼➡️": ["accessibility", "facing", "motorized", "person", "right", "wheelchair"], "👩🦼": ["woman", "female", "accessibility"], + "👩🦼➡️": ["accessibility", "facing", "motorized", "right", "wheelchair", "woman"], "👨🦼": ["man", "male", "accessibility"], + "👨🦼➡️": ["accessibility", "facing", "man", "motorized", "right", "wheelchair"], "🧑🦽": ["accessibility"], + "🧑🦽➡️": ["accessibility", "facing", "manual", "person", "right", "wheelchair"], "👩🦽": ["woman", "female", "accessibility"], + "👩🦽➡️": ["accessibility", "facing", "manual", "right", "wheelchair", "woman"], "👨🦽": ["man", "male", "accessibility"], + "👨🦽➡️": ["accessibility", "facing", "man", "manual", "right", "wheelchair"], "💑": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], + "👩❤️👨": ["anniversary", "babe", "bae", "couple", "dating", "heart", "kiss", "love", "man", "person", "relationship", "romance", "together", "woman", "you"], "👩❤️👩": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], "👨❤️👨": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"], "💏": ["pair", "valentines", "love", "like", "dating", "marriage"], + "👩❤️💋👨": ["anniversary", "babe", "bae", "couple", "date", "dating", "heart", "kiss", "love", "man", "mwah", "person", "romance", "together", "woman", "xoxo"], "👩❤️💋👩": ["pair", "valentines", "love", "like", "dating", "marriage"], "👨❤️💋👨": ["pair", "valentines", "love", "like", "dating", "marriage"], + "👨👩👦": ["boy", "child", "family", "man", "woman"], "👪": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"], "👨👩👧": ["home", "parents", "people", "human", "child"], "👨👩👧👦": ["home", "parents", "people", "human", "children"], @@ -391,6 +454,10 @@ "👨👧👦": ["home", "parent", "people", "human", "children"], "👨👦👦": ["home", "parent", "people", "human", "children"], "👨👧👧": ["home", "parent", "people", "human", "children"], + "🧑🧑🧒": ["adult", "child", "family"], + "🧑🧑🧒🧒": ["adult", "child", "family"], + "🧑🧒": ["adult", "child", "family"], + "🧑🧒🧒": ["adult", "child", "family"], "🧶": ["ball", "crochet", "knit"], "🧵": ["needle", "sewing", "spool", "string"], "🧥": ["jacket"], @@ -415,7 +482,7 @@ "👢": ["shoes", "fashion"], "👞": ["fashion", "male"], "👟": ["shoes", "sports", "sneakers"], - "🩴": [], + "🩴": ["beach", "flip", "flop", "sandal", "sandals", "shoe", "thong", "thongs", "zōri"], "🩰": ["shoes", "sports"], "🧦": ["stockings", "clothes"], "🧤": ["hands", "winter", "clothes"], @@ -424,7 +491,7 @@ "🎩": ["magic", "gentleman", "classy", "circus"], "🧢": ["cap", "baseball"], "⛑": ["construction", "build"], - "🪖": [], + "🪖": ["army", "helmet", "military", "soldier", "war", "warrior"], "🎓": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"], "👑": ["king", "kod", "leader", "royalty", "lord"], "🎒": ["student", "education", "bag", "backpack"], @@ -475,6 +542,7 @@ "🐦⬛": ["animal", "nature", "bird", "black", "crow", "raven", "rook"], "🦅": ["animal", "nature", "bird"], "🦉": ["animal", "nature", "bird", "hoot"], + "🐦🔥": ["animal", "nature", "ascend", "ascension", "emerge", "fantasy", "firebird", "glory", "immortal", "phoenix", "rebirth", "reincarnation", "reinvent", "renewal", "revival", "revive", "rise", "transform"], "🦇": ["animal", "nature", "blind", "vampire"], "🐺": ["animal", "nature", "wild"], "🐗": ["animal", "nature"], @@ -575,6 +643,7 @@ "🌿": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"], "☘": ["vegetable", "plant", "nature", "irish", "clover"], "🍀": ["vegetable", "plant", "nature", "lucky", "irish"], + "": ["plant", "nature", "bare", "barren", "branches", "dead", "drought", "leafless", "tree", "trunk", "winter", "wood"], "🎍": ["plant", "nature", "vegetable", "panda", "pine_decoration"], "🎋": ["plant", "nature", "branch", "summer"], "🍃": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"], @@ -644,10 +713,11 @@ "💧": ["water", "drip", "faucet", "spring"], "💦": ["water", "drip", "oops"], "🌊": ["sea", "water", "wave", "nature", "tsunami", "disaster"], - "🪷": [], - "🪸": [], - "🪹": [], - "🪺": [], + "🪷": ["plant", "nature", "beauty", "buddhism", "calm", "flower", "hinduism", "lotus", "peace", "purity", "serenity"], + "🪸": ["animal", "nature", "change", "climate", "coral", "ocean", "reef", "sea"], + "🪹": ["plant", "nature", "branch", "empty", "home", "nest", "nesting"], + "🪺": ["plant", "nature", "bird", "branch", "egg", "eggs", "nest", "nesting"], + "🍋🟩": ["acidity", "citrus", "cocktail", "fruit", "garnish", "key", "lime", "margarita", "mojito", "refreshing", "salsa", "sour", "tangy", "tequila", "tropical", "zest"], "🍏": ["fruit", "nature"], "🍎": ["fruit", "mac", "school"], "🍐": ["fruit", "nature", "food"], @@ -667,6 +737,8 @@ "🥑": ["fruit", "food"], "🫛": ["beans", "edamame", "legume", "pea", "pod", "vegetable", "food"], "🥦": ["fruit", "food", "vegetable"], + "🍄🟫": ["food", "fungi", "fungus", "mushroom", "nature", "pizza", "portobello", "shiitake", "shroom", "spore", "sprout", "toppings", "truffle", "vegetable", "vegetarian", "veggie"], + "": ["beet", "food", "garden", "radish", "root", "salad", "turnip", "vegetable", "vegetarian"], "🍅": ["fruit", "vegetable", "nature", "food"], "🍆": ["vegetable", "nature", "food", "aubergine"], "🥒": ["fruit", "food", "pickle"], @@ -759,7 +831,7 @@ "🍵": ["drink", "bowl", "breakfast", "green", "british"], "🥤": ["drink", "soda"], "☕": ["beverage", "caffeine", "latte", "espresso"], - "🫖": [], + "🫖": ["brew", "drink", "food", "pot", "tea", "teapot"], "🧋": ["tapioca"], "🍼": ["food", "container", "milk"], "🧃": ["food", "drink"], @@ -772,9 +844,9 @@ "🥣": ["food", "breakfast", "cereal", "oatmeal", "porridge"], "🥡": ["food", "leftovers"], "🥢": ["food"], - "🫗": [], - "🫘": [], - "🫙": [], + "🫗": ["accident", "drink", "empty", "glass", "liquid", "oops", "pour", "pouring", "spill", "water"], + "🫘": ["beans", "food", "kidney", "legume", "small"], + "🫙": ["condiment", "container", "empty", "jar", "nothing", "sauce", "store"], "⚽": ["sports", "football"], "🏀": ["sports", "balls", "NBA"], "🏈": ["sports", "balls", "NFL"], @@ -788,6 +860,7 @@ "⛳": ["sports", "business", "flag", "hole", "summer"], "🏌️♀️": ["sports", "business", "woman", "female"], "🏌": ["sports", "business"], + "🏌️♂️": ["sport", "ball", "birdie", "caddy", "driving", "golf", "golfing", "green", "man", "pga", "putt", "range", "tee"], "🏓": ["sports", "pingpong"], "🏸": ["sports"], "🥅": ["sports"], @@ -799,10 +872,13 @@ "⛷": ["sports", "winter", "snow"], "🏂": ["sports", "winter"], "🤺": ["sports", "fencing", "sword"], + "🤼": ["sport", "combat", "duel", "grapple", "people", "ring", "tournament", "wrestle", "wrestling"], "🤼♀️": ["sports", "wrestlers"], "🤼♂️": ["sports", "wrestlers"], + "🤸": ["sport", "active", "cartwheel", "cartwheeling", "excited", "flip", "gymnastics", "happy", "person", "somersault"], "🤸♀️": ["gymnastics"], "🤸♂️": ["gymnastics"], + "🤾": ["sport", "athletics", "ball", "catch", "chuck", "handball", "hurl", "lob", "person", "pitch", "playing", "throw", "toss"], "🤾♀️": ["sports"], "🤾♂️": ["sports"], "⛸": ["sports"], @@ -815,32 +891,42 @@ "🥋": ["judo", "karate", "taekwondo"], "🚣♀️": ["sports", "hobby", "water", "ship", "woman", "female"], "🚣": ["sports", "hobby", "water", "ship"], + "🚣♂️": ["sport", "boat", "canoe", "cruise", "fishing", "lake", "man", "oar", "paddle", "raft", "river", "row", "rowboat", "rowing"], + "🧗": ["climb", "climber", "climbing", "mountain", "person", "rock", "scale", "up"], "🧗♀️": ["sports", "hobby", "woman", "female", "rock"], "🧗♂️": ["sports", "hobby", "man", "male", "rock"], "🏊♀️": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"], "🏊": ["sports", "exercise", "human", "athlete", "water", "summer"], + "🏊♂️": ["sport", "freestyle", "man", "swim", "swimmer", "swimming", "triathlon"], + "🤽": ["sport", "person", "playing", "polo", "swimming", "water", "waterpolo"], "🤽♀️": ["sports", "pool"], "🤽♂️": ["sports", "pool"], + "🧘": ["cross", "legged", "legs", "lotus", "meditation", "peace", "person", "position", "relax", "serenity", "yoga", "yogi", "zen"], "🧘♀️": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"], "🧘♂️": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"], "🏄♀️": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"], "🏄": ["sports", "ocean", "sea", "summer", "beach"], + "🏄♂️": ["sport", "beach", "man", "ocean", "surf", "surfer", "surfing", "swell", "waves"], "🛀": ["clean", "shower", "bathroom"], "⛹️♀️": ["sports", "human", "woman", "female"], "⛹": ["sports", "human"], + "⛹️♂️": ["sport", "athletic", "ball", "basketball", "bouncing", "championship", "dribble", "man", "net", "player", "throw"], "🏋️♀️": ["sports", "training", "exercise", "woman", "female"], "🏋": ["sports", "training", "exercise"], + "🏋️♂️": ["sport", "barbell", "bodybuilder", "deadlift", "lifter", "lifting", "man", "powerlifting", "weight", "weightlifter", "weights", "workout"], "🚴♀️": ["sports", "bike", "exercise", "hipster", "woman", "female"], "🚴": ["sports", "bike", "exercise", "hipster"], + "🚴♂️": ["sport", "bicycle", "bicyclist", "bike", "biking", "cycle", "cyclist", "man", "riding"], "🚵♀️": ["transportation", "sports", "human", "race", "bike", "woman", "female"], "🚵": ["transportation", "sports", "human", "race", "bike"], + "🚵♂️": ["sport", "bicycle", "bicyclist", "bike", "biking", "cycle", "cyclist", "man", "mountain", "riding"], "🏇": ["animal", "betting", "competition", "gambling", "luck"], "🤿": ["sports"], "🪀": ["sports"], "🪁": ["sports"], "🦺": ["sports"], - "🪡": [], - "🪢": [], + "🪡": ["embroidery", "needle", "sew", "sewing", "stitches", "sutures", "tailoring", "thread"], + "🪢": ["cord", "knot", "rope", "tangled", "tie", "twine", "twist"], "🕴": ["suit", "business", "levitate", "hover", "jump"], "🏆": ["win", "award", "contest", "place", "ftw", "ceremony"], "🎽": ["play", "pageant"], @@ -856,6 +942,7 @@ "🎭": ["acting", "theater", "drama"], "🎨": ["design", "paint", "draw", "colors"], "🎪": ["festival", "carnival", "party"], + "🤹": ["sport", "act", "balance", "balancing", "handle", "juggle", "juggling", "manage", "multitask", "person", "skill"], "🤹♀️": ["juggle", "balance", "skill", "multitask"], "🤹♂️": ["juggle", "balance", "skill", "multitask"], "🎤": ["sound", "music", "PA", "sing", "talkshow"], @@ -872,6 +959,7 @@ "🪕": ["music", "instrument"], "🪗": ["music", "instrument"], "🪘": ["music", "instrument"], + "": ["cupid", "harp", "instrument", "love", "music", "orchestra"], "🎬": ["movie", "film", "record"], "🎮": ["play", "console", "PS4", "controller"], "👾": ["game", "arcade", "play"], @@ -881,11 +969,11 @@ "🎰": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"], "🧩": ["interlocking", "puzzle", "piece"], "🎳": ["sports", "fun", "play"], - "🪄": [], - "🪅": [], - "🪆": [], - "🪬": [], - "🪩": [], + "🪄": ["magic", "magician", "wand", "witch", "wizard"], + "🪅": ["candy", "celebrate", "celebration", "cinco", "de", "festive", "mayo", "party", "pinada", "pinata", "piñata"], + "🪆": ["babooshka", "baboushka", "babushka", "doll", "dolls", "matryoshka", "nesting", "russia"], + "🪬": ["amulet", "fatima", "fortune", "guide", "hamsa", "hand", "mary", "miriam", "palm", "protect", "protection"], + "🪩": ["ball", "dance", "disco", "glitter", "mirror", "party"], "🚗": ["red", "transportation", "vehicle"], "🚕": ["uber", "vehicle", "cars", "transportation"], "🚙": ["transportation", "vehicle"], @@ -941,7 +1029,7 @@ "🚀": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"], "🛰": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"], "🛻": ["car"], - "🛼": [], + "🛼": ["blades", "roller", "skate", "skates", "sport"], "💺": ["sit", "airplane", "transport", "bus", "flight", "fly"], "🛶": ["boat", "paddle", "water", "ship"], "⚓": ["ship", "ferry", "sea", "boat"], @@ -1013,12 +1101,12 @@ "🕋": ["mecca", "mosque", "islam"], "⛩": ["temple", "japan", "kyoto"], "🛕": ["temple"], - "🪨": [], - "🪵": [], - "🛖": [], - "🛝": [], - "🛞": [], - "🛟": [], + "🪨": ["boulder", "heavy", "rock", "solid", "stone", "tough"], + "🪵": ["log", "lumber", "timber", "wood"], + "🛖": ["home", "house", "hut", "roundhouse", "shelter", "yurt"], + "🛝": ["amusement", "park", "play", "playground", "playing", "slide", "sliding", "theme"], + "🛞": ["car", "circle", "tire", "turn", "vehicle", "wheel"], + "🛟": ["buoy", "float", "life", "lifesaver", "preserver", "rescue", "ring", "safety", "save", "saver", "swim"], "⌚": ["time", "accessories"], "📱": ["technology", "apple", "gadgets", "dial"], "📲": ["iphone", "incoming"], @@ -1059,7 +1147,7 @@ "⌛": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"], "📡": ["communication", "future", "radio", "space"], "🔋": ["power", "energy", "sustain"], - "🪫": [], + "🪫": ["battery", "drained", "electronic", "energy", "low", "power"], "🔌": ["charger", "power"], "💡": ["light", "electricity", "idea"], "🔦": ["dark", "camping", "sight", "night"], @@ -1075,9 +1163,11 @@ "💰": ["dollar", "payment", "coins", "sale"], "🪙": ["dollar", "payment", "coins", "sale"], "💳": ["money", "sales", "dollar", "bill", "payment", "shopping"], - "🪪": [], + "🪪": ["card", "credentials", "document", "id", "identification", "license", "security"], + "🥾": ["backpacking", "boot", "brown", "camping", "hiking", "outdoors", "shoe"], "💎": ["blue", "ruby", "diamond", "jewelry"], "⚖": ["law", "fairness", "weight"], + "⛓️💥": ["break", "breaking", "broken", "chain", "cuffs", "freedom"], "🧰": ["tools", "diy", "fix", "maintainer", "mechanic"], "🔧": ["tools", "diy", "ikea", "fix", "maintainer"], "🔨": ["tools", "build", "create"], @@ -1093,6 +1183,7 @@ "🪛": ["tool"], "🪝": ["tool"], "🪜": ["tool"], + "": ["bury", "dig", "garden", "hole", "plant", "scoop", "shovel", "snow", "spade"], "🧱": ["bricks"], "⛓": ["lock", "arrest"], "🧲": ["attraction", "magnetic"], @@ -1123,8 +1214,8 @@ "🩺": ["health", "hospital", "medicine", "needle", "doctor", "nurse"], "🪒": ["health"], "🪮": ["afro", "comb", "hair", "pick"], - "🩻": [], - "🩼": [], + "🩻": ["bones", "doctor", "medical", "skeleton", "skull", "x-ray", "xray"], + "🩼": ["aid", "cane", "crutch", "disability", "help", "hurt", "injured", "mobility", "stick"], "🧬": ["biologist", "genetics", "life"], "🧫": ["bacteria", "biology", "culture", "lab"], "🧪": ["chemistry", "experiment", "lab", "science"], @@ -1159,7 +1250,7 @@ "🪤": ["household"], "🪣": ["household"], "🪥": ["household"], - "🫧": [], + "🫧": ["bubble", "bubbles", "burp", "clean", "floating", "pearl", "soap", "underwater"], "⛱": ["weather", "summer"], "🗿": ["rock", "easter island", "moai"], "🛍": ["mall", "buy", "purchase"], @@ -1249,8 +1340,8 @@ "🖌": ["drawing", "creativity", "art"], "🔍": ["search", "zoom", "find", "detective"], "🔎": ["search", "zoom", "find", "detective"], - "🪦": [], - "🪧": [], + "🪦": ["cemetery", "dead", "grave", "graveyard", "headstone", "memorial", "rip", "tomb", "tombstone"], + "🪧": ["card", "demonstration", "notice", "picket", "placard", "plaque", "protest", "sign"], "💯": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"], "🔢": ["numbers", "blue-square"], "🩷": ["love", "like", "affection", "valentines"], @@ -1275,8 +1366,8 @@ "💘": ["love", "like", "heart", "affection", "valentines"], "💝": ["love", "valentines"], "💟": ["purple-square", "love", "like"], - "❤️🔥": [], - "❤️🩹": [], + "❤️🔥": ["burn", "fire", "heart", "love", "lust", "sacred"], + "❤️🩹": ["healthier", "heart", "improving", "mending", "recovering", "recuperating", "well"], "☮": ["hippie"], "✝": ["christianity"], "☪": ["islam"], @@ -1304,6 +1395,8 @@ "♓": ["purple-square", "sign", "zodiac", "astrology"], "🆔": ["purple-square", "words"], "⚛": ["science", "physics", "chemistry"], + "♀️": ["female", "sign", "woman", "pink-square"], + "♂️": ["male", "man", "sign", "blue-square"], "⚧️": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"], "🈳": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"], "🈹": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"], @@ -1459,13 +1552,15 @@ "➖": ["math", "calculation", "subtract", "less"], "➗": ["divide", "math", "calculation"], "✖️": ["math", "calculation"], - "🟰": [], + "🟰": ["answer", "equal", "equality", "equals", "heavy", "math", "sign"], "♾": ["forever"], "💲": ["money", "sales", "payment", "currency", "buck"], "💱": ["money", "sales", "dollar", "travel"], + "⚕️": ["aesculapius", "medical", "medicine", "staff", "symbol"], "©️": ["ip", "license", "circle", "law", "legal"], "®️": ["alphabet", "circle"], "™️": ["trademark", "brand", "law", "legal"], + "": ["drip", "holi", "ink", "liquid", "mess", "paint", "spill", "splatter", "stain"], "🔚": ["words", "arrow"], "🔙": ["arrow", "words", "return"], "🔛": ["arrow", "words"], @@ -1576,6 +1671,7 @@ "🇧🇲": ["bm", "bermuda", "flag", "nation", "country", "banner"], "🇧🇹": ["bt", "bhutan", "flag", "nation", "country", "banner"], "🇧🇴": ["bo", "bolivia", "flag", "nation", "country", "banner"], + "🇧🇻": ["bv", "bouvet", "island", "flag", "nation", "country", "banner"], "🇧🇶": ["bq", "bonaire", "flag", "nation", "country", "banner"], "🇧🇦": ["ba", "bosnia", "herzegovina", "flag", "nation", "country", "banner"], "🇧🇼": ["bw", "botswana", "flag", "nation", "country", "banner"], @@ -1593,10 +1689,12 @@ "🇮🇨": ["ic", "canary", "islands", "flag", "nation", "country", "banner"], "🇰🇾": ["ky", "cayman", "islands", "flag", "nation", "country", "banner"], "🇨🇫": ["cf", "central", "african", "republic", "flag", "nation", "country", "banner"], + "🇪🇦": ["ea", "ceuta", "melilla", "flag", "nation", "country", "banner"], "🇹🇩": ["td", "chad", "flag", "nation", "country", "banner"], "🇨🇱": ["cl", "chile", "flag", "nation", "country", "banner"], "🇨🇳": ["cn", "china", "chinese", "prc", "flag", "country", "nation", "banner"], "🇨🇽": ["cx", "christmas", "island", "flag", "nation", "country", "banner"], + "🇨🇵": ["cp", "clipperton", "island", "flag", "nation", "country", "banner"], "🇨🇨": ["cc", "cocos", "keeling", "islands", "flag", "nation", "country", "banner"], "🇨🇴": ["co", "colombia", "flag", "nation", "country", "banner"], "🇰🇲": ["km", "comoros", "flag", "nation", "country", "banner"], @@ -1610,6 +1708,7 @@ "🇨🇾": ["cy", "cyprus", "flag", "nation", "country", "banner"], "🇨🇿": ["cz", "czech", "republic", "flag", "nation", "country", "banner"], "🇩🇰": ["dk", "denmark", "flag", "nation", "country", "banner"], + "🇩🇬": ["dg", "diego", "garcia", "flag", "nation", "country", "banner"], "🇩🇯": ["dj", "djibouti", "flag", "nation", "country", "banner"], "🇩🇲": ["dm", "dominica", "flag", "nation", "country", "banner"], "🇩🇴": ["do", "dominican", "republic", "flag", "nation", "country", "banner"], @@ -1646,6 +1745,7 @@ "🇬🇼": ["gw", "guiana", "bissau", "flag", "nation", "country", "banner"], "🇬🇾": ["gy", "guyana", "flag", "nation", "country", "banner"], "🇭🇹": ["ht", "haiti", "flag", "nation", "country", "banner"], + "🇭🇲": ["hm", "heard", "mcdonald", "islands", "flag", "nation", "country", "banner"], "🇭🇳": ["hn", "honduras", "flag", "nation", "country", "banner"], "🇭🇰": ["hk", "hong", "kong", "flag", "nation", "country", "banner"], "🇭🇺": ["hu", "hungary", "flag", "nation", "country", "banner"], @@ -1733,10 +1833,12 @@ "🇷🇴": ["ro", "romania", "flag", "nation", "country", "banner"], "🇷🇺": ["ru", "russian", "federation", "flag", "nation", "country", "banner"], "🇷🇼": ["rw", "rwanda", "flag", "nation", "country", "banner"], + "🇨🇶": ["cq", "sark", "flag", "nation", "country", "banner"], "🇧🇱": ["bl", "saint", "barthélemy", "flag", "nation", "country", "banner"], "🇸🇭": ["sh", "saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"], "🇰🇳": ["kn", "saint", "kitts", "nevis", "flag", "nation", "country", "banner"], "🇱🇨": ["lc", "saint", "lucia", "flag", "nation", "country", "banner"], + "🇲🇫": ["mf", "st", "martin", "flag", "nation", "country", "banner"], "🇵🇲": ["pm", "saint", "pierre", "miquelon", "flag", "nation", "country", "banner"], "🇻🇨": ["vc", "saint", "vincent", "grenadines", "flag", "nation", "country", "banner"], "🇼🇸": ["ws", "western", "samoa", "flag", "nation", "country", "banner"], @@ -1762,6 +1864,7 @@ "🇸🇩": ["sd", "sudan", "flag", "nation", "country", "banner"], "🇸🇷": ["sr", "suriname", "flag", "nation", "country", "banner"], "🇸🇿": ["sz", "eswatini", "flag", "nation", "country", "banner"], + "🇸🇯": ["sj", "svalbard", "jan", "mayen", "flag", "nation", "country", "banner"], "🇸🇪": ["se", "sweden", "flag", "nation", "country", "banner"], "🇨🇭": ["ch", "switzerland", "confoederatio", "helvetica", "flag", "nation", "country", "banner"], "🇸🇾": ["sy", "syrian", "arab", "republic", "flag", "nation", "country", "banner"], @@ -1788,6 +1891,7 @@ "🏴": ["flag", "scottish"], "🏴": ["flag", "welsh"], "🇺🇸": ["us", "usa", "united", "states", "america", "flag", "nation", "country", "banner"], + "🇺🇲": ["um", "us", "outlying", "islands", "flag", "nation", "country", "banner"], "🇻🇮": ["vi", "virgin", "islands", "us", "flag", "nation", "country", "banner"], "🇺🇾": ["uy", "uruguay", "flag", "nation", "country", "banner"], "🇺🇿": ["uz", "uzbekistan", "flag", "nation", "country", "banner"], diff --git a/packages/frontend/src/unicode-emoji-indexes/ja-JP.json b/packages/frontend/src/unicode-emoji-indexes/ja-JP.json index 9c491804f2..136e96759e 100644 --- a/packages/frontend/src/unicode-emoji-indexes/ja-JP.json +++ b/packages/frontend/src/unicode-emoji-indexes/ja-JP.json @@ -36,6 +36,9 @@ "🤡":["ピエロの顔","ピエロ","顔"], "😏":["にやにやした顔","顔","にやにや"], "😶":["口のない顔","顔","口","静かに","沈黙"], + "🙂↔️":["いいえ","不賛成","顔","首を振る","首を横に振る"], + "🙂↕️":["はい","頷く","顔","首を振る","首を縦に振る"], + "":["クマ","徹夜","疲れた","眠い","顔"], "🫥":["点線の顔","落ち込んだ","消える","隠れる","内向的","目に見えない"], "😐":["普通の顔","無表情","顔","平静"], "🫤":["口が斜めになった顔","がっかり","無関心","疑い深い","不安"], @@ -111,6 +114,38 @@ "💩":["うんち","マンガ","漫画","フン","顔","モンスター"], "👻":["お化け","妖怪","顔","おとぎ話","ファンタジー","幽霊","モンスター","ハロウィーン"], "💀":["ドクロ","体","死","顔","おとぎ話","モンスター","骸骨","ハロウィーン"], + "":["指紋","鑑識","生体認証","セキュリティ"], + "🏃➡️":["ジョギング","マラソン","ランナー","ランニング","右向き","急ぐ","走る","走る人","駆け足"], + "🏃♀️➡️":["ジョギング","マラソン","ランナー","ランニング","右向き","女性","急ぐ","走る","走る女","駆け足"], + "🏃♂️➡️":["ジョギング","マラソン","ランナー","ランニング","右向き","男性","走る男"], + "🚶➡️":["ウォーキング","ハイキング","ぶらつく","人","右向き","大股","散歩","歩く","歩行","歩行者"], + "🚶♀️➡️":["ウォーキング","ハイキング","右向き","女性","散歩","歩く","歩く女","歩行","歩行者"], + "🚶♂️➡️":["ウォーキング","右向き","歩く男","歩行","歩行者","男性"], + "🧎➡️":["ひざまずく","人","右向き","座る","正座する人"], + "🧎♀️➡️":["ひざまずく","右向き","女性","座る","正座する女性"], + "🧎♂️➡️":["ひざまずく","右向き","座る","正座する男性","男性"], + "🧑🦯➡️":["アクセシビリティ","右向き","杖をついた人","目","視覚","障がい"], + "👩🦯➡️":["アクセシビリティ","右向き","女性","杖をついた女性","白杖をついた女性","目","視覚","障がい"], + "👨🦯➡️":["アクセシビリティ","右向き","杖をついた男性","男性","白杖をついた男性","目","視覚","障がい"], + "🧑🦼➡️":["アクセシビリティ","右向き","車いす","障がい","電動車椅子の人"], + "👩🦼➡️":["アクセシビリティ","右向き","女性","車いす","障がい","電動車椅子の女性"], + "👨🦼➡️":["アクセシビリティ","右向き","男性","車いす","障がい","電動車椅子の男性"], + "🧑🦽➡️":["アクセシビリティ","右向き","手動式車椅子の人","車いす","障がい"], + "👩🦽➡️":["アクセシビリティ","右向き","女性","手動式車椅子の女性","車いす","障がい"], + "👨🦽➡️":["アクセシビリティ","右向き","手動式車椅子の男性","男性","車いす","障がい"], + "👨👩👦":["女性","子供","家族","男の子","男性","親子"], + "🧑🧑🧒":["大人二人","子供一人","家族","大人二人と子供一人","親子"], + "🧑🧑🧒🧒":["大人二人","子供二人","家族","大人二人と子供二人","親子"], + "🧑🧒":["大人一人","子供一人","家族","大人一人と子供一人","親子"], + "🧑🧒🧒":["大人一人","子供二人","家族","大人一人と子供二人","親子"], + "🐦🔥":["ファンタジー","フェニックス","不死鳥","再生","復活","火の鳥","生まれ変わり","神話","転生","輪廻"], + "":["不毛","干ばつ","葉のない木","冬"], + "🍋🟩":["さわやか","トロピカル","フルーツ","マルガリータ","モヒート","ライム","果物","柑橘類","緑","酸っぱい"], + "🍄🟫":["きのこ","キノコ","しいたけ","トリュフ","ブラウンマッシュルーム","ポートベロー","マッシュルーム","椎茸","茸","菌類"], + "":["ビーツ","庭","根","カブ","野菜"], + "":["オーケストラ","キューピッド","ハープ","弦楽器","愛","楽器","音楽"], + "⛓️💥":["くさり","チェーン","壊れた","壊れた鎖"], + "":["シャベル","ショベル","スコップ","掘る","穴","鋤"], "☠":["ドクロマーク","体","交差した骨","死","顔","モンスター","骸骨","ハロウィーン"], "👽":["宇宙人","怪獣","異星人","顔","おとぎ話","ファンタジー","モンスター","宇宙","UFO"], "🤖":["ロボットの顔","顔","モンスター","ロボット"], @@ -1518,6 +1553,7 @@ "©️":["コピーライトマーク","著作権"], "®️":["登録商標マーク","登録済み","商標"], "™️":["商標マーク","マーク","tm","商標"], + "":["しぶき","ペンキ","飛沫","飛び散り","スプラッシュ"], "🔚":["ENDと左矢印","矢印","端"], "🔙":["BACKと左矢印","矢印","戻る"], "🔛":["ON!と左右矢印","矢印","マーク","オン"], @@ -1643,6 +1679,7 @@ "🇧🇷":["ブラジル国旗","ブラジル","国旗"], "🇧🇸":["バハマ国旗","バハマ","国旗"], "🇧🇹":["ブータン国旗","ブータン","国旗"], + "🇧🇻":["ブーベ島の旗","ブーベ島","国旗"], "🇧🇼":["ボツワナ国旗","ボツワナ","国旗"], "🇧🇾":["ベラルーシ国旗","ベラルーシ","国旗"], "🇧🇿":["ベリーズ国旗","ベリーズ","国旗"], @@ -1658,6 +1695,8 @@ "🇨🇲":["カメルーン国旗","カメルーン","国旗"], "🇨🇳":["中国国旗","中国","国旗"], "🇨🇴":["コロンビア国旗","コロンビア","国旗"], + "🇨🇵":["クリッパートン島の旗","クリッパートン島","国旗"], + "🇨🇶":["サーク島の旗", "サーク島", "国旗"], "🇨🇷":["コスタリカ国旗","コスタリカ","国旗"], "🇨🇺":["キューバ国旗","キューバ","国旗"], "🇨🇻":["カーボベルデ国旗","カーボ","ケープ","国旗","ベルデ"], @@ -1666,11 +1705,13 @@ "🇨🇾":["キプロス国旗","キプロス","国旗"], "🇨🇿":["チェコ国旗","チェコ共和国","国旗"], "🇩🇪":["ドイツ国旗","国旗","ドイツ"], + "🇩🇬":["ディエゴガルシア島の旗","ディエゴガルシア島","国旗"], "🇩🇯":["ジブチ国旗","ジブチ","国旗"], "🇩🇰":["デンマーク国旗","デンマーク","国旗"], "🇩🇲":["ドミニカ国旗","ドミニカ","国旗"], "🇩🇴":["ドミニカ共和国国旗","ドミニカ共和国","国旗"], "🇩🇿":["アルジェリア国旗","アルジェリア","国旗"], + "🇪🇦":["セウタ・メリリャの旗","セウタ・メリリャ","国旗"], "🇪🇨":["エクアドル国旗","エクアドル","国旗"], "🏴":["イングランドの旗","イングランド","旗"], "🇪🇪":["エストニア国旗","エストニア","国旗"], @@ -1706,6 +1747,7 @@ "🇬🇼":["ギニアビサウ国旗","ビサウ","国旗","ギニア"], "🇬🇾":["ガイアナ国旗","国旗","ガイアナ"], "🇭🇰":["香港の旗","中国","国旗","香港"], + "🇭🇲":["ハード島・マクドナルド諸島の旗","ハード島・マクドナルド諸島","国旗"], "🇭🇳":["ホンジュラス国旗","国旗","ホンジュラス"], "🇭🇷":["クロアチア国旗","クロアチア","国旗"], "🇭🇹":["ハイチ国旗","国旗","ハイチ"], @@ -1751,6 +1793,7 @@ "🇲🇨":["モナコ国旗","国旗","モナコ"], "🇲🇩":["モルドバ国旗","国旗","モルドバ"], "🇲🇪":["モンテネグロ国旗","国旗","モンテネグロ"], + "🇲🇫":["サン・マルタンの旗","サン・マルタン","国旗"], "🇲🇬":["マダガスカル国旗","国旗","マダガスカル"], "🇲🇭":["マーシャル諸島国旗","国旗","諸島","マーシャル"], "🇲🇰":["マケドニア国旗","国旗","マケドニア"], @@ -1811,6 +1854,7 @@ "🇸🇬":["シンガポール国旗","国旗","シンガポール"], "🇸🇭":["セントヘレナ島の旗","旗","ヘレナ","セント"], "🇸🇮":["スロベニア国旗","国旗","スロベニア"], + "🇸🇯":["スバールバル諸島・ヤンマイエン島の旗","スバールバル諸島・ヤンマイエン島","国旗"], "🇸🇰":["スロバキア国旗","国旗","スロバキア"], "🇸🇱":["シエラレオネ国旗","国旗","シエラレオネ"], "🇸🇲":["サンマリノ国旗","国旗","サンマリノ"], @@ -1842,6 +1886,7 @@ "🇹🇿":["タンザニア国旗","国旗","タンザニア"], "🇺🇦":["ウクライナ国旗","国旗","ウクライナ"], "🇺🇬":["ウガンダ国旗","国旗","ウガンダ"], + "🇺🇲":["合衆国領有小離島の旗","合衆国領有小離島","国旗"], "🇺🇳":["国連の旗","旗","国連","連合","国際"], "🇺🇸":["アメリカ国旗","アメリカ","旗","合衆","合衆国","アメリカ合衆国","合衆国領有小離島"], "🇺🇾":["ウルグアイ国旗","国旗","ウルグアイ"], diff --git a/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json b/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json index 2ad282d501..7dfe022d72 100644 --- a/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json +++ b/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json @@ -36,6 +36,9 @@ "🤡": ["ぴえろのかお","ぴえろ","かお"], "😏": ["にやにやしたかお","かお","にやにや"], "😶": ["くちのないかお","かお","くち","しずかに","ちんもく"], + "🙂↔️": ["いいえ","ふさんせい","かお","くびをふる","くびをよこにふる"], + "🙂↕️": ["はい","うなずく","かお","くびをふる","くびをたてにふる"], + "": ["くま","てつや","つかれた","ねむい","かお"], "🫥": ["てんせんのかお","おちこんだ","きえる","かくれる","ないこうてき","めにみえない"], "😐": ["ふつうのかお","むひょうじょう","かお","へいせい"], "🫤": ["くちがななめになったかお","がっかり","むかんしん","うたがいぶかい","ふあん"], @@ -111,6 +114,38 @@ "💩": ["うんち","まんが","ふん","かお","もんすたー"], "👻": ["おばけ","ようかい","かお","おとぎばなし","ふぁんたじー","ゆうれい","もんすたー","はろうぃーん"], "💀": ["どくろ","からだ","し","かお","おとぎばなし","もんすたー","がいこつ","はろうぃーん"], + "": ["しもん","かんしき","せいたいにんしょう","せきゅりてぃ"], + "🏃➡️": ["じょぎんぐ","まらそん","らんなー","らんにんぐ","みぎむき","いそぐ","はしる","はしるひと","かけあし"], + "🏃♀️➡️": ["じょぎんぐ","まらそん","らんなー","らんにんぐ","みぎむき","じょせい","いそぐ","はしる","はしるおんな","かけあし"], + "🏃♂️➡️": ["じょぎんぐ","まらそん","らんなー","らんにんぐ","みぎむき","だんせい","はしるおとこ"], + "🚶➡️": ["うぉーきんぐ","はいきんぐ","ぶらつく","ひと","みぎむき","おおまた","さんぽ","あるく","ほこう","ほこうしゃ"], + "🚶♀️➡️": ["うぉーきんぐ","はいきんぐ","みぎむき","じょせい","さんぽ","あるく","あるくおんな","ほこう","ほこうしゃ"], + "🚶♂️➡️": ["うぉーきんぐ","みぎむき","あるくおとこ","ほこう","ほこうしゃ","だんせい"], + "🧎➡️": ["ひざまずく","ひと","みぎむき","すわる","せいざするひと"], + "🧎♀️➡️": ["ひざまずく","みぎむき","じょせい","すわる","せいざするじょせい"], + "🧎♂️➡️": ["ひざまずく","みぎむき","すわる","せいざするだんせい","だんせい"], + "🧑🦯➡️": ["あくせしびりてぃ","みぎむき","つえをついたひと","め","しかく","しょうがい"], + "👩🦯➡️": ["あくせしびりてぃ","みぎむき","じょせい","つえをついたじょせい","はくじょうをついたじょせい","め","しかく","しょうがい"], + "👨🦯➡️": ["あくせしびりてぃ","みぎむき","つえをついただんせい","だんせい","はくじょうをついただんせい","め","しかく","しょうがい"], + "🧑🦼➡️": ["あくせしびりてぃ","みぎむき","くるまいす","しょうがい","でんどうくるまいすのひと"], + "👩🦼➡️": ["あくせしびりてぃ","みぎむき","じょせい","くるまいす","しょうがい","でんどうくるまいすのじょせい"], + "👨🦼➡️": ["あくせしびりてぃ","みぎむき","だんせい","くるまいす","しょうがい","でんどうくるまいすのだんせい"], + "🧑🦽➡️": ["あくせしびりてぃ","みぎむき","しゅどうしきくるまいすのひと","くるまいす","しょうがい"], + "👩🦽➡️": ["あくせしびりてぃ","みぎむき","じょせい","しゅどうしきくるまいすのじょせい","くるまいす","しょうがい"], + "👨🦽➡️": ["あくせしびりてぃ","みぎむき","しゅどうしきくるまいすのだんせい","だんせい","くるまいす","しょうがい"], + "👨👩👦": ["じょせい","こども","かぞく","おとこのこ","だんせい","おやこ"], + "🧑🧑🧒": ["おとなふたり","こどもひとり","かぞく","おとなふたりとこどもひとり","おやこ"], + "🧑🧑🧒🧒": ["おとなふたり","こどもふたり","かぞく","おとなふたりとこどもふたり","おやこ"], + "🧑🧒": ["おとなひとり","こどもひとり","かぞく","おとなひとりとこどもひとり","おやこ"], + "🧑🧒🧒": ["おとなひとり","こどもふたり","かぞく","おとなひとりとこどもふたり","おやこ"], + "🐦🔥": ["ふぁんたじー","ふぇにっくす","ふしちょう","さいせい","ふっかつ","ひのとり","うまれかわり","しんわ","てんせい","りんね"], + "": ["ふもう","かんばつ","はのないき","ふゆ"], + "🍋🟩": ["さわやか","とろぴかる","ふるーつ","まるがりーた","もひーと","らいむ","くだもの","かんきつるい","みどり","すっぱい"], + "🍄🟫": ["きのこ","しいたけ","とりゅふ","ぶらうんまっしゅるーむ","ぽーとべろー","まっしゅるーむ","たけ","きんるい"], + "": ["びーつ","にわ","ね","かぶ","やさい"], + "": ["おーけすとら","きゅーぴっど","はーぷ","げんがっき","あい","がっき","おんがく"], + "⛓️💥": ["くさり","ちぇーん","こわれた","こわれたくさり"], + "": ["しゃべる","しょべる","すこっぷ","ほる","あな","すき"], "☠": ["どくろまーく","からだ","こうさしたほね","し","かお","もんすたー","がいこつ","はろうぃーん"], "👽": ["うちゅうじん","かいじゅう","いせいじん","かお","おとぎばなし","ふぁんたじー","もんすたー","うちゅう","UFO"], "🤖": ["ろぼっとのかお","かお","もんすたー","ろぼっと"], @@ -382,9 +417,9 @@ "🚶♀️": ["あるくじょせい","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ","じょせい","おんな"], "🚶": ["あるくひと","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ"], "🚶♂️": ["あるくだんせい","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ","おとこ","だんせい"], - "👩🦯": ["しろつえをもったじょせい","あくせしびりてぃ","めがふじゆう","じょせい","おんな"], - "🧑🦯": ["しろつえをもったひと","あくせしびりてぃ","めがふじゆう"], - "👨🦯": ["しろつえをもっただんせい","あくせしびりてぃ","めがふじゆう","だんせい","おとこ"], + "👩🦯": ["はくじょうをもったじょせい","あくせしびりてぃ","めがふじゆう","じょせい","おんな"], + "🧑🦯": ["はくじょうをもったひと","あくせしびりてぃ","めがふじゆう"], + "👨🦯": ["はくじょうをもっただんせい","あくせしびりてぃ","めがふじゆう","だんせい","おとこ"], "🏃♀️": ["はしるじょせい","まらそん","らんなー","らんにんぐ","じょせい","おんな"], "🏃": ["はしるひと","まらそん","らんなー","らんにんぐ"], "🏃♂️": ["はしるだんせい","まらそん","らんなー","らんにんぐ","おとこ","だんせい"], @@ -1129,7 +1164,7 @@ "🧾": ["りょうしゅうしょ","かいけい","ぼき","しょうこ","しょうめい"], "💎": ["ほうせき","だいあもんど","じゅえる","ろまんす"], "⚖": ["はかり","てんびん","こうせい","てんびんざ","ものさし","どうぐ","じゅうりょう","せいざ"], - "🦯": ["しろつえ","あくせしびりてぃ","めがふじゆう"], + "🦯": ["はくじょう","あくせしびりてぃ","めがふじゆう"], "🧰": ["どうぐばこ","むね","せいびし","こうぐ"], "🔧": ["れんち","どうぐ"], "🪛": ["どらいばー","ねじ","こうぐ"], @@ -1518,6 +1553,7 @@ "©️": ["こぴーらいとまーく","ちょさくけん"], "®️": ["とうろくしょうひょうまーく","とうろくずみ","しょうひょう"], "™️": ["しょうひょうまーく","まーく","tm","しょうひょう"], + "": ["しぶき","ぺんき","ひまつ","とびちり","すぷらっしゅ"], "🔚": ["ENDとひだりやじるし","やじるし","はじ"], "🔙": ["BACKとひだりやじるし","やじるし","もどる"], "🔛": ["ON!とさゆうやじるし","やじるし","まーく","おん"], @@ -1643,6 +1679,7 @@ "🇧🇷": ["ぶらじるこっき","ぶらじる","こっき"], "🇧🇸": ["ばはまこっき","ばはま","こっき"], "🇧🇹": ["ぶーたんこっき","ぶーたん","こっき"], + "🇧🇻": ["ぶーべとうのはた","ぶーべとう","こっき"], "🇧🇼": ["ぼつわなこっき","ぼつわな","こっき"], "🇧🇾": ["べらるーしこっき","べらるーし","こっき"], "🇧🇿": ["べりーずこっき","べりーず","こっき"], @@ -1658,6 +1695,8 @@ "🇨🇲": ["かめるーんこっき","かめるーん","こっき"], "🇨🇳": ["ちゅうごくこっき","ちゅうごく","こっき"], "🇨🇴": ["ころんびあこっき","ころんびあ","こっき"], + "🇨🇵": ["くりっぱーとんとうのはた","くりっぱーとんとう","こっき"], + "🇨🇶": ["さーくとうのはた", "さーくとう", "こっき"], "🇨🇷": ["こすたりかこっき","こすたりか","こっき"], "🇨🇺": ["きゅーばこっき","きゅーば","こっき"], "🇨🇻": ["かーぼべるでこっき","かーぼ","けーぷ","こっき","べるで"], @@ -1666,11 +1705,13 @@ "🇨🇾": ["きぷろすこっき","きぷろす","こっき"], "🇨🇿": ["ちぇここっき","ちぇこきょうわこく","こっき"], "🇩🇪": ["どいつこっき","こっき","どいつ"], + "🇩🇬": ["でぃえごがるしあとうのはた","でぃえごがるしあとう","こっき"], "🇩🇯": ["じぶちこっき","じぶち","こっき"], "🇩🇰": ["でんまーくこっき","でんまーく","こっき"], "🇩🇲": ["どみにかこっき","どみにか","こっき"], "🇩🇴": ["どみにかきょうわこくこっき","どみにかきょうわこく","こっき"], "🇩🇿": ["あるじぇりあこっき","あるじぇりあ","こっき"], + "🇪🇦": ["せうた・めりりゃのはた","せうた・めりりゃ","こっき"], "🇪🇨": ["えくあどるこっき","えくあどる","こっき"], "🏴": ["いんぐらんどのはた","いんぐらんど","こっき"], "🇪🇪": ["えすとにあこっき","えすとにあ","こっき"], @@ -1706,6 +1747,7 @@ "🇬🇼": ["ぎにあびさうこっき","びさう","こっき","ぎにあ"], "🇬🇾": ["がいあなこっき","こっき","がいあな"], "🇭🇰": ["ほんこんのはた","ちゅうごく","こっき","ほんこん"], + "🇭🇲": ["はーどとう・まくどなるどしょとうのはた","はーどとう・まくどなるどしょとう","こっき"], "🇭🇳": ["ほんじゅらすこっき","こっき","ほんじゅらす"], "🇭🇷": ["くろあちあこっき","くろあちあ","こっき"], "🇭🇹": ["はいちこっき","こっき","はいち"], @@ -1751,6 +1793,7 @@ "🇲🇨": ["もなここっき","こっき","もなこ"], "🇲🇩": ["もるどばこっき","こっき","もるどば"], "🇲🇪": ["もんてねぐろこっき","こっき","もんてねぐろ"], + "🇲🇫": ["さん・まるたんのはた","さん・まるたん","こっき"], "🇲🇬": ["まだがすかるこっき","こっき","まだがすかる"], "🇲🇭": ["まーしゃるしょとうこっき","こっき","しょとう","まーしゃる"], "🇲🇰": ["まけどにあこっき","こっき","まけどにあ"], @@ -1811,6 +1854,7 @@ "🇸🇬": ["しんがぽーるこっき","こっき","しんがぽーる"], "🇸🇭": ["せんとへれなとうのはた","はた","へれな","せんと"], "🇸🇮": ["すろべにあこっき","こっき","すろべにあ"], + "🇸🇯": ["すばーるばるしょとう・やんまいえんとうのはた","すばーるばるしょとう・やんまいえんとう","こっき"], "🇸🇰": ["すろばきあこっき","こっき","すろばきあ"], "🇸🇱": ["しえられおねこっき","こっき","しえられおね"], "🇸🇲": ["さんまりのこっき","こっき","さんまりの"], @@ -1842,6 +1886,7 @@ "🇹🇿": ["たんざにあこっき","こっき","たんざにあ"], "🇺🇦": ["うくらいなこっき","こっき","うくらいな"], "🇺🇬": ["うがんだこっき","こっき","うがんだ"], + "🇺🇲": ["がっしゅうこくりょうゆうしょうりとうのはた","がっしゅうこくりょうゆうしょうりとう","こっき"], "🇺🇳": ["こくれんのはた","はた","こくれん","れんごう","こくさい"], "🇺🇸": ["あめりかこっき","あめりか","はた","ごうしゅう","がっしゅうこく","あめりかがっしゅうこく","がっしゅうこくりょうゆうしょうりとう"], "🇺🇾": ["うるぐあいこっき","こっき","うるぐあい"], diff --git a/packages/frontend/src/utility/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts index 7405e229fe..18eebaa8f8 100644 --- a/packages/frontend/src/utility/admin-lookup.ts +++ b/packages/frontend/src/utility/admin-lookup.ts @@ -12,7 +12,7 @@ export async function lookupUser() { const { canceled, result } = await os.inputText({ title: i18n.ts.usernameOrUserId, }); - if (canceled) return; + if (canceled || result == null) return; const show = (user) => { os.pageWindow(`/admin/user/${user.id}`); @@ -46,13 +46,13 @@ export async function lookupUserByEmail() { title: i18n.ts.emailAddress, type: 'email', }); - if (canceled) return; + if (canceled || result == null) return; try { const user = await os.apiWithDialog('admin/accounts/find-by-email', { email: result }); os.pageWindow(`/admin/user/${user.id}`); - } catch (err) { + } catch (err: any) { if (err.code === 'USER_NOT_FOUND') { os.alert({ type: 'error', diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 1246c32554..82109af1a0 100644 --- a/packages/frontend/src/utility/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts @@ -78,7 +78,10 @@ export class Autocomplete { const caretPos = Number(this.textarea.selectionStart); const text = this.text.substring(0, caretPos).split('\n').pop()!; - const mentionIndex = text.lastIndexOf('@'); + // メンションに含められる文字のみで構成された、最も末尾にある文字列を抽出 + const mentionCandidate = text.split(/[^a-zA-Z0-9_@.\-]+/).pop()!; + + const mentionIndex = mentionCandidate.lastIndexOf('@'); const hashtagIndex = text.lastIndexOf('#'); const emojiIndex = text.lastIndexOf(':'); const mfmTagIndex = text.lastIndexOf('$'); @@ -97,7 +100,7 @@ export class Autocomplete { const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop(); - const isMention = mentionIndex !== -1; + const maybeMention = mentionIndex !== -1; const isHashtag = hashtagIndex !== -1; const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; @@ -107,20 +110,27 @@ export class Autocomplete { let opened = false; - if (isMention && this.onlyType.includes('user')) { + if (maybeMention && this.onlyType.includes('user')) { // ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう // この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、 // ホスト名を含むリモートのユーザ名を全て拾えるようにする - const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1); - const username = mentionIndexAlt === -1 - ? text.substring(mentionIndex + 1) - : text.substring(mentionIndexAlt + 1); - if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) { - this.open('user', username); - opened = true; - } else if (username === '') { - this.open('user', null); - opened = true; + const mentionIndexAlt = mentionCandidate.lastIndexOf('@', mentionIndex - 1); + + // @が連続している場合、1つ目を無視する + const mentionIndexLeft = (mentionIndexAlt !== -1 && mentionIndexAlt !== mentionIndex - 1) ? mentionIndexAlt : mentionIndex; + + // メンションを構成する条件を満たしているか確認する + const isMention = mentionIndexLeft === 0 || '_@.-'.includes(mentionCandidate[mentionIndexLeft - 1]); + + if (isMention) { + const username = mentionCandidate.substring(mentionIndexLeft + 1); + if (username !== '' && username.match(/^[a-zA-Z0-9_@.\-]+$/)) { + this.open('user', username); + opened = true; + } else if (username === '') { + this.open('user', null); + opened = true; + } } } diff --git a/packages/frontend/src/utility/chart-legend.ts b/packages/frontend/src/utility/chart-legend.ts index e701d18dd2..fcbddf5669 100644 --- a/packages/frontend/src/utility/chart-legend.ts +++ b/packages/frontend/src/utility/chart-legend.ts @@ -10,7 +10,7 @@ export const chartLegend = (legend: InstanceType<typeof MkChartLegend>) => ({ id: 'htmlLegend', afterUpdate(chart, args, options) { // Reuse the built-in legendItems generator - const items = chart.options.plugins.legend.labels.generateLabels(chart); + const items = chart.options.plugins!.legend!.labels!.generateLabels!(chart); legend.update(chart, items); }, diff --git a/packages/frontend/src/utility/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts index 465ca591c6..2fe4bdb83b 100644 --- a/packages/frontend/src/utility/chart-vline.ts +++ b/packages/frontend/src/utility/chart-vline.ts @@ -8,9 +8,10 @@ import type { Plugin } from 'chart.js'; export const chartVLine = (vLineColor: string) => ({ id: 'vLine', beforeDraw(chart, args, options) { - if (chart.tooltip?._active?.length) { + const tooltip = chart.tooltip as any; + if (tooltip?._active?.length) { const ctx = chart.ctx; - const xs = chart.tooltip._active.map(a => a.element.x); + const xs = tooltip._active.map(a => a.element.x); const x = xs.reduce((a, b) => a + b, 0) / xs.length; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; diff --git a/packages/frontend/src/utility/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts index 2de8fd2cd1..4f55556e0a 100644 --- a/packages/frontend/src/utility/check-permissions.ts +++ b/packages/frontend/src/utility/check-permissions.ts @@ -17,3 +17,11 @@ export const notesSearchAvailable = ( export const canSearchNonLocalNotes = ( instance.noteSearchableScope === 'global' ); + +export const usersSearchAvailable = ( + // FIXME: instance.policies would be null in Vitest + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ($i == null && instance.policies != null && instance.policies.canSearchUsers) || + ($i != null && $i.policies.canSearchUsers) || + false +); diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts index b6ae254727..8a62265438 100644 --- a/packages/frontend/src/utility/clear-cache.ts +++ b/packages/frontend/src/utility/clear-cache.ts @@ -13,8 +13,6 @@ export async function clearCache() { os.waiting(); miLocalStorage.removeItem('instance'); miLocalStorage.removeItem('instanceCachedAt'); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); miLocalStorage.removeItem('theme'); miLocalStorage.removeItem('emojis'); miLocalStorage.removeItem('lastEmojisFetchedAt'); diff --git a/packages/frontend/src/utility/clicker-game.ts b/packages/frontend/src/utility/clicker-game.ts index 0544be7757..4360c58455 100644 --- a/packages/frontend/src/utility/clicker-game.ts +++ b/packages/frontend/src/utility/clicker-game.ts @@ -27,7 +27,7 @@ export async function load() { scope: ['clickerGame'], key: 'saveData', }); - } catch (err) { + } catch (err: any) { if (err.code === 'NO_SUCH_KEY') { saveData.value = { gameVersion: 2, @@ -43,20 +43,6 @@ export async function load() { } throw err; } - - // migration - if (saveData.value.gameVersion === 1) { - saveData.value = { - gameVersion: 2, - cookies: saveData.value.cookies, - totalCookies: saveData.value.cookies, - totalHandmadeCookies: saveData.value.cookies, - clicked: saveData.value.clicked, - achievements: [], - facilities: [], - }; - save(); - } } export async function save() { diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts index f2f491b3fd..fb2825e7f7 100644 --- a/packages/frontend/src/utility/drive.ts +++ b/packages/frontend/src/utility/drive.ts @@ -233,7 +233,7 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string | os.popupMenu([label ? { text: label, type: 'label', - } : undefined, { + } : null, { text: i18n.ts.upload, icon: 'ti ti-upload', action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)), @@ -300,15 +300,13 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi }); } -export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> { +export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<(Misskey.entities.DriveFolder | null)[]> { return new Promise(async resolve => { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), { initialFolder, }, { done: folders => { - if (folders) { - resolve(folders); - } + resolve(folders); }, closed: () => dispose(), }); diff --git a/packages/frontend/src/utility/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts index d518562053..2ec9349718 100644 --- a/packages/frontend/src/utility/extract-mentions.ts +++ b/packages/frontend/src/utility/extract-mentions.ts @@ -9,7 +9,7 @@ import * as mfm from 'mfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 - const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention') as mfm.MfmMention[]; const mentions = mentionNodes.map(x => x.props); return mentions; diff --git a/packages/frontend/src/utility/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts index 570823d5b5..48243dff36 100644 --- a/packages/frontend/src/utility/extract-url-from-mfm.ts +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -13,7 +13,7 @@ const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { const urlNodes = mfm.extract(nodes, (node) => { return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); - }); + }) as mfm.MfmUrl[]; const urls: string[] = unique(urlNodes.map(x => x.props.url)); return urls.reduce((array, url) => { diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index 11c87dc653..90de952a91 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -39,7 +39,7 @@ export async function getNoteClipMenu(props: { } } - const appearNote = getAppearNote(props.note); + const appearNote = getAppearNote(props.note) ?? props.note; const clips = await clipsCache.fetch(); const menu: MenuItem[] = [...clips.map(clip => ({ @@ -179,7 +179,7 @@ export function getNoteMenu(props: { translating: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { - const appearNote = getAppearNote(props.note); + const appearNote = getAppearNote(props.note) ?? props.note; const link = appearNote.url ?? appearNote.uri; const cleanups = [] as (() => void)[]; @@ -554,7 +554,7 @@ export function getRenoteMenu(props: { renoteButton: ShallowRef<HTMLElement | null | undefined>; mock?: boolean; }) { - const appearNote = getAppearNote(props.note); + const appearNote = getAppearNote(props.note) ?? props.note; const channelRenoteItems: MenuItem[] = []; const normalRenoteItems: MenuItem[] = []; diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index ad0864019b..d4407dadec 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -158,7 +158,11 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router icon: 'ti ti-user-exclamation', text: i18n.ts.moderation, action: () => { - router.push(`/admin/user/${user.id}`); + router.push('/admin/user/:userId', { + params: { + userId: user.id, + }, + }); }, }, { type: 'divider' }); } @@ -216,7 +220,12 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router icon: 'ti ti-search', text: i18n.ts.searchThisUsersNotes, action: () => { - router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); + router.push('/search', { + query: { + username: user.username, + host: user.host ?? undefined, + }, + }); }, }); } diff --git a/packages/frontend/src/utility/haptic.ts b/packages/frontend/src/utility/haptic.ts new file mode 100644 index 0000000000..6f4706d202 --- /dev/null +++ b/packages/frontend/src/utility/haptic.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { haptic as _haptic } from 'ios-haptics'; +import { prefer } from '@/preferences.js'; + +export function haptic() { + if (prefer.s['experimental.enableHapticFeedback']) { + _haptic(); + } +} diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts index 1028c57f35..66b4d1026c 100644 --- a/packages/frontend/src/utility/image-effector/ImageEffector.ts +++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts @@ -6,22 +6,78 @@ import { getProxiedImageUrl } from '../media-proxy.js'; import { initShaderProgram } from '../webgl.js'; +export type ImageEffectorRGB = [r: number, g: number, b: number]; + type ParamTypeToPrimitive = { - 'number': number; - 'number:enum': number; - 'boolean': boolean; - 'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; }; - 'seed': number; - 'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null; - 'color': [r: number, g: number, b: number]; + [K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default']; }; -type ImageEffectorFxParamDefs = Record<string, { - type: keyof ParamTypeToPrimitive; - default: any; +interface CommonParamDef { + type: string; label?: string; - toViewValue?: (v: any) => string; -}>; + caption?: string; + default: any; +} + +interface NumberParamDef extends CommonParamDef { + type: 'number'; + default: number; + min: number; + max: number; + step?: number; + toViewValue?: (v: number) => string; +}; + +interface NumberEnumParamDef extends CommonParamDef { + type: 'number:enum'; + enum: { + value: number; + label?: string; + icon?: string; + }[]; + default: number; +}; + +interface BooleanParamDef extends CommonParamDef { + type: 'boolean'; + default: boolean; +}; + +interface AlignParamDef extends CommonParamDef { + type: 'align'; + default: { + x: 'left' | 'center' | 'right'; + y: 'top' | 'center' | 'bottom'; + }; +}; + +interface SeedParamDef extends CommonParamDef { + type: 'seed'; + default: number; +}; + +interface TextureParamDef extends CommonParamDef { + type: 'texture'; + default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null; +}; + +interface ColorParamDef extends CommonParamDef { + type: 'color'; + default: ImageEffectorRGB; +}; + +type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef; + +export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>; + +export type GetParamType<T extends ImageEffectorFxParamDef> = + T extends NumberEnumParamDef + ? T['enum'][number]['value'] + : ParamTypeToPrimitive[T['type']]; + +export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = { + [K in keyof PS]: GetParamType<PS[K]>; +}; export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) { return fx; @@ -36,9 +92,7 @@ export type ImageEffectorFx<ID extends string = string, PS extends ImageEffector main: (ctx: { gl: WebGL2RenderingContext; program: WebGLProgram; - params: { - [key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']]; - }; + params: ParamsRecordTypeToDefRecord<PS>; u: Record<US[number], WebGLUniformLocation>; width: number; height: number; diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts b/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts index bf7eaa8bda..7e09524c10 100644 --- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts +++ b/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts @@ -48,20 +48,22 @@ void main() { `; export const FX_blockNoise = defineImageEffectorFx({ - id: 'blockNoise' as const, + id: 'blockNoise', name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise, shader, uniforms: ['amount', 'channelShift'] as const, params: { amount: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.amount, + type: 'number', default: 50, min: 1, max: 100, step: 1, }, strength: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', default: 0.05, min: -1, max: 1, @@ -69,7 +71,8 @@ export const FX_blockNoise = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, width: { - type: 'number' as const, + label: i18n.ts.width, + type: 'number', default: 0.05, min: 0.01, max: 1, @@ -77,7 +80,8 @@ export const FX_blockNoise = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, height: { - type: 'number' as const, + label: i18n.ts.height, + type: 'number', default: 0.01, min: 0.01, max: 1, @@ -85,7 +89,8 @@ export const FX_blockNoise = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, channelShift: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.glitchChannelShift, + type: 'number', default: 0, min: 0, max: 10, @@ -93,7 +98,8 @@ export const FX_blockNoise = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, seed: { - type: 'seed' as const, + label: i18n.ts._imageEffector._fxProps.seed, + type: 'seed', default: 100, }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-effector/fxs/checker.ts index c426308951..c48f73acbd 100644 --- a/packages/frontend/src/utility/image-effector/fxs/checker.ts +++ b/packages/frontend/src/utility/image-effector/fxs/checker.ts @@ -47,13 +47,14 @@ void main() { `; export const FX_checker = defineImageEffectorFx({ - id: 'checker' as const, + id: 'checker', name: i18n.ts._imageEffector._fxs.checker, shader, uniforms: ['angle', 'scale', 'color', 'opacity'] as const, params: { angle: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', default: 0, min: -1.0, max: 1.0, @@ -61,18 +62,21 @@ export const FX_checker = defineImageEffectorFx({ toViewValue: v => Math.round(v * 90) + '°', }, scale: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.scale, + type: 'number', default: 3.0, min: 1.0, max: 10.0, step: 0.1, }, color: { - type: 'color' as const, + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', default: [1, 1, 1], }, opacity: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', default: 0.5, min: 0.0, max: 1.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts index 82d7d883aa..4adb7ce91e 100644 --- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts +++ b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts @@ -52,17 +52,19 @@ void main() { `; export const FX_chromaticAberration = defineImageEffectorFx({ - id: 'chromaticAberration' as const, + id: 'chromaticAberration', name: i18n.ts._imageEffector._fxs.chromaticAberration, shader, uniforms: ['amount', 'start', 'normalize'] as const, params: { normalize: { - type: 'boolean' as const, + label: i18n.ts._imageEffector._fxProps.normalize, + type: 'boolean', default: false, }, amount: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.amount, + type: 'number', default: 0.1, min: 0.0, max: 1.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts index c38490e198..8cfbbcb516 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts +++ b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts @@ -85,13 +85,14 @@ void main() { `; export const FX_colorAdjust = defineImageEffectorFx({ - id: 'colorAdjust' as const, + id: 'colorAdjust', name: i18n.ts._imageEffector._fxs.colorAdjust, shader, uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const, params: { lightness: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.lightness, + type: 'number', default: 0, min: -1, max: 1, @@ -99,7 +100,8 @@ export const FX_colorAdjust = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, contrast: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.contrast, + type: 'number', default: 1, min: 0, max: 4, @@ -107,7 +109,8 @@ export const FX_colorAdjust = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, hue: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.hue, + type: 'number', default: 0, min: -1, max: 1, @@ -115,7 +118,8 @@ export const FX_colorAdjust = defineImageEffectorFx({ toViewValue: v => Math.round(v * 180) + '°', }, brightness: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.brightness, + type: 'number', default: 1, min: 0, max: 4, @@ -123,7 +127,8 @@ export const FX_colorAdjust = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, saturation: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.saturation, + type: 'number', default: 1, min: 0, max: 4, diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts index ae0d92b8ae..4f18eb63c4 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts +++ b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts @@ -26,13 +26,14 @@ void main() { `; export const FX_colorClamp = defineImageEffectorFx({ - id: 'colorClamp' as const, + id: 'colorClamp', name: i18n.ts._imageEffector._fxs.colorClamp, shader, uniforms: ['max', 'min'] as const, params: { max: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.max, + type: 'number', default: 1.0, min: 0.0, max: 1.0, @@ -40,7 +41,8 @@ export const FX_colorClamp = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, min: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.min, + type: 'number', default: -1.0, min: -1.0, max: 0.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts index b9387900fb..7e793061cf 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts +++ b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts @@ -30,13 +30,14 @@ void main() { `; export const FX_colorClampAdvanced = defineImageEffectorFx({ - id: 'colorClampAdvanced' as const, + id: 'colorClampAdvanced', name: i18n.ts._imageEffector._fxs.colorClampAdvanced, shader, uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const, params: { rMax: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`, + type: 'number', default: 1.0, min: 0.0, max: 1.0, @@ -44,7 +45,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, rMin: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.redComponent})`, + type: 'number', default: -1.0, min: -1.0, max: 0.0, @@ -52,7 +54,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, gMax: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.greenComponent})`, + type: 'number', default: 1.0, min: 0.0, max: 1.0, @@ -60,7 +63,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, gMin: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.greenComponent})`, + type: 'number', default: -1.0, min: -1.0, max: 0.0, @@ -68,7 +72,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, bMax: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.blueComponent})`, + type: 'number', default: 1.0, min: 0.0, max: 1.0, @@ -76,7 +81,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, bMin: { - type: 'number' as const, + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.blueComponent})`, + type: 'number', default: -1.0, min: -1.0, max: 0.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-effector/fxs/distort.ts index 4b1aefc159..7b5ec45f4b 100644 --- a/packages/frontend/src/utility/image-effector/fxs/distort.ts +++ b/packages/frontend/src/utility/image-effector/fxs/distort.ts @@ -34,18 +34,23 @@ void main() { `; export const FX_distort = defineImageEffectorFx({ - id: 'distort' as const, + id: 'distort', name: i18n.ts._imageEffector._fxs.distort, shader, uniforms: ['phase', 'frequency', 'strength', 'direction'] as const, params: { direction: { - type: 'number:enum' as const, - enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }], + label: i18n.ts._imageEffector._fxProps.direction, + type: 'number:enum', + enum: [ + { value: 0 as const, label: i18n.ts.horizontal }, + { value: 1 as const, label: i18n.ts.vertical }, + ], default: 1, }, phase: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.phase, + type: 'number', default: 0.0, min: -1.0, max: 1.0, @@ -53,14 +58,16 @@ export const FX_distort = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, frequency: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.frequency, + type: 'number', default: 30, min: 0, max: 100, step: 0.1, }, strength: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', default: 0.05, min: 0, max: 1, diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts index 8f33706ae7..e1a288fc85 100644 --- a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts +++ b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts @@ -26,7 +26,7 @@ void main() { `; export const FX_grayscale = defineImageEffectorFx({ - id: 'grayscale' as const, + id: 'grayscale', name: i18n.ts._imageEffector._fxs.grayscale, shader, uniforms: [] as const, diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-effector/fxs/invert.ts index 220a2dea30..1c662ae849 100644 --- a/packages/frontend/src/utility/image-effector/fxs/invert.ts +++ b/packages/frontend/src/utility/image-effector/fxs/invert.ts @@ -27,21 +27,24 @@ void main() { `; export const FX_invert = defineImageEffectorFx({ - id: 'invert' as const, + id: 'invert', name: i18n.ts._imageEffector._fxs.invert, shader, uniforms: ['r', 'g', 'b'] as const, params: { r: { - type: 'boolean' as const, + label: i18n.ts._imageEffector._fxProps.redComponent, + type: 'boolean', default: true, }, g: { - type: 'boolean' as const, + label: i18n.ts._imageEffector._fxProps.greenComponent, + type: 'boolean', default: true, }, b: { - type: 'boolean' as const, + label: i18n.ts._imageEffector._fxProps.blueComponent, + type: 'boolean', default: true, }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-effector/fxs/mirror.ts index 5946a2e0dc..3d7893f8b0 100644 --- a/packages/frontend/src/utility/image-effector/fxs/mirror.ts +++ b/packages/frontend/src/utility/image-effector/fxs/mirror.ts @@ -35,19 +35,29 @@ void main() { `; export const FX_mirror = defineImageEffectorFx({ - id: 'mirror' as const, + id: 'mirror', name: i18n.ts._imageEffector._fxs.mirror, shader, uniforms: ['h', 'v'] as const, params: { h: { - type: 'number:enum' as const, - enum: [{ value: -1, label: '<-' }, { value: 0, label: '|' }, { value: 1, label: '->' }], + label: i18n.ts.horizontal, + type: 'number:enum', + enum: [ + { value: -1 as const, icon: 'ti ti-arrow-bar-right' }, + { value: 0 as const, icon: 'ti ti-minus-vertical' }, + { value: 1 as const, icon: 'ti ti-arrow-bar-left' } + ], default: -1, }, v: { - type: 'number:enum' as const, - enum: [{ value: -1, label: '^' }, { value: 0, label: '-' }, { value: 1, label: 'v' }], + label: i18n.ts.vertical, + type: 'number:enum', + enum: [ + { value: -1 as const, icon: 'ti ti-arrow-bar-down' }, + { value: 0 as const, icon: 'ti ti-minus' }, + { value: 1 as const, icon: 'ti ti-arrow-bar-up' } + ], default: 0, }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts index 14f6f91148..1685601bd2 100644 --- a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts +++ b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts @@ -78,14 +78,16 @@ void main() { } `; +// Primarily used for watermark export const FX_polkadot = defineImageEffectorFx({ - id: 'polkadot' as const, + id: 'polkadot', name: i18n.ts._imageEffector._fxs.polkadot, shader, uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const, params: { angle: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', default: 0, min: -1.0, max: 1.0, @@ -93,21 +95,24 @@ export const FX_polkadot = defineImageEffectorFx({ toViewValue: v => Math.round(v * 90) + '°', }, scale: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.scale, + type: 'number', default: 3.0, min: 1.0, max: 10.0, step: 0.1, }, majorRadius: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.polkadotMainDotRadius, + type: 'number', default: 0.1, min: 0.0, max: 1.0, step: 0.01, }, majorOpacity: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.polkadotMainDotOpacity, + type: 'number', default: 0.75, min: 0.0, max: 1.0, @@ -115,21 +120,24 @@ export const FX_polkadot = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, minorDivisions: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.polkadotSubDotDivisions, + type: 'number', default: 4, min: 0, max: 16, step: 1, }, minorRadius: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.polkadotSubDotRadius, + type: 'number', default: 0.25, min: 0.0, max: 1.0, step: 0.01, }, minorOpacity: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.polkadotSubDotOpacity, + type: 'number', default: 0.5, min: 0.0, max: 1.0, @@ -137,7 +145,8 @@ export const FX_polkadot = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, color: { - type: 'color' as const, + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', default: [1, 1, 1], }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-effector/fxs/stripe.ts index f6c1d2278d..1c054c1aaa 100644 --- a/packages/frontend/src/utility/image-effector/fxs/stripe.ts +++ b/packages/frontend/src/utility/image-effector/fxs/stripe.ts @@ -48,14 +48,16 @@ void main() { } `; +// Primarily used for watermark export const FX_stripe = defineImageEffectorFx({ - id: 'stripe' as const, + id: 'stripe', name: i18n.ts._imageEffector._fxs.stripe, shader, uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const, params: { angle: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', default: 0.5, min: -1.0, max: 1.0, @@ -63,14 +65,16 @@ export const FX_stripe = defineImageEffectorFx({ toViewValue: v => Math.round(v * 90) + '°', }, frequency: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.stripeFrequency, + type: 'number', default: 10.0, min: 1.0, max: 30.0, step: 0.1, }, threshold: { - type: 'number' as const, + label: i18n.ts._watermarkEditor.stripeWidth, + type: 'number', default: 0.1, min: 0.0, max: 1.0, @@ -78,11 +82,13 @@ export const FX_stripe = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, color: { - type: 'color' as const, + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', default: [1, 1, 1], }, opacity: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', default: 0.5, min: 0.0, max: 1.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.ts b/packages/frontend/src/utility/image-effector/fxs/tearing.ts index d5f1e062ec..a1d5178d24 100644 --- a/packages/frontend/src/utility/image-effector/fxs/tearing.ts +++ b/packages/frontend/src/utility/image-effector/fxs/tearing.ts @@ -38,20 +38,22 @@ void main() { `; export const FX_tearing = defineImageEffectorFx({ - id: 'tearing' as const, + id: 'tearing', name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing, shader, uniforms: ['amount', 'channelShift'] as const, params: { amount: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.amount, + type: 'number', default: 3, min: 1, max: 100, step: 1, }, strength: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', default: 0.05, min: -1, max: 1, @@ -59,7 +61,8 @@ export const FX_tearing = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, size: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.size, + type: 'number', default: 0.2, min: 0, max: 1, @@ -67,7 +70,8 @@ export const FX_tearing = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, channelShift: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.glitchChannelShift, + type: 'number', default: 0.5, min: 0, max: 10, @@ -75,7 +79,8 @@ export const FX_tearing = defineImageEffectorFx({ toViewValue: v => Math.round(v * 100) + '%', }, seed: { - type: 'seed' as const, + label: i18n.ts._imageEffector._fxProps.seed, + type: 'seed', default: 100, }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-effector/fxs/threshold.ts index f2b8b107fd..3e591fc939 100644 --- a/packages/frontend/src/utility/image-effector/fxs/threshold.ts +++ b/packages/frontend/src/utility/image-effector/fxs/threshold.ts @@ -27,27 +27,30 @@ void main() { `; export const FX_threshold = defineImageEffectorFx({ - id: 'threshold' as const, + id: 'threshold', name: i18n.ts._imageEffector._fxs.threshold, shader, uniforms: ['r', 'g', 'b'] as const, params: { r: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.redComponent, + type: 'number', default: 0.5, min: 0.0, max: 1.0, step: 0.01, }, g: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.greenComponent, + type: 'number', default: 0.5, min: 0.0, max: 1.0, step: 0.01, }, b: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.blueComponent, + type: 'number', default: 0.5, min: 0.0, max: 1.0, diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts index 1c1c95b0c5..9b79e2bf94 100644 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts +++ b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts @@ -83,46 +83,46 @@ void main() { `; export const FX_watermarkPlacement = defineImageEffectorFx({ - id: 'watermarkPlacement' as const, + id: 'watermarkPlacement', name: '(internal)', shader, uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const, params: { cover: { - type: 'boolean' as const, + type: 'boolean', default: false, }, repeat: { - type: 'boolean' as const, + type: 'boolean', default: false, }, scale: { - type: 'number' as const, + type: 'number', default: 0.3, min: 0.0, max: 1.0, step: 0.01, }, angle: { - type: 'number' as const, + type: 'number', default: 0, min: -1.0, max: 1.0, step: 0.01, }, align: { - type: 'align' as const, + type: 'align', default: { x: 'right', y: 'bottom' }, }, opacity: { - type: 'number' as const, + type: 'number', default: 0.75, min: 0.0, max: 1.0, step: 0.01, }, watermark: { - type: 'texture' as const, + type: 'texture', default: null, }, }, diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts index 2613362a71..2e16ebea3b 100644 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts +++ b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts @@ -37,59 +37,68 @@ void main() { `; export const FX_zoomLines = defineImageEffectorFx({ - id: 'zoomLines' as const, + id: 'zoomLines', name: i18n.ts._imageEffector._fxs.zoomLines, shader, uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const, params: { x: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.centerX, + type: 'number', default: 0.0, min: -1.0, max: 1.0, step: 0.01, }, y: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.centerY, + type: 'number', default: 0.0, min: -1.0, max: 1.0, step: 0.01, }, frequency: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.frequency, + type: 'number', default: 30.0, min: 1.0, max: 200.0, step: 0.1, }, - thresholdEnabled: { - type: 'boolean' as const, - default: true, + smoothing: { + label: i18n.ts._imageEffector._fxProps.zoomLinesSmoothing, + caption: i18n.ts._imageEffector._fxProps.zoomLinesSmoothingDescription, + type: 'boolean', + default: false, }, threshold: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.zoomLinesThreshold, + type: 'number', default: 0.2, min: 0.0, max: 1.0, step: 0.01, }, maskSize: { - type: 'number' as const, + label: i18n.ts._imageEffector._fxProps.zoomLinesMaskSize, + type: 'number', default: 0.5, min: 0.0, max: 1.0, step: 0.01, }, black: { - type: 'boolean' as const, + label: i18n.ts._imageEffector._fxProps.zoomLinesBlack, + type: 'boolean', default: false, }, }, main: ({ gl, u, params }) => { gl.uniform2f(u.pos, (1.0 + params.x) / 2.0, (1.0 + params.y) / 2.0); gl.uniform1f(u.frequency, params.frequency); - gl.uniform1i(u.thresholdEnabled, params.thresholdEnabled ? 1 : 0); + // thresholdの調整が有効な間はsmoothingが利用できない + gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1); gl.uniform1f(u.threshold, params.threshold); gl.uniform1f(u.maskSize, params.maskSize); gl.uniform1i(u.black, params.black ? 1 : 0); diff --git a/packages/frontend/src/utility/inapp-search.ts b/packages/frontend/src/utility/inapp-search.ts new file mode 100644 index 0000000000..cbc3d87ff8 --- /dev/null +++ b/packages/frontend/src/utility/inapp-search.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { GeneratedSearchIndexItem } from 'search-index'; + +export type SearchIndexItem = { + id: string; + parentId?: string; + path?: string; + label: string; + keywords: string[]; + texts: string[]; + icon?: string; +}; + +export function genSearchIndexes(generated: GeneratedSearchIndexItem[]): SearchIndexItem[] { + const rootMods = new Map(generated.map(item => [item.id, item])); + + // link inlining here + for (const item of generated) { + if (item.inlining) { + for (const id of item.inlining) { + const inline = rootMods.get(id); + if (inline) { + inline.parentId = item.id; + inline.path = item.path; + } else { + console.log('[Settings Search Index] Failed to inline', id); + } + } + } + } + + return generated; +} diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts index 90611094fa..9baf40b731 100644 --- a/packages/frontend/src/utility/lookup.ts +++ b/packages/frontend/src/utility/lookup.ts @@ -8,6 +8,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { mainRouter } from '@/router.js'; +import { acct } from '@/filters/user'; export async function lookup(router?: Router) { const _router = router ?? mainRouter; @@ -19,12 +20,16 @@ export async function lookup(router?: Router) { if (canceled || query.length <= 1) return; if (query.startsWith('@') && !query.includes(' ')) { - _router.push(`/${query}`); + _router.pushByPath(`/${query}`); return; } if (query.startsWith('#')) { - _router.push(`/tags/${encodeURIComponent(query.substring(1))}`); + _router.push('/tags/:tag', { + params: { + tag: query.substring(1), + } + }); return; } @@ -32,9 +37,17 @@ export async function lookup(router?: Router) { const res = await apLookup(query); if (res.type === 'User') { - _router.push(`/@${res.object.username}@${res.object.host}`); + _router.push('/@:acct/:page?', { + params: { + acct: acct(res.object), + }, + }); } else if (res.type === 'Note') { - _router.push(`/notes/${res.object.id}`); + _router.push('/notes/:noteId/:initialTab?', { + params: { + noteId: res.object.id, + }, + }); } return; diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts index 63525b311a..7db55db7d6 100644 --- a/packages/frontend/src/utility/paginator.ts +++ b/packages/frontend/src/utility/paginator.ts @@ -37,6 +37,7 @@ export interface IPaginator<T = unknown, _T = T & MisskeyEntity> { fetchingOlder: Ref<boolean>; fetchingNewer: Ref<boolean>; canFetchOlder: Ref<boolean>; + canFetchNewer: Ref<boolean>; canSearch: boolean; error: Ref<boolean>; computedParams: ComputedRef<Misskey.Endpoints[PaginatorCompatibleEndpointPaths]['req'] | null | undefined> | null; @@ -77,6 +78,7 @@ export class Paginator< public fetchingOlder = ref(false); public fetchingNewer = ref(false); public canFetchOlder = ref(false); + public canFetchNewer = ref(false); public canSearch = false; public error = ref(false); private endpoint: Endpoint; @@ -85,7 +87,12 @@ export class Paginator< public computedParams: ComputedRef<E['req'] | null | undefined> | null; public initialId: MisskeyEntity['id'] | null = null; public initialDate: number | null = null; + + // 初回読み込み時、initialIdを基準にそれより新しいものを取得するか古いものを取得するか + // newer: initialIdより新しいものを取得する + // older: initialIdより古いものを取得する (default) public initialDirection: 'newer' | 'older'; + private offsetMode: boolean; public noPaging: boolean; public searchQuery = ref<null | string>(''); @@ -116,6 +123,7 @@ export class Paginator< initialId?: MisskeyEntity['id']; initialDate?: number | null; initialDirection?: 'newer' | 'older'; + order?: 'newest' | 'oldest'; // 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨 @@ -222,15 +230,15 @@ export class Paginator< if (this.canFetchDetection === 'limit') { if (apiRes.length < FIRST_FETCH_LIMIT) { - this.canFetchOlder.value = false; + (this.initialDirection === 'older' ? this.canFetchOlder : this.canFetchNewer).value = false; } else { - this.canFetchOlder.value = true; + (this.initialDirection === 'older' ? this.canFetchOlder : this.canFetchNewer).value = true; } } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { if (apiRes.length === 0 || this.noPaging) { - this.canFetchOlder.value = false; + (this.initialDirection === 'older' ? this.canFetchOlder : this.canFetchNewer).value = false; } else { - this.canFetchOlder.value = true; + (this.initialDirection === 'older' ? this.canFetchOlder : this.canFetchNewer).value = true; } } @@ -273,7 +281,11 @@ export class Paginator< if (i === 10) item._shouldInsertAd_ = true; } - this.pushItems(apiRes); + if (this.order.value === 'oldest') { + this.unshiftItems(apiRes.toReversed(), false); + } else { + this.pushItems(apiRes); + } if (this.canFetchDetection === 'limit') { if (apiRes.length < FIRST_FETCH_LIMIT) { @@ -313,7 +325,11 @@ export class Paginator< this.fetchingNewer.value = false; - if (apiRes == null || apiRes.length === 0) return; // これやらないと余計なre-renderが走る + if (apiRes == null || apiRes.length === 0) { + this.canFetchNewer.value = false; + // 余計なre-renderを防止するためここで終了 + return; + } if (options.toQueue) { this.aheadQueue.unshift(...apiRes.toReversed()); @@ -325,9 +341,19 @@ export class Paginator< if (this.order.value === 'oldest') { this.pushItems(apiRes); } else { - this.unshiftItems(apiRes.toReversed()); + this.unshiftItems(apiRes.toReversed(), false); } } + + if (this.canFetchDetection === 'limit') { + if (apiRes.length < FIRST_FETCH_LIMIT) { + this.canFetchNewer.value = false; + } else { + this.canFetchNewer.value = true; + } + } + // canFetchDetectionが'safe'の場合・apiRes.length === 0 の場合は apiRes.length === 0 の場合に canFetchNewer.value = false になるが、 + // 余計な re-render を防ぐために上部で処理している。そのため、ここでは何もしない } public trim(trigger = true): void { @@ -336,10 +362,10 @@ export class Paginator< if (this.useShallowRef && trigger) triggerRef(this.items); } - public unshiftItems(newItems: T[]): void { + public unshiftItems(newItems: T[], trim = true): void { if (newItems.length === 0) return; // これやらないと余計なre-renderが走る this.items.value.unshift(...newItems.filter(x => !this.items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため - this.trim(false); + if (trim) this.trim(true); if (this.useShallowRef) triggerRef(this.items); } diff --git a/packages/frontend/src/utility/popout.ts b/packages/frontend/src/utility/popout.ts index 5b141222e8..7e0222c459 100644 --- a/packages/frontend/src/utility/popout.ts +++ b/packages/frontend/src/utility/popout.ts @@ -20,8 +20,8 @@ export function popout(path: string, w?: HTMLElement) { } else { const width = 400; const height = 500; - const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); - const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + const x = window.top == null ? 0 : window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top == null ? 0 : window.top.outerWidth / 2 + window.top.screenX - (width / 2); window.open(url, url, `width=${width}, height=${height}, top=${x}, left=${y}`); } diff --git a/packages/frontend/src/utility/popup-position.ts b/packages/frontend/src/utility/popup-position.ts index 676dfb7507..0ba0d8e897 100644 --- a/packages/frontend/src/utility/popup-position.ts +++ b/packages/frontend/src/utility/popup-position.ts @@ -29,8 +29,8 @@ export function calcPopupPosition(el: HTMLElement, props: { left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); top = (rect.top + window.scrollY - contentHeight) - props.innerMargin; } else { - left = props.x; - top = (props.y - contentHeight) - props.innerMargin; + left = props.x!; + top = (props.y! - contentHeight) - props.innerMargin; } left -= (el.offsetWidth / 2); @@ -54,8 +54,8 @@ export function calcPopupPosition(el: HTMLElement, props: { left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin; } else { - left = props.x; - top = (props.y) + props.innerMargin; + left = props.x!; + top = (props.y!) + props.innerMargin; } left -= (el.offsetWidth / 2); @@ -79,8 +79,8 @@ export function calcPopupPosition(el: HTMLElement, props: { left = (rect.left + window.scrollX - contentWidth) - props.innerMargin; top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2); } else { - left = (props.x - contentWidth) - props.innerMargin; - top = props.y; + left = (props.x! - contentWidth) - props.innerMargin; + top = props.y!; } top -= (el.offsetHeight / 2); @@ -97,8 +97,8 @@ export function calcPopupPosition(el: HTMLElement, props: { }; const calcPosWhenRight = () => { - let left: number; - let top: number; + let left = 0; // TSを黙らすためとりあえず初期値を0に + let top = 0; // TSを黙らすためとりあえず初期値を0に if (props.anchorElement) { left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin; @@ -113,8 +113,8 @@ export function calcPopupPosition(el: HTMLElement, props: { top -= (el.offsetHeight / 2); } } else { - left = props.x + props.innerMargin; - top = props.y; + left = props.x! + props.innerMargin; + top = props.y!; top -= (el.offsetHeight / 2); } diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts deleted file mode 100644 index 7c7ea113d4..0000000000 --- a/packages/frontend/src/utility/reload-ask.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; -import { unisonReload } from '@/utility/unison-reload.js'; - -let isReloadConfirming = false; - -export async function reloadAsk(opts: { - unison?: boolean; - reason?: string; -}) { - if (isReloadConfirming) { - return; - } - - isReloadConfirming = true; - - const { canceled } = await os.confirm(opts.reason == null ? { - type: 'info', - text: i18n.ts.reloadConfirm, - } : { - type: 'info', - title: i18n.ts.reloadConfirm, - text: opts.reason, - }).finally(() => { - isReloadConfirming = false; - }); - - if (canceled) return; - - if (opts.unison) { - unisonReload(); - } else { - window.location.reload(); - } -} diff --git a/packages/frontend/src/utility/reload-suggest.ts b/packages/frontend/src/utility/reload-suggest.ts new file mode 100644 index 0000000000..f1888f721e --- /dev/null +++ b/packages/frontend/src/utility/reload-suggest.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; + +export const shouldSuggestReload = ref(false); + +export function suggestReload() { + shouldSuggestReload.value = true; +} diff --git a/packages/frontend/src/utility/settings-search-index.ts b/packages/frontend/src/utility/settings-search-index.ts deleted file mode 100644 index 7ed97ed34f..0000000000 --- a/packages/frontend/src/utility/settings-search-index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { searchIndexes as generated } from 'search-index:settings'; -import type { GeneratedSearchIndexItem } from 'search-index:settings'; - -export type SearchIndexItem = { - id: string; - parentId?: string; - path?: string; - label: string; - keywords: string[]; - icon?: string; -}; - -const rootMods = new Map(generated.map(item => [item.id, item])); - -// link inlining here -for (const item of generated) { - if (item.inlining) { - for (const id of item.inlining) { - const inline = rootMods.get(id); - if (inline) { - inline.parentId = item.id; - } else { - console.log('[Settings Search Index] Failed to inline', id); - } - } - } -} - -export const searchIndexes: SearchIndexItem[] = generated; - diff --git a/packages/frontend/src/utility/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts index 867c9b8324..435555896f 100644 --- a/packages/frontend/src/utility/sticky-sidebar.ts +++ b/packages/frontend/src/utility/sticky-sidebar.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/* export class StickySidebar { private lastScrollTop = 0; private container: HTMLElement; @@ -53,3 +54,4 @@ export class StickySidebar { this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; } } +*/ diff --git a/packages/frontend/src/utility/virtual.d.ts b/packages/frontend/src/utility/virtual.d.ts index 63dc4372b7..00f01992aa 100644 --- a/packages/frontend/src/utility/virtual.d.ts +++ b/packages/frontend/src/utility/virtual.d.ts @@ -3,16 +3,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +type XGeneratedSearchIndexItem = { + id: string; + parentId?: string; + path?: string; + label: string; + keywords: string[]; + texts: string[]; + icon?: string; + inlining?: string[]; +}; + +declare module 'search-index' { + export type GeneratedSearchIndexItem = XGeneratedSearchIndexItem; +} + declare module 'search-index:settings' { - export type GeneratedSearchIndexItem = { - id: string; - parentId?: string; - path?: string; - label: string; - keywords: string[]; - icon?: string; - inlining?: string[]; - }; + export const searchIndexes: XGeneratedSearchIndexItem[]; +} - export const searchIndexes: GeneratedSearchIndexItem[]; +declare module 'search-index:admin' { + export const searchIndexes: XGeneratedSearchIndexItem[]; } diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts index f0b38684f0..75807b30c4 100644 --- a/packages/frontend/src/utility/watermark.ts +++ b/packages/frontend/src/utility/watermark.ts @@ -150,7 +150,6 @@ export class WatermarkRenderer { minorRadius: layer.minorRadius, minorOpacity: layer.minorOpacity, color: layer.color, - opacity: layer.opacity, }, }; } else if (layer.type === 'checker') { diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index 722e6fadb2..7e6a779cf0 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_panel"> <div :class="$style.container" :style="{ backgroundImage: instance.bannerUrl ? `url(${ instance.bannerUrl })` : undefined }"> <div :class="$style.iconContainer"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.icon"/> + <img :src="instance.iconUrl ?? '/favicon.ico'" alt="" :class="$style.icon"/> </div> <div :class="$style.bodyContainer"> <div :class="$style.body"> @@ -20,10 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import { host } from '@@/js/config.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; -import { host } from '@@/js/config.js'; import { instance } from '@/instance.js'; const name = 'instanceInfo'; diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts index 22de6cd3a8..6e49f6bf66 100644 --- a/packages/frontend/src/workers/draw-blurhash.ts +++ b/packages/frontend/src/workers/draw-blurhash.ts @@ -18,5 +18,5 @@ onmessage = (event) => { render(event.data.hash, canvas); const bitmap = canvas.transferToImageBitmap(); - postMessage({ id: event.data.id, bitmap }); + postMessage({ id: event.data.id, bitmap }, [bitmap]); }; |