diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-02-16 21:42:35 +0000 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-02-16 21:42:35 +0000 |
| commit | 2d7918a9b74a1c049c2e520b0331ba6f161c1a16 (patch) | |
| tree | c2e30ecca540b187eee0659afa249bad51b45fe3 /packages/frontend/src | |
| parent | merge: fill `myReaction` in more cases - may fix #944 (!907) (diff) | |
| parent | Merge branch 'develop' into merge/2024-02-03 (diff) | |
| download | sharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.tar.gz sharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.tar.bz2 sharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.zip | |
merge: Merge upstream 2025.2.0 (!886)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886
Approved-by: Marie <github@yuugi.dev>
Approved-by: Amber Null <puppygirlhornyposting@gmail.com>
Diffstat (limited to 'packages/frontend/src')
136 files changed, 8041 insertions, 746 deletions
diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts deleted file mode 100644 index ebce7e735f..0000000000 --- a/packages/frontend/src/_dev_boot_.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -await main(); - -import('@/_boot_.js'); - -/** - * backend/src/server/web/boot.jsで差し込まれている起動処理のうち、最低限必要なものを模倣するための処理 - */ -async function main() { - const forceError = localStorage.getItem('forceError'); - if (forceError != null) { - renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); - } - - //#region Detect language & fetch translations - - // dev-modeの場合は常に取り直す - const supportedLangs = _LANGS_.map(it => it[0]); - let lang: string | null | undefined = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]); - - // Fallback - if (lang == null) lang = 'en-US'; - } - } - - // TODO:今のままだと言語ファイル変更後はpnpm devをリスタートする必要があるので、chokidarを使ったり等で対応できるようにする - const locale = _LANGS_FULL_.find(it => it[0] === lang); - localStorage.setItem('lang', lang); - localStorage.setItem('locale', JSON.stringify(locale[1])); - localStorage.setItem('localeVersion', _VERSION_); - //#endregion - - //#region Theme - const theme = localStorage.getItem('theme'); - if (theme) { - for (const [k, v] of Object.entries(JSON.parse(theme))) { - document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); - - // HTMLの theme-color 適用 - if (k === 'htmlThemeColor') { - for (const tag of document.head.children) { - if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { - tag.setAttribute('content', v); - break; - } - } - } - } - } - const colorScheme = localStorage.getItem('colorScheme'); - if (colorScheme) { - document.documentElement.style.setProperty('color-scheme', colorScheme); - } - //#endregion - - const fontSize = localStorage.getItem('fontSize'); - if (fontSize) { - document.documentElement.classList.add('f-' + fontSize); - } - - const useSystemFont = localStorage.getItem('useSystemFont'); - if (useSystemFont) { - document.documentElement.classList.add('useSystemFont'); - } - - const wallpaper = localStorage.getItem('wallpaper'); - if (wallpaper) { - document.documentElement.style.backgroundImage = `url(${wallpaper})`; - } - - const customCss = localStorage.getItem('customCss'); - if (customCss && customCss.length > 0) { - const style = document.createElement('style'); - style.innerHTML = customCss; - document.head.appendChild(style); - } -} - -function renderError(code: string, details?: string) { - console.log(code, details); -} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 366345b5b3..b3fa151a22 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -102,6 +102,9 @@ export async function removeAccount(idOrToken: Account['id']) { } function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> { + document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`; + document.cookie = `token=${token}; path=/queue; max-age=86400${ location.protocol === 'https:' ? '; SameSite=Strict; Secure' : ''}`; // bull dashboardの認証とかで使う + return new Promise((done, fail) => { window.fetch(`${apiUrl}/i`, { method: 'POST', @@ -150,9 +153,9 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr } else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') { // rate limited const timeToWait = res.error.info?.resetMs ?? 1000; - window.setTimeout(timeToWait, () => { + window.setTimeout(() => { fetchAccount(token, id, forceShowDialog).then(done, fail); - }); + }, timeToWait); return; } else { await alert({ @@ -221,7 +224,6 @@ export async function login(token: Account['token'], redirect?: string) { throw reason; }); miLocalStorage.setItem('account', JSON.stringify(me)); - document.cookie = `token=${token}; path=/; max-age=31536000${ location.protocol === 'https:' ? '; Secure' : ''}`; // bull dashboardの認証とかで使う await addAccount(me.id, token); if (redirect) { diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index d43a2b0799..46ec4533ec 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -99,6 +99,11 @@ export async function common(createVue: () => App<Element>) { // タッチデバイスでCSSの:hoverを機能させる document.addEventListener('touchend', () => {}, { passive: true }); + // URLに#pswpを含む場合は取り除く + if (location.hash === '#pswp') { + history.replaceState(null, '', location.href.replace('#pswp', '')); + } + // 一斉リロード reloadChannel.addEventListener('message', path => { if (path !== null) location.href = path; diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index eb8a4d30d2..6c544feb2a 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -7,6 +7,7 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { ui } from '@@/js/config.js'; import { common } from './common.js'; import type * as Misskey from 'misskey-js'; +import type { Component } from 'vue'; import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; @@ -26,13 +27,38 @@ import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; export async function mainBoot() { - const { isClientUpdated } = await common(() => createApp( - new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : - !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : - ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : - ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : - defineAsyncComponent(() => import('@/ui/universal.vue')), - )); + const { isClientUpdated } = await common(() => { + let uiStyle = ui; + const searchParams = new URLSearchParams(window.location.search); + + if (!$i) uiStyle = 'visitor'; + + if (searchParams.has('zen')) uiStyle = 'zen'; + if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen'; + + if (searchParams.has('ui')) uiStyle = searchParams.get('ui'); + + let rootComponent: Component; + switch (uiStyle) { + case 'zen': + rootComponent = defineAsyncComponent(() => import('@/ui/zen.vue')); + break; + case 'deck': + rootComponent = defineAsyncComponent(() => import('@/ui/deck.vue')); + break; + case 'visitor': + rootComponent = defineAsyncComponent(() => import('@/ui/visitor.vue')); + break; + case 'classic': + rootComponent = defineAsyncComponent(() => import('@/ui/classic.vue')); + break; + default: + rootComponent = defineAsyncComponent(() => import('@/ui/universal.vue')); + break; + } + + return createApp(rootComponent); + }); reactionPicker.init(); emojiPicker.init(); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index c28dbc7ffa..564d1fe7e3 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> @@ -77,8 +77,8 @@ import MkPostForm from '@/components/MkPostForm.vue'; const props = withDefaults(defineProps<{ component: AsUiComponent; components: Ref<AsUiComponent>[]; - size: 'small' | 'medium' | 'large'; - align: 'left' | 'center' | 'right'; + size?: 'small' | 'medium' | 'large'; + align?: 'left' | 'center' | 'right'; }>(), { size: 'medium', align: 'left', @@ -86,7 +86,7 @@ const props = withDefaults(defineProps<{ const c = props.component; -function g(id) { +function g(id: string) { const v = props.components.find(x => x.value.id === id)?.value; if (v) return v; @@ -122,13 +122,22 @@ const containerStyle = computed(() => { const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false); -function onSwitchUpdate(v) { +function onSwitchUpdate(v: boolean) { valueForSwitch.value = v; if ('onChange' in c && c.onChange) { c.onChange(v as never); } } +const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null); + +function onSelectUpdate(v) { + valueForSelect.value = v; + if ('onChange' in c && c.onChange) { + c.onChange(v as never); + } +} + function openPostForm() { const form = (c as AsUiPostFormButton).form; if (!form) return; diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index e9493edbd1..aeed90722f 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount import { defaultStore } from '@/store.js'; // APIs provided by Captcha services +// see: https://docs.hcaptcha.com/configuration/#javascript-api +// see: https://developers.google.com/recaptcha/docs/display?hl=ja +// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget export type Captcha = { render(container: string | Node, options: { readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; @@ -56,6 +59,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; sitekey: string | null; // null will show error on request + secretKey?: string | null; instanceUrl?: string | null; modelValue?: string | null; }>(); @@ -67,7 +71,7 @@ const emit = defineEmits<{ const available = ref(false); const captchaEl = shallowRef<HTMLDivElement | undefined>(); - +const captchaWidgetId = ref<string | undefined>(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -99,6 +103,15 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); +watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { + // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない + if (available.value) { + callback(undefined); + clearWidget(); + await requestRender(); + } +}); + if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { @@ -111,14 +124,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') } function reset() { - if (captcha.value.reset) captcha.value.reset(); + if (captcha.value.reset && captchaWidgetId.value !== undefined) { + try { + captcha.value.reset(captchaWidgetId.value); + } catch (error: unknown) { + // ignore + if (_DEV_) console.warn(error); + } + } testcaptchaPassed.value = false; testcaptchaInput.value = ''; } +function remove() { + if (captcha.value.remove && captchaWidgetId.value) { + try { + if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value); + captcha.value.remove(captchaWidgetId.value); + } catch (error: unknown) { + // ignore + if (_DEV_) console.warn(error); + } + } +} + async function requestRender() { - if (captcha.value.render && captchaEl.value instanceof Element) { - captcha.value.render(captchaEl.value, { + if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { + // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. + // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) + const elem = document.createElement('div'); + captchaEl.value.appendChild(elem); + + captchaWidgetId.value = captcha.value.render(elem, { sitekey: props.sitekey, theme: defaultStore.state.darkMode ? 'dark' : 'light', callback: callback, @@ -146,6 +183,23 @@ async function requestRender() { } } +function clearWidget() { + if (props.provider === 'mcaptcha') { + const container = document.getElementById('mcaptcha__widget-container'); + if (container) { + container.innerHTML = ''; + } + } else { + reset(); + remove(); + + if (captchaEl.value) { + // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 + captchaEl.value.innerHTML = ''; + } + } +} + function callback(response?: string) { emit('update:modelValue', typeof response === 'string' ? response : null); } @@ -178,7 +232,7 @@ onUnmounted(() => { }); onBeforeUnmount(() => { - reset(); + clearWidget(); }); defineExpose({ diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index e036fec528..7ff9da1ced 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -125,7 +125,9 @@ const bannerStyle = computed(() => { position: absolute; top: 16px; left: 16px; + max-width: calc(100% - 32px); padding: 12px 16px; + box-sizing: border-box; background: rgba(0, 0, 0, 0.7); color: #fff; font-size: 1.2em; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index f4d20c7d8c..30a9b26bef 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]"> <slot></slot> - <button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }"> + <button v-if="omitted" :class="$style.fade" class="_button" @click="showMore"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> </div> @@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{ thin?: boolean; naked?: boolean; foldable?: boolean; + onUnfold?: () => boolean; // return false to prevent unfolding scrollable?: boolean; expanded?: boolean; maxHeight?: number | null; @@ -101,6 +102,13 @@ const omitObserver = new ResizeObserver((entries, observer) => { calcOmit(); }); +function showMore() { + if (props.onUnfold && !props.onUnfold()) return; + + ignoreOmit.value = true; + omitted.value = false; +} + onMounted(() => { watch(showBody, v => { if (!rootEl.value) return; diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index ecbee864dc..e6ab17417d 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { defineProps, shallowRef } from 'vue'; +import { shallowRef } from 'vue'; import MkLink from '@/components/MkLink.vue'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 1079e52030..5ba5de0c4a 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -5,13 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - ref="thumbnail" - :class="[ - $style.root, - { [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive }, - ]" + v-panel + :class="[$style.root, { + [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive, + [$style.large]: large, + }]" > - <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> + <ImgWithBlurhash + v-if="isThumbnailAvailable" + :hash="file.blurhash" + :src="file.thumbnailUrl" + :alt="file.name" + :title="file.name" + :cover="fit !== 'contain'" + :forceBlurhash="forceBlurhash" + /> <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i> <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i> @@ -34,6 +42,8 @@ const props = defineProps<{ file: Misskey.entities.DriveFile; fit: 'cover' | 'contain'; highlightWhenSensitive?: boolean; + forceBlurhash?: boolean; + large?: boolean; }>(); const is = computed(() => { @@ -60,7 +70,7 @@ const is = computed(() => { const isThumbnailAvailable = computed(() => { return props.file.thumbnailUrl - ? (is.value === 'image' as const || is.value === 'video') + ? (is.value === 'image' || is.value === 'video') : false; }); </script> @@ -101,4 +111,8 @@ const isThumbnailAvailable = computed(() => { font-size: 32px; color: #777; } + +.large .icon { + font-size: 40px; +} </style> diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 0b4114d252..084c81bb52 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22"> + <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> <slot></slot> </MkSpacer> <div v-else> @@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{ defaultOpen?: boolean; maxHeight?: number | null; withSpacer?: boolean; + spacerMin?: number; + spacerMax?: number; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, + spacerMin: 14, + spacerMax: 22, }); const rootEl = shallowRef<HTMLElement>(); diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index f409f6ce50..96214a9542 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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> - <MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton primary rounded :disabled="!canSaving" @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> </template> @@ -18,7 +18,7 @@ import { } from 'vue'; import MkButton from './MkButton.vue'; import { i18n } from '@/i18n.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ form: { modifiedCount: { value: number; @@ -26,7 +26,10 @@ const props = defineProps<{ discard: () => void; save: () => void; }; -}>(); + canSaving?: boolean; +}>(), { + canSaving: true, +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 8ccbf61e48..d8066857fe 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.chart"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="i18n.ts.federation"> + <optgroup v-if="shouldShowFederation" :label="i18n.ts.federation"> <option value="federation">{{ i18n.ts._charts.federation }}</option> <option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> </optgroup> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <optgroup :label="i18n.ts.notes"> <option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> <option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> + <option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> <option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option> </optgroup> <optgroup :label="i18n.ts.drive"> @@ -46,9 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> <option value="active-users">Active users</option> <option value="notes">Notes</option> - <option value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> + <option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option> + <option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> + <option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> </MkSelect> <div class="_panel" :class="$style.heatmap"> <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> - <MkFoldableSection class="item"> + <MkFoldableSection v-if="shouldShowFederation" class="item"> <template #header>Federation</template> <div :class="$style.federation"> <div class="pies"> @@ -84,13 +84,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef } from 'vue'; +import { onMounted, ref, computed, shallowRef } from 'vue'; import { Chart } from 'chart.js'; import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; +import { $i } from '@/account.js'; import * as os from '@/os.js'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; @@ -100,6 +102,8 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); +const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator); + const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 2a8d5c9f71..9d9cc76822 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -4,19 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" :style="bg"> +<div :class="$style.root" :style="themeColorStyle"> <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/> - <div :class="$style.name">{{ instance.name }}</div> + <div :class="$style.name">{{ instanceName }}</div> </div> </template> <script lang="ts" setup> -import { computed } from 'vue'; -import { instanceName } from '@@/js/config.js'; -import { instance as Instance } from '@/instance.js'; +import { computed, type CSSProperties } from 'vue'; +import { instanceName as localInstanceName } from '@@/js/config.js'; +import { instance as localInstance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ + host: string | null; instance?: { faviconUrl?: string | null name?: string | null @@ -25,18 +26,28 @@ const props = defineProps<{ }>(); // if no instance data is given, this is for the local instance -const instance = props.instance ?? { - name: instanceName, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, -}; +const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host); -const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico'); - -const themeColor = instance.themeColor ?? '#777777'; +const faviconUrl = computed(() => { + let imageSrc: string | null = null; + if (props.host == null) { + if (localInstance.iconUrl == null) { + return '/favicon.ico'; + } else { + imageSrc = localInstance.iconUrl; + } + } else { + imageSrc = props.instance?.faviconUrl ?? null; + } + return getProxiedImageUrlNullable(imageSrc); +}); -const bg = { - background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, -}; +const themeColorStyle = computed<CSSProperties>(() => { + const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777'; + return { + background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, + }; +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index f64ca4bc77..ac50d82a63 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { toUnicode } from 'punycode'; +import { toUnicode } from 'punycode.js'; import { computed } from 'vue'; import { host as localHost } from '@@/js/config.js'; import { $i } from '@/account.js'; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index c766a33823..a446dad0ab 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -288,20 +288,23 @@ const align = () => { const onOpened = () => { emit('opened'); - // NOTE: Chromatic テストの際に undefined になる場合がある - if (content.value == null) return; + // contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要) + nextTick(() => { + // NOTE: Chromatic テストの際に undefined になる場合がある + if (content.value == null) return; - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const el = content.value.children[0]; - el.addEventListener('mousedown', ev => { - contentClicking = true; - window.addEventListener('mouseup', ev => { - // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ - window.setTimeout(() => { - contentClicking = false; - }, 100); - }, { passive: true, once: true }); - }, { passive: true }); + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const el = content.value.children[0]; + el.addEventListener('mousedown', ev => { + contentClicking = true; + window.addEventListener('mouseup', ev => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + window.setTimeout(() => { + contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }); }; const onClosed = () => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 69c6b4f357..9271e9e4b7 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <MkNoteHeader :note="appearNote" :mini="true" @click.stop/> - <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> + <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <bdi> <p v-if="appearNote.cw != null" :class="$style.cw"> @@ -100,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/> </div> @@ -179,13 +179,23 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </template> </I18n> - <I18n v-else :src="i18n.ts.userSaysSomething" tag="small"> + <I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> </MkA> </template> </I18n> + <I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + <template #word> + {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }} + </template> + </I18n> </div> <div v-else> <!-- @@ -319,6 +329,7 @@ const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); @@ -343,13 +354,18 @@ const renoteTooltip = computeRenoteTooltip(renoted); /* Overload FunctionにLintが対応していないのでコメントアウト function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute'; */ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' { +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' { if (mutedWords != null) { - if (checkWordMute(noteToCheck, $i, mutedWords)) return true; - if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true; - if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true; + const result = checkWordMute(noteToCheck, $i, mutedWords); + if (Array.isArray(result)) return result; + + const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); + if (Array.isArray(replyResult)) return replyResult; + + const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); + if (Array.isArray(renoteResult)) return renoteResult; } if (checkOnly) return false; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 2eb4431de2..6c52714f46 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only <img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/> </div> </div> - <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> + <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> </div> </header> <div :class="$style.noteContent"> @@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/> </div> diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue new file mode 100644 index 0000000000..bf105c3c27 --- /dev/null +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -0,0 +1,109 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> + <template v-for="file in note.files"> + <div + v-if="((( + (defaultStore.state.nsfw === 'force' || file.isSensitive) && + defaultStore.state.nsfw !== 'ignore' + ) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) && + !showingFiles.has(file.id) + )" + :class="[$style.filePreview, { [$style.square]: square }]" + @click="showingFiles.add(file.id)" + > + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" + :forceBlurhash="true" + :large="true" + :class="$style.file" + /> + <div :class="$style.sensitive"> + <div> + <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> + <div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> + <div>{{ i18n.ts.clickToShow }}</div> + </div> + </div> + </div> + <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)"> + <MkDriveFileThumbnail + :file="file" + fit="cover" + :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" + :large="true" + :class="$style.file" + /> + </MkA> + </template> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { notePage } from '@/filters/note.js'; +import { i18n } from '@/i18n.js'; +import * as Misskey from 'misskey-js'; +import { defaultStore } from '@/store.js'; +import bytes from '@/filters/bytes.js'; + +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; + +defineProps<{ + note: Misskey.entities.Note; + square?: boolean; +}>(); + +const showingFiles = ref<Set<string>>(new Set()); +</script> + +<style lang="scss" module> +.square { + width: 100%; + height: auto; + aspect-ratio: 1; +} + +.filePreview { + position: relative; + height: 128px; + border-radius: calc(var(--MI-radius) / 2); + overflow: clip; + + &:hover { + text-decoration: none; + } + + &.square { + height: 100%; + } +} + +.file { + width: 100%; + height: 100%; + border-radius: calc(var(--MI-radius) / 2); +} + +.sensitive { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: grid; + place-items: center; + font-size: 0.8em; + text-align: center; + padding: 8px; + box-sizing: border-box; + color: #fff; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(5px); + cursor: pointer; +} +</style> diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index b13df2813b..bd157d0b14 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef, ref } from 'vue'; +import { defineAsyncComponent, shallowRef } from 'vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 84189211b6..3ff4cc215c 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import { url } from '@@/js/config.js'; import { getScrollContainer } from '@@/js/scroll.js'; +import MkUserName from './global/MkUserName.vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; @@ -43,7 +44,6 @@ import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { useRouterFactory } from '@/router/supplier.js'; import { mainRouter } from '@/router/main.js'; -import MkUserName from './global/MkUserName.vue'; const props = defineProps<{ initialPath: string; diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue new file mode 100644 index 0000000000..fe59efd83a --- /dev/null +++ b/packages/frontend/src/components/MkPagingButtons.vue @@ -0,0 +1,124 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <MkButton primary :disabled="min === current" @click="onToPrevButtonClicked"><</MkButton> + + <div :class="$style.buttons"> + <div v-if="prevDotVisible" :class="$style.headTailButtons"> + <MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton> + <span class="ti ti-dots"/> + </div> + + <MkButton + v-for="i in buttonRanges" :key="i" + :disabled="current === i" + @click="onNumberButtonClicked(i)" + > + {{ i }} + </MkButton> + + <div v-if="nextDotVisible" :class="$style.headTailButtons"> + <span class="ti ti-dots"/> + <MkButton @click="onToTailButtonClicked">{{ max }}</MkButton> + </div> + </div> + + <MkButton primary :disabled="max === current" @click="onToNextButtonClicked">></MkButton> +</div> +</template> + +<script setup lang="ts"> + +import { computed, toRefs } from 'vue'; +import MkButton from '@/components/MkButton.vue'; + +const min = 1; + +const emit = defineEmits<{ + (ev: 'pageChanged', pageNumber: number): void; +}>(); + +const props = defineProps<{ + current: number; + max: number; + buttonCount: number; +}>(); + +const { current, max } = toRefs(props); + +const buttonCount = computed(() => Math.min(max.value, props.buttonCount)); +const buttonCountHalf = computed(() => Math.floor(buttonCount.value / 2)); +const buttonCountStart = computed(() => Math.min(Math.max(min, current.value - buttonCountHalf.value), max.value - buttonCount.value + 1)); +const buttonRanges = computed(() => Array.from({ length: buttonCount.value }, (_, i) => buttonCountStart.value + i)); + +const prevDotVisible = computed(() => (current.value - 1 > buttonCountHalf.value) && (max.value > buttonCount.value)); +const nextDotVisible = computed(() => (current.value < max.value - buttonCountHalf.value) && (max.value > buttonCount.value)); + +if (_DEV_) { + console.log('[MkPagingButtons]', current.value, max.value, buttonCount.value, buttonCountHalf.value); + console.log('[MkPagingButtons]', current.value < max.value - buttonCountHalf.value); + console.log('[MkPagingButtons]', max.value > buttonCount.value); +} + +function onNumberButtonClicked(pageNumber: number) { + emit('pageChanged', pageNumber); +} + +function onToHeadButtonClicked() { + emit('pageChanged', min); +} + +function onToPrevButtonClicked() { + const newPageNumber = current.value <= min ? min : current.value - 1; + emit('pageChanged', newPageNumber); +} + +function onToNextButtonClicked() { + const newPageNumber = current.value >= max.value ? max.value : current.value + 1; + emit('pageChanged', newPageNumber); +} + +function onToTailButtonClicked() { + emit('pageChanged', max.value); +} +</script> + +<style module lang="scss"> +.root { + display: flex; + justify-content: center; + align-items: center; + gap: 24px; + + button { + border-radius: 9999px; + min-width: 2.5em; + min-height: 2.5em; + max-width: 2.5em; + max-height: 2.5em; + padding: 4px; + } +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.headTailButtons { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + span { + font-size: 0.75em; + } +} +</style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index a414676bda..f6218de4c8 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template> - <Mfm :text="choice.text" :plain="true"/> + <Mfm :text="choice.text" :plain="true" :author="author" :emojiUrls="emojiUrls"/> <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span> </span> </li> @@ -48,6 +48,8 @@ const props = defineProps<{ poll: NonNullable<Misskey.entities.Note['poll']>; readOnly?: boolean; local?: boolean; + emojiUrls?: Record<string, string>; + author?: Misskey.entities.UserLite; }>(); const remaining = ref(-1); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 6f057ed5eb..059de8011c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -46,14 +46,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="posted"></template> <template v-else-if="posting"><MkEllipsis/></template> <template v-else>{{ submitText }}</template> - <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i> + <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i> </div> </button> </div> </header> <MkNoteSimple v-if="reply" :class="$style.targetNote" :hideFiles="true" :note="reply"/> - <MkNoteSimple v-if="renote" :class="$style.targetNote" :hideFiles="true" :note="renote"/> - <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div> + <MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :hideFiles="true" :note="renoteTargetNote"/> + <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div> <div v-if="visibility === 'specified'" :class="$style.toSpecified"> <span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span> <div :class="$style.visibleUsers"> @@ -106,13 +106,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; -import { toASCII } from 'punycode/'; +import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; +import type { PostFormProps } from '@/types/post-form.js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; @@ -136,7 +137,6 @@ import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; -import type { PostFormProps } from '@/types/post-form.js'; import MkScheduleEditor from '@/components/MkScheduleEditor.vue'; const $i = signinRequired(); @@ -202,12 +202,13 @@ const justEndedComposition = ref(false); const scheduleNote = ref<{ scheduledAt: number | null; } | null>(null); +const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; - if (props.renote) { - key += `renote:${props.renote.id}`; + if (renoteTargetNote.value) { + key += `renote:${renoteTargetNote.value.id}`; } else if (props.reply) { key += `reply:${props.reply.id}`; } else { @@ -218,7 +219,7 @@ const draftKey = computed((): string => { }); const placeholder = computed((): string => { - if (props.renote) { + if (renoteTargetNote.value) { return i18n.ts._postForm.quotePlaceholder; } else if (props.reply) { return i18n.ts._postForm.replyPlaceholder; @@ -238,7 +239,7 @@ const placeholder = computed((): string => { }); const submitText = computed((): string => { - return props.renote + return renoteTargetNote.value ? i18n.ts.quote : props.reply ? i18n.ts.reply @@ -262,11 +263,12 @@ const canPost = computed((): boolean => { 1 <= textLength.value || 1 <= files.value.length || poll.value != null || - props.renote != null || + renoteTargetNote.value != null || quoteId.value != null ) && (textLength.value <= maxTextLength.value) && (cwLength.value <= maxCwLength.value) && + (files.value.length <= 16) && (!poll.value || poll.value.choices.length >= 2); }); @@ -647,7 +649,7 @@ async function onPaste(ev: ClipboardEvent) { const paste = ev.clipboardData.getData('text'); - if (!props.renote && !quoteId.value && paste.startsWith(url + '/notes/')) { + if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) { ev.preventDefault(); os.confirm({ @@ -863,7 +865,7 @@ async function post(ev?: MouseEvent) { text: text.value === '' ? null : text.value, fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined, replyId: props.reply ? props.reply.id : undefined, - renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined, + renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined, channelId: props.channel ? props.channel.id : undefined, poll: poll.value, cw: useCw.value ? cw.value ?? '' : null, @@ -953,7 +955,7 @@ async function post(ev?: MouseEvent) { claimAchievement('brainDiver'); } - if (props.renote && (props.renote.userId === $i.id) && text.length > 0) { + if (renoteTargetNote.value && (renoteTargetNote.value.userId === $i.id) && text.length > 0) { claimAchievement('selfQuote'); } @@ -1163,7 +1165,7 @@ onMounted(() => { users.forEach(u => pushVisibleUser(u)); }); } - quoteId.value = init.renote ? init.renote.id : null; + quoteId.value = renoteTargetNote.value ? renoteTargetNote.value.id : null; reactionAcceptance.value = init.reactionAcceptance; if (init.isSchedule) { scheduleNote.value = { diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 11444d8d78..bab7d22112 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> </Sortable> - <p :class="$style.remain">{{ 16 - props.modelValue.length }}/16</p> + <p :class="[$style.remain, { + [$style.exceeded]: props.modelValue.length > 16, + }]">{{ 16 - props.modelValue.length }}/16</p> </div> </template> @@ -239,5 +241,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar margin: 0; padding: 0; font-size: 90%; + + &.exceeded { + color: var(--MI_THEME-error); + } } </style> diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue new file mode 100644 index 0000000000..873b276b3d --- /dev/null +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -0,0 +1,132 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkWindow + ref="windowEl" + :initialWidth="400" + :initialHeight="500" + :canResize="true" + @close="windowEl?.close()" + @closed="emit('closed')" +> + <template #header>:{{ name }}:</template> + + <div style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <div class="_gaps_m"> + <div v-if="imgUrl != null" :class="$style.imgs"> + <div style="background: #000;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img" :alt="name"/> + </div> + <div style="background: #222;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img" :alt="name"/> + </div> + <div style="background: #ddd;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img" :alt="name"/> + </div> + <div style="background: #fff;" :class="$style.imgContainer"> + <img :src="imgUrl" :class="$style.img" :alt="name"/> + </div> + </div> + + <MkKeyValue> + <template #key>{{ i18n.ts.id }}</template> + <template #value>{{ name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.host }}</template> + <template #value>{{ host }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.license }}</template> + <template #value>{{ license }}</template> + </MkKeyValue> + </div> + </MkSpacer> + <div :class="$style.footer"> + <MkButton primary rounded style="margin: 0 auto;" @click="done"> + <i class="ti ti-plus"></i> {{ i18n.ts.import }} + </MkButton> + </div> + </div> +</MkWindow> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkWindow from '@/components/MkWindow.vue'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +const props = defineProps<{ + emoji: { + id: string, + name: string, + host: string, + license: string | null, + url: string + }, +}>(); + +const emit = defineEmits<{ + // 必要なら戻り値を増やす + (ev: 'done'): void, + (ev: 'closed'): void +}>(); + +const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); + +const name = computed(() => props.emoji.name); +const host = computed(() => props.emoji.host); +const license = computed(() => props.emoji.license); +const imgUrl = computed(() => props.emoji.url); + +async function done() { + await os.apiWithDialog('admin/emoji/copy', { + emojiId: props.emoji.id, + }); + + emit('done'); + windowEl.value?.close(); +} +</script> + +<style lang="scss" module> +.imgs { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.imgContainer { + padding: 8px; + border-radius: 6px; +} + +.img { + display: block; + height: 64px; + width: 64px; + object-fit: contain; +} + +.footer { + position: sticky; + z-index: 10000; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-acrylicBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); +} +</style> diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts new file mode 100644 index 0000000000..411d62edf9 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import { role } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue'; + +const roles = [ + role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), + role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'), + role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'), + role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'), +]; + +export const Default = { + render(args) { + return { + components: { + MkRoleSelectDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkRoleSelectDialog v-bind="props" />', + }; + }, + args: { + initialRoleIds: undefined, + infoMessage: undefined, + title: undefined, + publicOnly: true, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/admin/roles/list', ({ params }) => { + return HttpResponse.json(roles); + }), + ], + }, + }, + decorators: [() => ({ + template: '<div style="width:100cqmin"><story/></div>', + })], +} satisfies StoryObj<typeof MkRoleSelectDialog>; + +export const InitialIds = { + ...Default, + args: { + ...Default.args, + initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id], + }, +} satisfies StoryObj<typeof MkRoleSelectDialog>; + +export const InfoMessage = { + ...Default, + args: { + ...Default.args, + infoMessage: 'This is a message.', + }, +} satisfies StoryObj<typeof MkRoleSelectDialog>; + +export const Title = { + ...Default, + args: { + ...Default.args, + title: 'Select roles', + }, +} satisfies StoryObj<typeof MkRoleSelectDialog>; + +export const Full = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + }, +} satisfies StoryObj<typeof MkRoleSelectDialog>; + +export const FullWithPrivate = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + publicOnly: false, + }, +} satisfies StoryObj<typeof MkRoleSelectDialog>; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue new file mode 100644 index 0000000000..8d11bd855f --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -0,0 +1,200 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="windowEl" + :withOkButton="false" + :okButtonDisabled="false" + :width="400" + :height="500" + @close="onCloseModalWindow" + @closed="$emit('dispose')" +> + <template #header>{{ title }}</template> + <MkSpacer :marginMin="20" :marginMax="28"> + <MkLoading v-if="fetching"/> + <div v-else class="_gaps" :class="$style.root"> + <div :class="$style.header"> + <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + </div> + + <div v-if="selectedRoles.length > 0" class="_gaps" :class="$style.roleItemArea"> + <div v-for="role in selectedRoles" :key="role.id" :class="$style.roleItem"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/> + <button class="_button" :class="$style.roleUnAssign" @click="removeRole(role.id)"><i class="ti ti-x"></i></button> + </div> + </div> + <div v-else :class="$style.roleItemArea" style="text-align: center"> + {{ i18n.ts._roleSelectDialog.notSelected }} + </div> + + <MkInfo v-if="infoMessage">{{ infoMessage }}</MkInfo> + + <div :class="$style.buttons"> + <MkButton primary @click="onOkClicked">{{ i18n.ts.ok }}</MkButton> + <MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton> + </div> + </div> + </MkSpacer> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { computed, ref, toRefs } from 'vue'; +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import * as os from '@/os.js'; +import MkSpacer from '@/components/global/MkSpacer.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkLoading from '@/components/global/MkLoading.vue'; + +const emit = defineEmits<{ + (ev: 'done', value: Misskey.entities.Role[]), + (ev: 'close'), + (ev: 'dispose'), +}>(); + +const props = withDefaults(defineProps<{ + initialRoleIds?: string[], + infoMessage?: string, + title?: string, + publicOnly: boolean, +}>(), { + initialRoleIds: undefined, + infoMessage: undefined, + title: undefined, + publicOnly: true, +}); + +const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props); + +const windowEl = ref<InstanceType<typeof MkModalWindow>>(); +const roles = ref<Misskey.entities.Role[]>([]); +const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []); +const fetching = ref(false); + +const selectedRoles = computed(() => { + const r = roles.value.filter(role => selectedRoleIds.value.includes(role.id)); + r.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); + return r; +}); + +async function fetchRoles() { + fetching.value = true; + const result = await misskeyApi('admin/roles/list', {}); + roles.value = result.filter(it => publicOnly.value ? it.isPublic : true); + fetching.value = false; +} + +async function addRole() { + const items = roles.value + .filter(r => r.isPublic) + .filter(r => !selectedRoleIds.value.includes(r.id)) + .map(r => ({ text: r.name, value: r })); + + const { canceled, result: role } = await os.select({ items }); + if (canceled) { + return; + } + + selectedRoleIds.value.push(role.id); +} + +async function removeRole(roleId: string) { + selectedRoleIds.value = selectedRoleIds.value.filter(x => x !== roleId); +} + +function onOkClicked() { + emit('done', selectedRoles.value); + windowEl.value?.close(); +} + +function onCancelClicked() { + emit('close'); + windowEl.value?.close(); +} + +function onCloseModalWindow() { + emit('close'); + windowEl.value?.close(); +} + +fetchRoles(); +</script> + +<style module lang="scss"> +.root { + max-height: 410px; + height: 410px; + display: flex; + flex-direction: column; +} + +.roleItemArea { + background-color: var(--MI_THEME-acrylicBg); + border-radius: var(--MI-radius); + padding: 12px; + overflow-y: auto; +} + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnAssign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} + +.header { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.title { + flex: 1; +} + +.addRoleButton { + min-width: 32px; + min-height: 32px; + max-width: 32px; + max-height: 32px; + margin-left: 8px; + align-self: center; + padding: 0; +} + +.buttons { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: auto; +} + +.divider { + border-top: solid 0.5px var(--MI_THEME-divider); +} + +</style> diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index 34c22abc31..e98ac9cfd2 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref } from 'vue'; -import { toUnicode } from 'punycode/'; +import { toUnicode } from 'punycode.js'; import { query, extractDomain } from '@@/js/url.js'; import { host as configHost } from '@@/js/config.js'; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 4a6219071b..d6177762d2 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -141,6 +141,7 @@ function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void { return; } emit('login', res.signinResponse); + onLoginSucceeded(res.signinResponse); }).catch(onSigninApiError); } else if (userInfo.value != null) { tryLogin({ diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index e636712389..3560bebace 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -85,7 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; -import { toUnicode } from 'punycode/'; +import { toUnicode } from 'punycode.js'; import * as Misskey from 'misskey-js'; import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index 06481b808c..d1685c6990 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -10,8 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps_m"> - <div v-if="instance.disableRegistration"> - <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + <div v-if="instance.disableRegistration || instance.federation !== 'all'" class="_gaps_s"> + <MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + <MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo> + <MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo> </div> <div style="text-align: center;"> diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts new file mode 100644 index 0000000000..f023b5d72b --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type SortOrderDirection = '+' | '-' + +export type SortOrder<T extends string> = { + key: T; + direction: SortOrderDirection; +} diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue new file mode 100644 index 0000000000..9decacc5f5 --- /dev/null +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -0,0 +1,118 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.sortOrderArea"> + <div :class="$style.sortOrderAreaTags"> + <MkTagItem + v-for="order in currentOrders" + :key="order.key" + :iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'" + :exButtonIconClass="'ti ti-x'" + :content="order.key" + :class="$style.sortOrderTag" + @click="onToggleSortOrderButtonClicked(order)" + @exButtonClick="onRemoveSortOrderButtonClicked(order)" + /> + </div> + <MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked"> + <span class="ti ti-plus"></span> + </MkButton> +</div> +</template> + +<script setup lang="ts" generic="T extends string"> +import { toRefs } from 'vue'; +import MkTagItem from '@/components/MkTagItem.vue'; +import MkButton from '@/components/MkButton.vue'; +import { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; +import { SortOrder } from '@/components/MkSortOrderEditor.define.js'; + +const emit = defineEmits<{ + (ev: 'update', sortOrders: SortOrder<T>[]): void; +}>(); + +const props = defineProps<{ + baseOrderKeyNames: T[]; + currentOrders: SortOrder<T>[]; +}>(); + +const { currentOrders } = toRefs(props); + +function onToggleSortOrderButtonClicked(order: SortOrder<T>) { + switch (order.direction) { + case '+': + order.direction = '-'; + break; + case '-': + order.direction = '+'; + break; + } + + emitOrder(currentOrders.value); +} + +function onAddSortOrderButtonClicked(ev: MouseEvent) { + const menuItems: MenuItem[] = props.baseOrderKeyNames + .filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey)) + .map(it => { + return { + text: it, + action: () => { + emitOrder([...currentOrders.value, { key: it, direction: '+' }]); + }, + }; + }); + os.contextMenu(menuItems, ev); +} + +function onRemoveSortOrderButtonClicked(order: SortOrder<T>) { + emitOrder(currentOrders.value.filter(it => it.key !== order.key)); +} + +function emitOrder(sortOrders: SortOrder<T>[]) { + emit('update', sortOrders); +} + +</script> + +<style module lang="scss"> +.sortOrderArea { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: flex-start; +} + +.sortOrderAreaTags { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px; +} + +.sortOrderAddButton { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + min-width: 2.0em; + min-height: 2.0em; + max-width: 2.0em; + max-height: 2.0em; + padding: 8px; + margin-left: auto; + border-radius: 9999px; + background-color: var(--MI_THEME-buttonBg); +} + +.sortOrderTag { + user-select: none; + cursor: pointer; +} +</style> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index 8491ce2f84..b3fc67c0df 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -39,32 +39,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> <!-- MFMで上位レイヤーに表示されるため、リンクをクリックできるようにstyleにpointer-events: none;を付与。 --> <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px; pointer-events: none;"> + <!-- SVGのanimateTransformを使用するとChromeで描画できなくなるためCSSアニメーションを使用している (Issue 14155) --> <path - style="transform-origin: center; transform-box: fill-box;" - :transform="`translate(${particle.x} ${particle.y})`" + :style="{ + '--translateX': particle.x + 'px', + '--translateY': particle.y + 'px', + '--duration': particle.dur + 'ms', + '--size': particle.size, + }" + :class="$style.particle" :fill="particle.color" d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z" - > - <animateTransform - attributeName="transform" - attributeType="XML" - type="rotate" - from="0 0 0" - to="360 0 0" - :dur="`${particle.dur}ms`" - repeatCount="1" - additive="sum" - /> - <animateTransform - attributeName="transform" - attributeType="XML" - type="scale" - :values="`0; ${particle.size}; 0`" - :dur="`${particle.dur}ms`" - repeatCount="1" - additive="sum" - /> - </path> + ></path> </svg> </span> </template> @@ -130,4 +116,25 @@ onUnmounted(() => { position: relative; display: inline-block; } + +.particle { + transform-origin: center; + transform-box: fill-box; + translate: var(--translateX) var(--translateY); + animation: particleAnimation var(--duration) linear infinite; +} + +@keyframes particleAnimation { + 0% { + rotate: 0deg; + scale: 0; + } + 50% { + scale: var(--size); + } + 100% { + rotate: 360deg; + scale: 0; + } +} </style> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index a32fd53c51..145de3b9d3 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> - <MkPoll :noteId="note.id" :poll="note.poll"/> + <MkPoll :noteId="note.id" :poll="note.poll" :author="note.user" :emojiUrls="note.emojis"/> </details> <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click.stop="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> @@ -42,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as mfm from '@transfem-org/sfm-js'; +import { shouldCollapsed } from '@@/js/collapsed.js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { shouldCollapsed } from '@@/js/collapsed.js'; import { defaultStore } from '@/store.js'; import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index c9c173aa35..56e8fcfa37 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -47,7 +47,7 @@ export type SuperMenuDef = { active?: boolean; action: (ev: MouseEvent) => void; } | { - type: 'link'; + type?: 'link'; to: string; icon?: string; text: string; diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts new file mode 100644 index 0000000000..3f243ff651 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import MkTagItem from './MkTagItem.vue'; + +export const Default = { + render(args) { + return { + components: { + MkTagItem: MkTagItem, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + click: action('click'), + exButtonClick: action('exButtonClick'), + }; + }, + }, + template: '<MkTagItem v-bind="props" v-on="events"></MkTagItem>', + }; + }, + args: { + content: 'name', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkTagItem>; + +export const Icon = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + }, +} satisfies StoryObj<typeof MkTagItem>; + +export const ExButton = { + ...Default, + args: { + ...Default.args, + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj<typeof MkTagItem>; + +export const IconExButton = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj<typeof MkTagItem>; diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue new file mode 100644 index 0000000000..8b7460f3a3 --- /dev/null +++ b/packages/frontend/src/components/MkTagItem.vue @@ -0,0 +1,76 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" @click="(ev) => emit('click', ev)"> + <span v-if="iconClass" :class="[$style.icon, iconClass]"></span> + <span :class="$style.content">{{ content }}</span> + <MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)"> + <span :class="[$style.exButtonIcon, exButtonIconClass]"></span> + </MkButton> +</div> +</template> + +<script setup lang="ts"> +import MkButton from '@/components/MkButton.vue'; + +const emit = defineEmits<{ + (ev: 'click', payload: MouseEvent): void; + (ev: 'exButtonClick', payload: MouseEvent): void; +}>(); + +defineProps<{ + iconClass?: string; + content: string; + exButtonIconClass?: string +}>(); +</script> + +<style module lang="scss"> +$buttonSize : 1.8em; + +.root { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + padding: 4px 6px; + gap: 3px; + + background-color: var(--MI_THEME-buttonBg); + + &:hover { + background-color: var(--MI_THEME-buttonHoverBg); + } +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.70em; +} + +.exButton { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + max-height: $buttonSize; + max-width: $buttonSize; + min-height: $buttonSize; + min-width: $buttonSize; + padding: 0; + box-sizing: border-box; + font-size: 0.65em; +} + +.exButtonIcon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.80em; +} +</style> diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 85d4666172..63af652cbc 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts.selectUser }}</template> <div> <div :class="$style.form"> - <MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search"> + <MkInput v-if="computedLocalOnly" v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef } from 'vue'; +import { onMounted, ref, computed, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; @@ -70,6 +70,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; import { host as currentHost, hostname } from '@@/js/config.js'; const emit = defineEmits<{ @@ -86,6 +87,8 @@ const props = withDefaults(defineProps<{ localOnly: false, }); +const computedLocalOnly = computed(() => props.localOnly || instance.federation === 'none'); + const username = ref(''); const host = ref(''); const users = ref<Misskey.entities.UserLite[]>([]); @@ -98,10 +101,9 @@ function search() { users.value = []; return; } - misskeyApi('users/search-by-username-and-host', { username: username.value, - host: props.localOnly ? '.' : host.value, + host: computedLocalOnly.value ? '.' : host.value, limit: 10, detail: false, }).then(_users => { @@ -143,7 +145,7 @@ onMounted(() => { }).then(foundUsers => { let _users = foundUsers; _users = _users.filter((u) => { - if (props.localOnly) { + if (computedLocalOnly.value) { return u.host == null; } else { return true; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 54f2ee655c..6d2a44e985 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -18,8 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- eslint-disable-next-line vue/no-v-html --> <div v-html="sanitizeHtml(instance.description) || i18n.ts.headlineMisskey"></div> </div> - <div v-if="instance.disableRegistration" :class="$style.mainWarn"> - <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + <div v-if="instance.disableRegistration || instance.federation !== 'all'" :class="$style.mainWarn" class="_gaps_s"> + <MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + <MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo> + <MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo> </div> <div v-if="instance.approvalRequiredForSignup" :class="$style.mainWarn"> <MkInfo warn>{{ i18n.ts.approvalRequiredToRegister }}</MkInfo> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index b987283a65..3446e3d6e2 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> - <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> + <option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> </MkSelect> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </Sortable> </template> - <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> + <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> </template> @@ -50,13 +50,14 @@ export type DefaultStoredWidget = { </script> <script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, computed } from 'vue'; import { v4 as uuid } from 'uuid'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; -import { widgets as widgetDefs } from '@/widgets/index.js'; +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)); @@ -66,6 +67,16 @@ const props = defineProps<{ edit: boolean; }>(); +const _widgetDefs = computed(() => { + if (instance.federation === 'none') { + return widgetDefs.filter(x => !federationWidgets.includes(x)); + } else { + return widgetDefs; + } +}); + +const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name))); + const emit = defineEmits<{ (ev: 'updateWidgets', widgets: Widget[]): void; (ev: 'addWidget', widget: Widget): void; diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue index 2bfe5cc157..800b3afc65 100644 --- a/packages/frontend/src/components/SkInstanceTicker.vue +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -4,40 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" :style="bg"> +<div :class="$style.root" :style="themeColorStyle"> <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/> - <div :class="$style.name">{{ instance.name }}</div> + <div :class="$style.name">{{ instanceName }}</div> </div> </template> <script lang="ts" setup> -import { computed } from 'vue'; -import { instanceName } from '@@/js/config.js'; -import { instance as Instance } from '@/instance.js'; +import { computed, type CSSProperties } from 'vue'; +import { instanceName as localInstanceName } from '@@/js/config.js'; +import { instance as localInstance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ + host: string | null; instance?: { - faviconUrl?: string - name: string - themeColor?: string + faviconUrl?: string | null + name?: string | null + themeColor?: string | null } }>(); // if no instance data is given, this is for the local instance -const instance = props.instance ?? { - name: instanceName, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, -}; +const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host); -const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); - -const themeColor = instance.themeColor ?? '#777777'; +const faviconUrl = computed(() => { + let imageSrc: string | null = null; + if (props.host == null) { + if (localInstance.iconUrl == null) { + return '/favicon.ico'; + } else { + imageSrc = localInstance.iconUrl; + } + } else { + imageSrc = props.instance?.faviconUrl ?? null; + } + return getProxiedImageUrlNullable(imageSrc); +}); -const bg = { - //background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, - background: `${themeColor}`, -}; +const themeColorStyle = computed<CSSProperties>(() => { + const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777'; + return { + background: `${themeColor}`, + }; +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 7cd5c2e0cf..323ca283bf 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/> </div> @@ -180,13 +180,23 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </template> </I18n> - <I18n v-else :src="i18n.ts.userSaysSomething" tag="small"> + <I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> </MkA> </template> </I18n> + <I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + <template #word> + {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }} + </template> + </I18n> </div> <div v-else> <!-- @@ -319,6 +329,7 @@ const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); @@ -343,13 +354,18 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ /* Overload FunctionにLintが対応していないのでコメントアウト function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute'; */ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' { +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' { if (mutedWords != null) { - if (checkWordMute(noteToCheck, $i, mutedWords)) return true; - if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true; - if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true; + const result = checkWordMute(noteToCheck, $i, mutedWords); + if (Array.isArray(result)) return result; + + const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); + if (Array.isArray(replyResult)) return replyResult; + + const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); + if (Array.isArray(renoteResult)) return renoteResult; } if (checkOnly) return false; diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 7b45885c3e..dc8a5f59b2 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> - <SkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> + <SkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> </div> </div> </header> @@ -120,7 +120,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/> </div> diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue index 6bcc30f6cb..cb50e57132 100644 --- a/packages/frontend/src/components/SkNoteHeader.vue +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -37,7 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> - <div :class="$style.info"><SkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" @click.stop="showOnRemote()"/></div> + <div :class="$style.info"> + <SkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/> + </div> </div> </header> <header v-else :class="$style.classicRoot"> @@ -52,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> </div> - <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" @click.stop="showOnRemote()"/> + <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/> <div :class="$style.classicInfo"> <div v-if="mock"> <MkTime :time="note.createdAt" colored/> @@ -84,6 +86,7 @@ import { popupMenu } from '@/os.js'; import { defaultStore } from '@/store.js'; import { useRouter } from '@/router/supplier.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; const props = defineProps<{ note: Misskey.entities.Note; diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 9a1ac3aca2..2f4141b901 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { toUnicode } from 'punycode/'; +import { toUnicode } from 'punycode.js'; import { host as hostRaw } from '@@/js/config.js'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index aceed17189..9785bc0f07 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, defineAsyncComponent, SetupContext, provide } from 'vue'; +import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import CkFollowMouse from '../CkFollowMouse.vue'; import { host } from '@@/js/config.js'; +import CkFollowMouse from '../CkFollowMouse.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; import MkLink from '@/components/MkLink.vue'; diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 18c97b1bdb..1a424f349f 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -57,13 +57,16 @@ import { scrollToTop } from '@@/js/scroll.js'; import { globalEvents } from '@/events.js'; import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { PageHeaderItem } from '@/types/page-header.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; +import type { PageMetadata } from '@/scripts/page-metadata.js'; const props = withDefaults(defineProps<{ + overridePageMetadata?: PageMetadata; tabs?: Tab[]; tab?: string; actions?: PageHeaderItem[] | null; thin?: boolean; + hideTitle?: boolean; displayMyAvatar?: boolean; displayBackButton?: boolean; }>(), { @@ -76,9 +79,10 @@ const emit = defineEmits<{ const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true); -const pageMetadata = injectReactiveMetadata(); +const injectedPageMetadata = injectReactiveMetadata(); +const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); -const hideTitle = inject('shouldOmitHeaderTitle', false); +const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); const thin_ = props.thin || inject('shouldHeaderThin', false); const el = shallowRef<HTMLElement | undefined>(undefined); @@ -87,7 +91,7 @@ const narrow = ref(false); const hasTabs = computed(() => props.tabs.length > 0); const hasActions = computed(() => props.actions && props.actions.length > 0); const show = computed(() => { - return !hideTitle || hasTabs.value || hasActions.value; + return !hideTitle.value || hasTabs.value || hasActions.value; }); const preventDrag = (ev: TouchEvent) => { diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 8cca47c1db..5196a63635 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; -import { toUnicode as decodePunycode } from 'punycode/'; +import { toUnicode as decodePunycode } from 'punycode.js'; import { url as local } from '@@/js/config.js'; import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue new file mode 100644 index 0000000000..fd289c6cd9 --- /dev/null +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -0,0 +1,35 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> + <div :class="$style.root"> + {{ content }} + </div> +</MkTooltip> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkTooltip from '@/components/MkTooltip.vue'; + +defineProps<{ + showing: boolean; + content: string; + targetElement: HTMLElement; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); +</script> + +<style lang="scss" module> +.root { + font-size: 0.9em; + text-align: left; + text-wrap: normal; +} +</style> diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue new file mode 100644 index 0000000000..e473b7c1af --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -0,0 +1,418 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-if="cell.row.using" + ref="rootEl" + class="mk_grid_td" + :class="$style.cell" + :style="{ maxWidth: cellWidth, minWidth: cellWidth }" + :tabindex="-1" + data-grid-cell + :data-grid-cell-row="cell.row.index" + :data-grid-cell-col="cell.column.index" + @keydown="onCellKeyDown" + @dblclick.prevent="onCellDoubleClick" +> + <div + :class="[ + $style.root, + [(cell.violation.valid || cell.selected) ? {} : $style.error], + [cell.selected ? $style.selected : {}], + // 行が選択されているときは範囲選択色の適用を行側に任せる + [(cell.ranged && !cell.row.ranged) ? $style.ranged : {}], + [needsContentCentering ? $style.center : {}], + ]" + > + <div v-if="!editing" :class="[$style.contentArea]" :style="cellType === 'boolean' ? 'justify-content: center' : ''"> + <div ref="contentAreaEl" :class="$style.content"> + <div v-if="cellType === 'text'"> + {{ cell.value }} + </div> + <div v-if="cellType === 'number'"> + {{ cell.value }} + </div> + <div v-if="cellType === 'date'"> + {{ cell.value }} + </div> + <div v-else-if="cellType === 'boolean'"> + <div :class="[$style.bool, { + [$style.boolTrue]: cell.value === true, + 'ti ti-check': cell.value === true, + }]"></div> + </div> + <div v-else-if="cellType === 'image'"> + <img + :src="cell.value" + :alt="cell.value" + :class="$style.viewImage" + @load="emitContentSizeChanged" + /> + </div> + </div> + </div> + <div v-else ref="inputAreaEl" :class="$style.inputArea"> + <input + v-if="cellType === 'text'" + type="text" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + <input + v-if="cellType === 'number'" + type="number" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + <input + v-if="cellType === 'date'" + type="date" + :class="$style.editingInput" + :value="editingValue" + @input="onInputText" + @mousedown.stop + @contextmenu.stop + /> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'; +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { useTooltip } from '@/scripts/use-tooltip.js'; +import * as os from '@/os.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginEdit', sender: GridCell): void; + (ev: 'operation:endEdit', sender: GridCell): void; + (ev: 'change:value', sender: GridCell, newValue: CellValue): void; + (ev: 'change:contentSize', sender: GridCell, newSize: Size): void; +}>(); +const props = defineProps<{ + cell: GridCell, + rowSetting: GridRowSetting, + bus: GridEventEmitter, +}>(); + +const { cell, bus } = toRefs(props); + +const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>(); +const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); +const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); + +/** 値が編集中かどうか */ +const editing = ref<boolean>(false); +/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */ +const editingValue = ref<CellValue>(undefined); + +const cellWidth = computed(() => cell.value.column.width); +const cellType = computed(() => cell.value.column.setting.type); +const needsContentCentering = computed(() => { + switch (cellType.value) { + case 'boolean': + return true; + default: + return false; + } +}); + +watch(() => [cell.value.value], () => { + // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する + nextTick(emitContentSizeChanged); +}, { immediate: true }); + +watch(() => cell.value.selected, () => { + if (cell.value.selected) { + requestFocus(); + } +}); + +function onCellDoubleClick(ev: MouseEvent) { + switch (ev.type) { + case 'dblclick': { + beginEditing(ev.target as HTMLElement); + break; + } + } +} + +function onOutsideMouseDown(ev: MouseEvent) { + const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target); + if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) { + endEditing(true, false); + } +} + +function onCellKeyDown(ev: KeyboardEvent) { + if (!editing.value) { + ev.preventDefault(); + switch (ev.code) { + case 'NumpadEnter': + case 'Enter': + case 'F2': { + beginEditing(ev.target as HTMLElement); + break; + } + } + } else { + switch (ev.code) { + case 'Escape': { + endEditing(false, true); + break; + } + case 'NumpadEnter': + case 'Enter': { + if (!ev.isComposing) { + endEditing(true, true); + } + } + } + } +} + +function onInputText(ev: Event) { + editingValue.value = (ev.target as HTMLInputElement).value; +} + +function onForceRefreshContentSize() { + emitContentSizeChanged(); +} + +function registerOutsideMouseDown() { + unregisterOutsideMouseDown(); + addEventListener('mousedown', onOutsideMouseDown); +} + +function unregisterOutsideMouseDown() { + removeEventListener('mousedown', onOutsideMouseDown); +} + +async function beginEditing(target: HTMLElement) { + if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) { + return; + } + + if (cell.value.column.setting.customValueEditor) { + emit('operation:beginEdit', cell.value); + const newValue = await cell.value.column.setting.customValueEditor( + cell.value.row, + cell.value.column, + cell.value.value, + target, + ); + emit('operation:endEdit', cell.value); + + if (newValue !== cell.value.value) { + emitValueChange(newValue); + } + + requestFocus(); + } else { + switch (cellType.value) { + case 'number': + case 'date': + case 'text': { + editingValue.value = cell.value.value; + editing.value = true; + registerOutsideMouseDown(); + emit('operation:beginEdit', cell.value); + + await nextTick(() => { + // inputの展開後にフォーカスを当てたい + if (inputAreaEl.value) { + (inputAreaEl.value.querySelector('*') as HTMLElement).focus(); + } + }); + break; + } + case 'boolean': { + // とくに特殊なUIは設けず、トグルするだけ + emitValueChange(!cell.value.value); + break; + } + } + } +} + +function endEditing(applyValue: boolean, requireFocus: boolean) { + if (!editing.value) { + return; + } + + const newValue = editingValue.value; + editingValue.value = undefined; + + emit('operation:endEdit', cell.value); + unregisterOutsideMouseDown(); + + if (applyValue && newValue !== cell.value.value) { + emitValueChange(newValue); + } + + editing.value = false; + + if (requireFocus) { + requestFocus(); + } +} + +function requestFocus() { + nextTick(() => { + rootEl.value?.focus(); + }); +} + +function emitValueChange(newValue: CellValue) { + const _cell = cell.value; + emit('change:value', _cell, newValue); +} + +function emitContentSizeChanged() { + emit('change:contentSize', cell.value, { + width: contentAreaEl.value?.clientWidth ?? 0, + height: contentAreaEl.value?.clientHeight ?? 0, + }); +} + +useTooltip(rootEl, (showing) => { + if (cell.value.violation.valid) { + return; + } + + const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n'); + const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), { + showing, + content, + targetElement: rootEl.value!, + }, { + closed: () => { + result.dispose(); + }, + }); +}); + +onMounted(() => { + bus.value.on('forceRefreshContentSize', onForceRefreshContentSize); +}); + +onUnmounted(() => { + bus.value.off('forceRefreshContentSize', onForceRefreshContentSize); +}); + +</script> + +<style module lang="scss"> +$cellHeight: 28px; + +.cell { + overflow: hidden; + white-space: nowrap; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + cursor: cell; + + &:focus { + outline: none; + } +} + +.root { + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + height: 100%; + + // selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい + border: solid 0.5px transparent; + + &.selected { + border: solid 0.5px var(--MI_THEME-accentLighten); + } + + &.ranged { + background-color: var(--MI_THEME-accentedBg); + } + + &.center { + justify-content: center; + } + + &.error { + border: solid 0.5px var(--MI_THEME-error); + } +} + +.contentArea, .inputArea { + display: flex; + align-items: center; + width: 100%; + max-width: 100%; +} + +.content { + display: inline-block; + padding: 0 8px; +} + +.viewImage { + width: auto; + max-height: $cellHeight; + height: $cellHeight; + object-fit: cover; +} + +.bool { + position: relative; + width: 18px; + height: 18px; + background: var(--MI_THEME-panel); + border: solid 2px var(--MI_THEME-divider); + border-radius: 4px; + box-sizing: border-box; + + &.boolTrue { + border-color: var(--MI_THEME-accent); + background: var(--MI_THEME-accent); + + &::before { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--MI_THEME-fgOnAccent); + font-size: 12px; + line-height: 18px; + } + } +} + +.editingInput { + padding: 0 8px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: $cellHeight - 2; + max-height: $cellHeight - 2; + height: $cellHeight - 2; + outline: none; + border: none; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; +} + +</style> diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue new file mode 100644 index 0000000000..280a14bc4a --- /dev/null +++ b/packages/frontend/src/components/grid/MkDataRow.vue @@ -0,0 +1,72 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_tr" + :class="[ + $style.row, + row.ranged ? $style.ranged : {}, + ...(row.additionalStyles ?? []).map(it => it.className ?? {}), + ]" + :style="[ + ...(row.additionalStyles ?? []).map(it => it.style ?? {}), + ]" + :data-grid-row="row.index" +> + <MkNumberCell + v-if="setting.showNumber" + :content="(row.index + 1).toString()" + :row="row" + /> + <MkDataCell + v-for="cell in cells" + :key="cell.address.col" + :vIf="cell.column.setting.type !== 'hidden'" + :cell="cell" + :rowSetting="setting" + :bus="bus" + @operation:beginEdit="(sender) => emit('operation:beginEdit', sender)" + @operation:endEdit="(sender) => emit('operation:endEdit', sender)" + @change:value="(sender, newValue) => emit('change:value', sender, newValue)" + @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)" + /> +</div> +</template> + +<script setup lang="ts"> +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import MkDataCell from '@/components/grid/MkDataCell.vue'; +import MkNumberCell from '@/components/grid/MkNumberCell.vue'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow, GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginEdit', sender: GridCell): void; + (ev: 'operation:endEdit', sender: GridCell): void; + (ev: 'change:value', sender: GridCell, newValue: CellValue): void; + (ev: 'change:contentSize', sender: GridCell, newSize: Size): void; +}>(); +defineProps<{ + row: GridRow, + cells: GridCell[], + setting: GridRowSetting, + bus: GridEventEmitter, +}>(); + +</script> + +<style module lang="scss"> +.row { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + + &.ranged { + background-color: var(--MI_THEME-accentedBg); + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts new file mode 100644 index 0000000000..5801012f15 --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -0,0 +1,223 @@ +/* + * 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 { StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js'; +import MkGrid from './MkGrid.vue'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { DataSource, GridSetting } from '@/components/grid/grid.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; + +function d(p: { + check?: boolean, + name?: string, + email?: string, + age?: number, + birthday?: string, + gender?: string, + country?: string, + reportCount?: number, + createdAt?: string, +}, seed: string) { + const prefix = text(10, seed); + + return { + check: p.check ?? boolean(seed), + name: p.name ?? `${firstName(seed)} ${lastName(seed)}`, + email: p.email ?? `${prefix}@example.com`, + age: p.age ?? integer(20, 80, seed), + birthday: date({}, seed).toISOString(), + gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed), + country: p.country ?? country(seed), + reportCount: p.reportCount ?? integer(0, 9999, seed), + createdAt: p.createdAt ?? date({}, seed).toISOString(), + }; +} + +const defaultCols: GridColumnSetting[] = [ + { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 }, + { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' }, + { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' }, + { bindTo: 'age', title: 'Age', type: 'number', width: 50 }, + { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' }, + { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 }, + { bindTo: 'country', title: 'Country', type: 'text', width: 120 }, + { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' }, + { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' }, +]; + +function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) { + const refData = ref<ReturnType<typeof d>[]>([]); + for (let i = 0; i < 100; i++) { + refData.value.push(d({}, i.toString())); + } + + return { + settings: { + row: overrides?.settings?.row, + cols: [ + ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true), + ...overrides?.settings?.cols ?? [], + ], + cells: overrides?.settings?.cells, + }, + data: refData.value, + }; +} + +function createRender(params: { settings: GridSetting, data: DataSource[] }) { + return { + render(args) { + return { + components: { + MkGrid, + }, + setup() { + return { + args, + }; + }, + data() { + return { + data: args.data, + }; + }, + computed: { + props() { + return { + ...args, + }; + }, + events() { + return { + event: (event: GridEvent, context: GridContext) => { + switch (event.type) { + case 'cell-value-change': { + args.data[event.row.index][event.column.setting.bindTo] = event.newValue; + } + } + }, + }; + }, + }, + template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>', + }; + }, + args: { + ...params, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + ], + }, + }, + } satisfies StoryObj<typeof MkGrid>; +} + +export const Default = createRender(createArgs()); + +export const NoNumber = createRender(createArgs({ + settings: { + row: { + showNumber: false, + }, + }, +})); + +export const NoSelectable = createRender(createArgs({ + settings: { + row: { + selectable: false, + }, + }, +})); + +export const Editable = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + }, +})); + +export const AdditionalRowStyle = createRender(createArgs({ + settings: { + cols: defaultCols.map(col => ({ ...col, editable: true })), + row: { + styleRules: [ + { + condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean, + applyStyle: { + style: { + backgroundColor: 'lightgray', + }, + }, + }, + ], + }, + }, +})); + +export const ContextMenu = createRender(createArgs({ + settings: { + cols: [ + { + bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [ + { + type: 'button', + text: 'Check All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = true; + } + }, + }, + { + type: 'button', + text: 'Uncheck All', + action: () => { + for (const d of ContextMenu.args.data) { + d.check = false; + } + }, + }, + ], + }, + ], + row: { + contextMenuFactory: (row, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + const idxes = context.rangedRows.map(r => r.index); + const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i)); + + ContextMenu.args.data.splice(0); + ContextMenu.args.data.push(...newData); + }, + }, + ], + }, + cells: { + contextMenuFactory: (col, row, value, context) => [ + { + type: 'button', + text: 'Delete', + action: () => { + for (const cell of context.rangedCells) { + ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined; + } + }, + }, + ], + }, + }, +})); diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue new file mode 100644 index 0000000000..4dbd4ebcae --- /dev/null +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -0,0 +1,1374 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + class="mk_grid_border" + :class="[$style.grid, { + [$style.noOverflowHandling]: rootSetting.noOverflowStyle, + 'mk_grid_root_rounded': rootSetting.rounded, + 'mk_grid_root_border': rootSetting.outerBorder, + }]" + @mousedown.prevent="onMouseDown" + @keydown="onKeyDown" + @contextmenu.prevent.stop="onContextMenu" +> + <div class="mk_grid_thead"> + <MkHeaderRow + :columns="columns" + :gridSetting="rowSetting" + :bus="bus" + @operation:beginWidthChange="onHeaderCellWidthBeginChange" + @operation:endWidthChange="onHeaderCellWidthEndChange" + @operation:widthLargest="onHeaderCellWidthLargest" + @change:width="onHeaderCellChangeWidth" + @change:contentSize="onHeaderCellChangeContentSize" + /> + </div> + <div class="mk_grid_tbody"> + <MkDataRow + v-for="row in rows" + v-show="row.using" + :key="row.index" + :row="row" + :cells="cells[row.index].cells" + :setting="rowSetting" + :bus="bus" + :using="row.using" + :class="[lastLine === row.index ? 'last_row' : '']" + @operation:beginEdit="onCellEditBegin" + @operation:endEdit="onCellEditEnd" + @change:value="onChangeCellValue" + @change:contentSize="onChangeCellContentSize" + /> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, toRefs, watch } from 'vue'; +import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import MkDataRow from '@/components/grid/MkDataRow.vue'; +import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; +import { cellValidation } from '@/components/grid/cell-validators.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js'; +import { + copyGridDataToClipboard, + equalCellAddress, + getCellAddress, + getCellElement, + pasteToGridFromClipboard, + removeDataFromGrid, +} from '@/components/grid/grid-utils.js'; +import { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; +import { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import { createColumn, GridColumn } from '@/components/grid/column.js'; +import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js'; +import { handleKeyEvent } from '@/scripts/key-event.js'; + +type RowHolder = { + row: GridRow, + cells: GridCell[], + origin: DataSource, +} + +const emit = defineEmits<{ + (ev: 'event', event: GridEvent, context: GridContext): void; +}>(); + +const props = defineProps<{ + settings: GridSetting; + data: DataSource[]; +}>(); + +const rootSetting: Required<GridSetting['root']> = { + noOverflowStyle: false, + rounded: true, + outerBorder: true, + ...props.settings.root, +}; + +// non-reactive +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const rowSetting: Required<GridRowSetting> = { + ...defaultGridRowSetting, + ...props.settings.row, +}; + +// non-reactive +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const columnSettings = props.settings.cols; + +// non-reactive +const cellSettings = props.settings.cells ?? {}; + +const { data } = toRefs(props); + +// #region Event Definitions +// region Event Definitions + +/** + * grid -> 各子コンポーネントのイベント経路を担う{@link GridEventEmitter}。おもにpropsでの伝搬が難しいイベントを伝搬するために使用する。 + * 子コンポーネント -> gridのイベントでは原則使用せず、{@link emit}を使用する。 + */ +const bus = new GridEventEmitter(); +/** + * テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}。 + * 表示切替を検知し、サイズの再計算要求を発行するために使用する(マウント時にコンテンツが表示されていない場合、初手のサイズの自動計算が正常に働かないため) + * + * {@link setTimeout}を経由している理由は、{@link onResize}の中でサイズ再計算要求→サイズ変更が発生するとループとみなされ、 + * 「ResizeObserver loop completed with undelivered notifications.」という警告が発生するため(再計算が完全に終われば通知は発生しなくなるので実際にはループしない) + * + * @see {@link onResize} + */ +const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries))); + +const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); +/** + * グリッドの最も上位にある状態。 + */ +const state = ref<GridState>('normal'); +/** + * グリッドの列定義。列定義の元の設定値は非リアクティブなので、初期値を生成して以降は変更しない。 + */ +const columns = ref<GridColumn[]>(columnSettings.map(createColumn)); +/** + * グリッドの行定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。 + */ +const rows = ref<GridRow[]>([]); +/** + * グリッドのセル定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。 + */ +const cells = ref<RowHolder[]>([]); + +/** + * mousemoveイベントが発生した際に、イベントから取得したセルアドレスを保持するための変数。 + * セルアドレスが変わった瞬間にイベントを起こしたい時のために前回値として使用する。 + */ +const previousCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE); +/** + * 編集中のセルのアドレスを保持するための変数。 + */ +const editingCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE); +/** + * 列の範囲選択をする際の開始地点となるインデックスを保持するための変数。 + * この開始地点からマウスが動いた地点までの範囲を選択する。 + */ +const firstSelectionColumnIdx = ref<number>(CELL_ADDRESS_NONE.col); +/** + * 行の範囲選択をする際の開始地点となるインデックスを保持するための変数。 + * この開始地点からマウスが動いた地点までの範囲を選択する。 + */ +const firstSelectionRowIdx = ref<number>(CELL_ADDRESS_NONE.row); + +/** + * 選択状態のセルを取得するための計算プロパティ。選択状態とは{@link GridCell.selected}がtrueのセルのこと。 + */ +const selectedCell = computed(() => { + const selected = cells.value.flatMap(it => it.cells).filter(it => it.selected); + return selected.length > 0 ? selected[0] : undefined; +}); +/** + * 範囲選択状態のセルを取得するための計算プロパティ。範囲選択状態とは{@link GridCell.ranged}がtrueのセルのこと。 + */ +const rangedCells = computed(() => cells.value.flatMap(it => it.cells).filter(it => it.ranged)); +/** + * 範囲選択状態のセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。 + */ +const rangedBounds = computed(() => { + const _cells = rangedCells.value; + const _cols = _cells.map(it => it.address.col); + const _rows = _cells.map(it => it.address.row); + + const leftTop = { + col: Math.min(..._cols), + row: Math.min(..._rows), + }; + const rightBottom = { + col: Math.max(..._cols), + row: Math.max(..._rows), + }; + + return { + leftTop, + rightBottom, + }; +}); +/** + * グリッドの中で使用可能なセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。 + */ +const availableBounds = computed(() => { + const leftTop = { + col: 0, + row: 0, + }; + const rightBottom = { + col: Math.max(...columns.value.map(it => it.index)), + row: Math.max(...rows.value.filter(it => it.using).map(it => it.index)), + }; + return { leftTop, rightBottom }; +}); +/** + * 範囲選択状態の行を取得するための計算プロパティ。範囲選択状態とは{@link GridRow.ranged}がtrueの行のこと。 + */ +const rangedRows = computed(() => rows.value.filter(it => it.ranged)); + +const lastLine = computed(() => rows.value.filter(it => it.using).length - 1); + +// endregion +// #endregion + +watch(data, patchData, { deep: true }); + +if (_DEV_) { + watch(state, (value, oldValue) => { + console.log(`[grid][state] ${oldValue} -> ${value}`); + }); +} + +// #region Event Handlers +// region Event Handlers + +function onResize(entries: ResizeObserverEntry[]) { + if (entries.length !== 1 || entries[0].target !== rootEl.value) { + return; + } + + const contentRect = entries[0].contentRect; + if (_DEV_) { + console.log(`[grid][resize] contentRect: ${contentRect.width}x${contentRect.height}`); + } + + switch (state.value) { + case 'hidden': { + if (contentRect.width > 0 && contentRect.height > 0) { + // 先に状態を変更しておき、再計算要求が複数回走らないようにする + state.value = 'normal'; + + // 選択状態が狂うかもしれないので解除しておく + unSelectionRangeAll(); + + // 再計算要求を発行。各セル側で最低限必要な横幅を算出し、emitで返してくるようになっている + bus.emit('forceRefreshContentSize'); + } + break; + } + default: { + if (contentRect.width === 0 || contentRect.height === 0) { + state.value = 'hidden'; + } + break; + } + } +} + +function onKeyDown(ev: KeyboardEvent) { + const { ctrlKey, shiftKey, code } = ev; + if (_DEV_) { + console.log(`[grid][key] ctrl: ${ctrlKey}, shift: ${shiftKey}, code: ${code}`); + } + + function updateSelectionRange(newBounds: { leftTop: CellAddress, rightBottom: CellAddress }) { + unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom); + expandCellRange(newBounds.leftTop, newBounds.rightBottom); + } + + switch (state.value) { + case 'normal': { + ev.preventDefault(); + ev.stopPropagation(); + + const selectedCellAddress = selectedCell.value?.address ?? CELL_ADDRESS_NONE; + const max = availableBounds.value; + const bounds = rangedBounds.value; + + handleKeyEvent(ev, [ + { + code: 'Delete', handler: () => { + if (rangedRows.value.length > 0) { + if (rowSetting.events.delete) { + rowSetting.events.delete(rangedRows.value); + } + } else { + const context = createContext(); + removeDataFromGrid(context, (cell) => { + emitCellValue(cell, undefined); + }); + } + }, + }, + { + code: 'KeyC', modifiers: ['Control'], handler: () => { + const context = createContext(); + copyGridDataToClipboard(data.value, context); + }, + }, + { + code: 'KeyV', modifiers: ['Control'], handler: async () => { + const _cells = cells.value; + const context = createContext(); + await pasteToGridFromClipboard(context, (row, col, parsedValue) => { + emitCellValue(_cells[row.index].cells[col.index], parsedValue); + }); + }, + }, + { + code: 'ArrowRight', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row }, + rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowLeft', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: max.leftTop.col, row: bounds.leftTop.row }, + rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowUp', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: bounds.leftTop.col, row: max.leftTop.row }, + rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row }, + }); + }, + }, + { + code: 'ArrowDown', modifiers: ['Control', 'Shift'], handler: () => { + updateSelectionRange({ + leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row }, + rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row }, + }); + }, + }, + { + code: 'ArrowRight', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col < selectedCellAddress.col + ? bounds.leftTop.col + 1 + : selectedCellAddress.col, + row: bounds.leftTop.row, + }, + rightBottom: { + col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col) + ? bounds.rightBottom.col + 1 + : selectedCellAddress.col, + row: bounds.rightBottom.row, + }, + }); + }, + }, + { + code: 'ArrowLeft', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col) + ? bounds.leftTop.col - 1 + : selectedCellAddress.col, + row: bounds.leftTop.row, + }, + rightBottom: { + col: bounds.rightBottom.col > selectedCellAddress.col + ? bounds.rightBottom.col - 1 + : selectedCellAddress.col, + row: bounds.rightBottom.row, + }, + }); + }, + }, + { + code: 'ArrowUp', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col, + row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row) + ? bounds.leftTop.row - 1 + : selectedCellAddress.row, + }, + rightBottom: { + col: bounds.rightBottom.col, + row: bounds.rightBottom.row > selectedCellAddress.row + ? bounds.rightBottom.row - 1 + : selectedCellAddress.row, + }, + }); + }, + }, + { + code: 'ArrowDown', modifiers: ['Shift'], handler: () => { + updateSelectionRange({ + leftTop: { + col: bounds.leftTop.col, + row: bounds.leftTop.row < selectedCellAddress.row + ? bounds.leftTop.row + 1 + : selectedCellAddress.row, + }, + rightBottom: { + col: bounds.rightBottom.col, + row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row) + ? bounds.rightBottom.row + 1 + : selectedCellAddress.row, + }, + }); + }, + }, + { + code: 'ArrowDown', handler: () => { + selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 }); + }, + }, + { + code: 'ArrowUp', handler: () => { + selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 }); + }, + }, + { + code: 'ArrowRight', handler: () => { + selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row }); + }, + }, + { + code: 'ArrowLeft', handler: () => { + selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row }); + }, + }, + ]); + + break; + } + } +} + +function onMouseDown(ev: MouseEvent) { + switch (ev.button) { + case 0: { + onLeftMouseDown(ev); + break; + } + case 2: { + onRightMouseDown(ev); + break; + } + } +} + +function onLeftMouseDown(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][mouse-left] state:${state.value}, button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + switch (state.value) { + case 'cellEditing': { + if (availableCellAddress(cellAddress) && !equalCellAddress(editingCellAddress.value, cellAddress)) { + selectionCell(cellAddress); + } + break; + } + case 'normal': { + if (availableCellAddress(cellAddress)) { + if (ev.shiftKey && selectedCell.value && !equalCellAddress(cellAddress, selectedCell.value.address)) { + const selectedCellAddress = selectedCell.value.address; + + const leftTop = { + col: Math.min(selectedCellAddress.col, cellAddress.col), + row: Math.min(selectedCellAddress.row, cellAddress.row), + }; + + const rightBottom = { + col: Math.max(selectedCellAddress.col, cellAddress.col), + row: Math.max(selectedCellAddress.row, cellAddress.row), + }; + + unSelectionRangeAll(); + expandCellRange(leftTop, rightBottom); + + cells.value[selectedCellAddress.row].cells[selectedCellAddress.col].selected = true; + } else { + selectionCell(cellAddress); + } + + previousCellAddress.value = cellAddress; + + registerMouseUp(); + registerMouseMove(); + state.value = 'cellSelecting'; + } else if (isColumnHeaderCellAddress(cellAddress)) { + if (ev.shiftKey) { + const rangedColumnIndexes = rangedCells.value.map(it => it.address.col); + const targetColumnIndexes = [cellAddress.col, ...rangedColumnIndexes]; + unSelectionRangeAll(); + + const leftTop = { + col: Math.min(...targetColumnIndexes), + row: 0, + }; + + const rightBottom = { + col: Math.max(...targetColumnIndexes), + row: cells.value.length - 1, + }; + + expandCellRange(leftTop, rightBottom); + + if (rangedColumnIndexes.length === 0) { + firstSelectionColumnIdx.value = cellAddress.col; + } else { + if (cellAddress.col > Math.min(...rangedColumnIndexes)) { + firstSelectionColumnIdx.value = Math.min(...rangedColumnIndexes); + } else { + firstSelectionColumnIdx.value = Math.max(...rangedColumnIndexes); + } + } + } else { + unSelectionRangeAll(); + + const colCells = cells.value.map(row => row.cells[cellAddress.col]); + selectionRange(...colCells.map(cell => cell.address)); + + firstSelectionColumnIdx.value = cellAddress.col; + } + + registerMouseUp(); + registerMouseMove(); + previousCellAddress.value = cellAddress; + state.value = 'colSelecting'; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + } else if (isRowNumberCellAddress(cellAddress)) { + if (ev.shiftKey) { + const rangedRowIndexes = rangedRows.value.map(it => it.index); + const targetRowIndexes = [cellAddress.row, ...rangedRowIndexes]; + unSelectionRangeAll(); + + const leftTop = { + col: 0, + row: Math.min(...targetRowIndexes), + }; + + const rightBottom = { + col: Math.min(...cells.value.map(it => it.cells.length - 1)), + row: Math.max(...targetRowIndexes), + }; + + expandCellRange(leftTop, rightBottom); + expandRowRange(Math.min(...targetRowIndexes), Math.max(...targetRowIndexes)); + + if (rangedRowIndexes.length === 0) { + firstSelectionRowIdx.value = cellAddress.row; + } else { + if (cellAddress.col > Math.min(...rangedRowIndexes)) { + firstSelectionRowIdx.value = Math.min(...rangedRowIndexes); + } else { + firstSelectionRowIdx.value = Math.max(...rangedRowIndexes); + } + } + } else { + unSelectionRangeAll(); + const rowCells = cells.value[cellAddress.row].cells; + selectionRange(...rowCells.map(cell => cell.address)); + expandRowRange(cellAddress.row, cellAddress.row); + + firstSelectionRowIdx.value = cellAddress.row; + } + + registerMouseUp(); + registerMouseMove(); + previousCellAddress.value = cellAddress; + state.value = 'rowSelecting'; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + } + break; + } + } +} + +function onRightMouseDown(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][mouse-right] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + switch (state.value) { + case 'normal': { + if (!availableCellAddress(cellAddress)) { + return; + } + + const _rangedCells = [...rangedCells.value]; + if (!_rangedCells.some(it => equalCellAddress(it.address, cellAddress))) { + // 範囲選択外を右クリックした場合は、範囲選択を解除(範囲選択内であれば範囲選択を維持する) + selectionCell(cellAddress); + } + + break; + } + } +} + +function onMouseMove(ev: MouseEvent) { + ev.preventDefault(); + + const targetCellAddress = getCellAddress(ev.target as HTMLElement); + if (equalCellAddress(previousCellAddress.value, targetCellAddress)) { + // セルが変わるまでイベントを起こしたくない + return; + } + + if (_DEV_) { + console.log(`[grid][mouse-move] button: ${ev.button}, cell: ${targetCellAddress.row}x${targetCellAddress.col}`); + } + + switch (state.value) { + case 'cellSelecting': { + const selectedCellAddress = selectedCell.value?.address; + if (!availableCellAddress(targetCellAddress) || !selectedCellAddress) { + // 正しいセル範囲ではない + return; + } + + const leftTop = { + col: Math.min(targetCellAddress.col, selectedCellAddress.col), + row: Math.min(targetCellAddress.row, selectedCellAddress.row), + }; + + const rightBottom = { + col: Math.max(targetCellAddress.col, selectedCellAddress.col), + row: Math.max(targetCellAddress.row, selectedCellAddress.row), + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + previousCellAddress.value = targetCellAddress; + + break; + } + case 'colSelecting': { + if (!isColumnHeaderCellAddress(targetCellAddress) || previousCellAddress.value.col === targetCellAddress.col) { + // セルが変わるまでイベントを起こしたくない + return; + } + + const leftTop = { + col: Math.min(targetCellAddress.col, firstSelectionColumnIdx.value), + row: 0, + }; + + const rightBottom = { + col: Math.max(targetCellAddress.col, firstSelectionColumnIdx.value), + row: cells.value.length - 1, + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + previousCellAddress.value = targetCellAddress; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + + break; + } + case 'rowSelecting': { + if (!isRowNumberCellAddress(targetCellAddress) || previousCellAddress.value.row === targetCellAddress.row) { + // セルが変わるまでイベントを起こしたくない + return; + } + + const leftTop = { + col: 0, + row: Math.min(targetCellAddress.row, firstSelectionRowIdx.value), + }; + + const rightBottom = { + col: Math.min(...cells.value.map(it => it.cells.length - 1)), + row: Math.max(targetCellAddress.row, firstSelectionRowIdx.value), + }; + + // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする + unSelectionOutOfRange(leftTop, rightBottom); + expandCellRange(leftTop, rightBottom); + + // 行も同様に + const rangedRowIndexes = [rows.value[targetCellAddress.row].index, ...rangedRows.value.map(it => it.index)]; + expandRowRange(Math.min(...rangedRowIndexes), Math.max(...rangedRowIndexes)); + + previousCellAddress.value = targetCellAddress; + + // フォーカスを当てないとキーイベントが拾えないので + getCellElement(ev.target as HTMLElement)?.focus(); + + break; + } + } +} + +function onMouseUp(ev: MouseEvent) { + ev.preventDefault(); + switch (state.value) { + case 'rowSelecting': + case 'colSelecting': + case 'cellSelecting': { + unregisterMouseUp(); + unregisterMouseMove(); + state.value = 'normal'; + previousCellAddress.value = CELL_ADDRESS_NONE; + break; + } + } +} + +function onContextMenu(ev: MouseEvent) { + const cellAddress = getCellAddress(ev.target as HTMLElement); + if (_DEV_) { + console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); + } + + const context = createContext(); + const menuItems = Array.of<MenuItem>(); + switch (true) { + // 通常セルのコンテキストメニュー作成 + case availableCellAddress(cellAddress): { + const cell = cells.value[cellAddress.row].cells[cellAddress.col]; + if (cell.setting.contextMenuFactory) { + menuItems.push(...cell.setting.contextMenuFactory(cell.column, cell.row, cell.value, context)); + } + break; + } + // 列ヘッダセルのコンテキストメニュー作成 + case isColumnHeaderCellAddress(cellAddress): { + const col = columns.value[cellAddress.col]; + if (col.setting.contextMenuFactory) { + menuItems.push(...col.setting.contextMenuFactory(col, context)); + } + break; + } + // 行ヘッダセルのコンテキストメニュー作成 + case isRowNumberCellAddress(cellAddress): { + const row = rows.value[cellAddress.row]; + if (row.setting.contextMenuFactory) { + menuItems.push(...row.setting.contextMenuFactory(row, context)); + } + break; + } + } + + if (menuItems.length > 0) { + os.contextMenu(menuItems, ev); + } +} + +function onCellEditBegin(sender: GridCell) { + state.value = 'cellEditing'; + editingCellAddress.value = sender.address; + for (const cell of cells.value.flatMap(it => it.cells)) { + if (cell.address.col !== sender.address.col || cell.address.row !== sender.address.row) { + // 編集状態となったセル以外は全部選択解除 + cell.selected = false; + } + } +} + +function onCellEditEnd() { + editingCellAddress.value = CELL_ADDRESS_NONE; + state.value = 'normal'; +} + +function onChangeCellValue(sender: GridCell, newValue: CellValue) { + applyRowRules([sender]); + emitCellValue(sender, newValue); +} + +function onChangeCellContentSize(sender: GridCell, contentSize: Size) { + const _cells = cells.value; + if (_cells.length > sender.address.row && _cells[sender.address.row].cells.length > sender.address.col) { + const currentSize = _cells[sender.address.row].cells[sender.address.col].contentSize; + if (currentSize.width !== contentSize.width || currentSize.height !== contentSize.height) { + // 通常セルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用) + _cells[sender.address.row].cells[sender.address.col].contentSize = contentSize; + + if (sender.column.setting.width === 'auto') { + calcLargestCellWidth(sender.column); + } + } + } +} + +function onHeaderCellWidthBeginChange() { + switch (state.value) { + case 'normal': { + state.value = 'colResizing'; + break; + } + } +} + +function onHeaderCellWidthEndChange() { + switch (state.value) { + case 'colResizing': { + state.value = 'normal'; + break; + } + } +} + +function onHeaderCellChangeWidth(sender: GridColumn, width: string) { + switch (state.value) { + case 'colResizing': { + const column = columns.value[sender.index]; + column.width = width; + break; + } + } +} + +function onHeaderCellChangeContentSize(sender: GridColumn, newSize: Size) { + switch (state.value) { + case 'normal': { + const currentSize = columns.value[sender.index].contentSize; + if (currentSize.width !== newSize.width || currentSize.height !== newSize.height) { + // ヘッダセルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用) + columns.value[sender.index].contentSize = newSize; + + if (sender.setting.width === 'auto') { + calcLargestCellWidth(sender); + } + } + break; + } + } +} + +function onHeaderCellWidthLargest(sender: GridColumn) { + switch (state.value) { + case 'normal': { + calcLargestCellWidth(sender); + break; + } + } +} + +// endregion +// #endregion + +// #region Methods +// region Methods + +/** + * カラム内のコンテンツを表示しきるために必要な横幅と、各セルのコンテンツを表示しきるために必要な横幅を比較し、大きい方を列全体の横幅として採用する。 + */ +function calcLargestCellWidth(column: GridColumn) { + const _cells = cells.value; + const largestColumnWidth = columns.value[column.index].contentSize.width; + + const largestCellWidth = (_cells.length > 0) + ? _cells + .map(row => row.cells[column.index]) + .reduce( + (acc, value) => Math.max(acc, value.contentSize.width), + 0, + ) + : 0; + + if (_DEV_) { + console.log(`[grid][calc-largest] idx:${column.setting.bindTo}, col:${largestColumnWidth}, cell:${largestCellWidth}`); + } + + column.width = `${Math.max(largestColumnWidth, largestCellWidth)}px`; +} + +/** + * {@link emit}を使用してイベントを発行する。 + */ +function emitGridEvent(ev: GridEvent) { + const currentState: GridContext = { + selectedCell: selectedCell.value, + rangedCells: rangedCells.value, + rangedRows: rangedRows.value, + randedBounds: rangedBounds.value, + availableBounds: availableBounds.value, + state: state.value, + rows: rows.value, + columns: columns.value, + }; + + emit( + 'event', + ev, + currentState, + ); +} + +/** + * 親コンポーネントに新しい値を通知する。 + * 新しい値は、イベント通知→元データへの反映→再計算(バリデーション含む)→再描画の流れで反映される。 + */ +function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) { + const cellAddress = 'address' in sender ? sender.address : sender; + const cell = cells.value[cellAddress.row].cells[cellAddress.col]; + + emitGridEvent({ + type: 'cell-value-change', + column: cell.column, + row: cell.row, + oldValue: cell.value, + newValue: newValue, + }); + + if (_DEV_) { + console.log(`[grid][cell-value] row:${cell.row}, col:${cell.column.index}, value:${newValue}`); + } +} + +/** + * {@link target}のセルを選択状態にする。 + * その際、{@link target}以外の行およびセルの範囲選択状態を解除する。 + */ +function selectionCell(target: CellAddress) { + if (!availableCellAddress(target)) { + return; + } + + unSelectionRangeAll(); + + const _cells = cells.value; + _cells[target.row].cells[target.col].selected = true; + _cells[target.row].cells[target.col].ranged = true; +} + +/** + * {@link targets}のセルを範囲選択状態にする。 + */ +function selectionRange(...targets: CellAddress[]) { + const _cells = cells.value; + for (const target of targets) { + const row = _cells[target.row]; + if (row.row.using) { + row.cells[target.col].ranged = true; + } + } +} + +/** + * 行およびセルの範囲選択状態をすべて解除する。 + */ +function unSelectionRangeAll() { + const _cells = rangedCells.value; + for (const cell of _cells) { + cell.selected = false; + cell.ranged = false; + } + + const _rows = rows.value.filter(it => it.using); + for (const row of _rows) { + row.ranged = false; + } +} + +/** + * {@link leftTop}から{@link rightBottom}の範囲外にあるセルを範囲選択状態から外す。 + */ +function unSelectionOutOfRange(leftTop: CellAddress, rightBottom: CellAddress) { + const safeBounds = getSafeAddressBounds({ leftTop, rightBottom }); + + const _cells = rangedCells.value; + for (const cell of _cells) { + const outOfRangeCol = cell.address.col < safeBounds.leftTop.col || cell.address.col > safeBounds.rightBottom.col; + const outOfRangeRow = cell.address.row < safeBounds.leftTop.row || cell.address.row > safeBounds.rightBottom.row; + if (outOfRangeCol || outOfRangeRow) { + cell.ranged = false; + } + } + + const outOfRangeRows = rows.value.filter((_, index) => index < safeBounds.leftTop.row || index > safeBounds.rightBottom.row); + for (const row of outOfRangeRows) { + row.ranged = false; + } +} + +/** + * {@link leftTop}から{@link rightBottom}の範囲内にあるセルを範囲選択状態にする。 + */ +function expandCellRange(leftTop: CellAddress, rightBottom: CellAddress) { + const safeBounds = getSafeAddressBounds({ leftTop, rightBottom }); + const targetRows = cells.value.slice(safeBounds.leftTop.row, safeBounds.rightBottom.row + 1); + for (const row of targetRows) { + for (const cell of row.cells.slice(safeBounds.leftTop.col, safeBounds.rightBottom.col + 1)) { + cell.ranged = true; + } + } +} + +/** + * {@link top}から{@link bottom}までの行を範囲選択状態にする。 + */ +function expandRowRange(top: number, bottom: number) { + if (!rowSetting.selectable) { + return; + } + + const targetRows = rows.value.slice(top, bottom + 1); + for (const row of targetRows) { + row.ranged = true; + } +} + +/** + * 特定の条件下でのみ適用されるCSSを反映する。 + */ +function applyRowRules(targetCells: GridCell[]) { + const _rows = rows.value; + const targetRowIdxes = [...new Set(targetCells.map(it => it.address.row))]; + const rowGroups = Array.of<{ row: GridRow, cells: GridCell[] }>(); + for (const rowIdx of targetRowIdxes) { + const rowGroup = targetCells.filter(it => it.address.row === rowIdx); + rowGroups.push({ row: _rows[rowIdx], cells: rowGroup }); + } + + const _cells = cells.value; + for (const group of rowGroups.filter(it => it.row.using)) { + const row = group.row; + const targetCols = group.cells.map(it => it.column); + const rowCells = _cells[group.row.index].cells; + + const newStyles = rowSetting.styleRules + .filter(it => it.condition({ row, targetCols, cells: rowCells })) + .map(it => it.applyStyle); + + if (JSON.stringify(newStyles) !== JSON.stringify(row.additionalStyles)) { + row.additionalStyles = newStyles; + } + } +} + +function availableCellAddress(cellAddress: CellAddress): boolean { + const safeBounds = availableBounds.value; + return cellAddress.row >= safeBounds.leftTop.row && + cellAddress.col >= safeBounds.leftTop.col && + cellAddress.row <= safeBounds.rightBottom.row && + cellAddress.col <= safeBounds.rightBottom.col; +} + +function isColumnHeaderCellAddress(cellAddress: CellAddress): boolean { + return cellAddress.row === -1 && cellAddress.col >= 0; +} + +function isRowNumberCellAddress(cellAddress: CellAddress): boolean { + return cellAddress.row >= 0 && cellAddress.col === -1; +} + +function getSafeAddressBounds( + bounds: { leftTop: CellAddress, rightBottom: CellAddress }, +): { leftTop: CellAddress, rightBottom: CellAddress } { + const available = availableBounds.value; + + const safeLeftTop = { + col: Math.max(bounds.leftTop.col, available.leftTop.col), + row: Math.max(bounds.leftTop.row, available.leftTop.row), + }; + const safeRightBottom = { + col: Math.min(bounds.rightBottom.col, available.rightBottom.col), + row: Math.min(bounds.rightBottom.row, available.rightBottom.row), + }; + + return { leftTop: safeLeftTop, rightBottom: safeRightBottom }; +} + +function registerMouseMove() { + unregisterMouseMove(); + addEventListener('mousemove', onMouseMove); +} + +function unregisterMouseMove() { + removeEventListener('mousemove', onMouseMove); +} + +function registerMouseUp() { + unregisterMouseUp(); + addEventListener('mouseup', onMouseUp); +} + +function unregisterMouseUp() { + removeEventListener('mouseup', onMouseUp); +} + +function createContext(): GridContext { + return { + selectedCell: selectedCell.value, + rangedCells: rangedCells.value, + rangedRows: rangedRows.value, + randedBounds: rangedBounds.value, + availableBounds: availableBounds.value, + state: state.value, + rows: rows.value, + columns: columns.value, + }; +} + +function refreshData() { + if (_DEV_) { + console.log('[grid][refresh-data][begin]'); + } + + // データを元に行・列・セルを作成する。 + // 行は元データの配列の長さに応じて作成するが、最低限の行数は設定によって決まる。 + // 行数が変わるたびに都度レンダリングするとパフォーマンスがイマイチなので、あらかじめ多めにセルを用意しておくための措置。 + const _data: DataSource[] = data.value; + const _rows: GridRow[] = (_data.length > rowSetting.minimumDefinitionCount) + ? _data.map((_, index) => createRow(index, true, rowSetting)) + : Array.from({ length: rowSetting.minimumDefinitionCount }, (_, index) => createRow(index, index < _data.length, rowSetting)); + const _cols: GridColumn[] = columns.value; + + // 行・列の定義から、元データの配列より値を取得してセルを作成する。 + // 行・列の定義はそれぞれインデックスを持っており、そのインデックスは元データの配列番地に対応している。 + const _cells: RowHolder[] = _rows.map(row => { + const newCells = row.using + ? _cols.map(col => createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings)) + : _cols.map(col => createCell(col, row, undefined, cellSettings)); + + return { row, cells: newCells, origin: _data[row.index] }; + }); + + rows.value = _rows; + cells.value = _cells; + + const allCells = _cells.filter(it => it.row.using).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + + applyRowRules(allCells); + + if (_DEV_) { + console.log('[grid][refresh-data][end]'); + } +} + +/** + * セル値を部分更新する。この関数は、外部起因でデータが変更された場合に呼ばれる。 + * + * 外部起因でデータが変更された場合は{@link data}の値が変更されるが、何処の番地がどのように変わったのかまでは検知できない。 + * セルをすべて作り直せばいいが、その手法だと以下のデメリットがある。 + * - 描画負荷がかかる + * - 各セルが持つ個別の状態(選択中状態やバリデーション結果など)が失われる + * + * そこで、新しい値とセルが持つ値を突き合わせ、変更があった場合のみ値を更新し、セルそのものは使いまわしつつ値を最新化する。 + */ +function patchData(newItems: DataSource[]) { + if (_DEV_) { + console.log('[grid][patch-data][begin]'); + } + + const _cols = columns.value; + + if (rows.value.length < newItems.length) { + const newRows = Array.of<GridRow>(); + const newCells = Array.of<RowHolder>(); + + // 未使用の行を含めても足りないので新しい行を追加する + for (let rowIdx = rows.value.length; rowIdx < newItems.length; rowIdx++) { + const newRow = createRow(rowIdx, true, rowSetting); + newRows.push(newRow); + newCells.push({ + row: newRow, + cells: _cols.map(col => createCell(col, newRow, newItems[rowIdx][col.setting.bindTo], cellSettings)), + origin: newItems[rowIdx], + }); + } + + rows.value.push(...newRows); + cells.value.push(...newCells); + + applyRowRules(newCells.flatMap(it => it.cells)); + } + + // 行数の上限が欲しい場合はここに設けてもいいかもしれない + + const usingRows = rows.value.filter(it => it.using); + if (usingRows.length > newItems.length) { + // 行数が減っているので古い行をクリアする(再マウント・再レンダリングが重いので要素そのものは消さない) + for (let rowIdx = newItems.length; rowIdx < usingRows.length; rowIdx++) { + resetRow(rows.value[rowIdx]); + for (let colIdx = 0; colIdx < _cols.length; colIdx++) { + const holder = cells.value[rowIdx]; + holder.origin = {}; + resetCell(holder.cells[colIdx]); + } + } + } + + // 新しい値と既に設定されていた値を入れ替える + const changedCells = Array.of<GridCell>(); + for (let rowIdx = 0; rowIdx < newItems.length; rowIdx++) { + const holder = cells.value[rowIdx]; + holder.row.using = true; + + const oldCells = holder.cells; + const newItem = newItems[rowIdx]; + for (let colIdx = 0; colIdx < oldCells.length; colIdx++) { + const _col = columns.value[colIdx]; + + const oldCell = oldCells[colIdx]; + const newValue = newItem[_col.setting.bindTo]; + if (oldCell.value !== newValue) { + oldCell.value = _col.setting.valueTransformer + ? _col.setting.valueTransformer(holder.row, _col, newValue) + : newValue; + changedCells.push(oldCell); + } + } + } + + if (changedCells.length > 0) { + const allCells = cells.value.slice(0, newItems.length).flatMap(it => it.cells); + for (const cell of allCells) { + cell.violation = cellValidation(allCells, cell, cell.value); + } + + applyRowRules(changedCells); + + // セル値が書き換わっており、バリデーションの結果も変わっているので外部に通知する必要がある + emitGridEvent({ + type: 'cell-validation', + all: cells.value + .filter(it => it.row.using) + .flatMap(it => it.cells) + .map(it => it.violation) + .filter(it => !it.valid), + }); + } + + if (_DEV_) { + console.log('[grid][patch-data][end]'); + } +} + +// endregion +// #endregion + +onMounted(() => { + state.value = 'normal'; + + const bindToList = columnSettings.map(it => it.bindTo); + if (new Set(bindToList).size !== columnSettings.length) { + // 取得元のプロパティ名重複は許容したくない + throw new Error(`Duplicate bindTo setting : [${bindToList.join(',')}]}]`); + } + + if (rootEl.value) { + resizeObserver.observe(rootEl.value); + + // 初期表示時にコンテンツが表示されていない場合はhidden状態にしておく。 + // コンテンツ表示時にresizeイベントが発生するが、そのときにhidden状態にしておかないとサイズの再計算が走らないので + const bounds = rootEl.value.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) { + state.value = 'hidden'; + } + } + + refreshData(); +}); +</script> + +<style module lang="scss"> +.grid { + font-size: 90%; + overflow-x: scroll; + // firefoxだとスクロールバーがセルに重なって見づらくなってしまうのでスペースを空けておく + padding-bottom: 8px; + + &.noOverflowHandling { + overflow-x: revert; + padding-bottom: 0; + } +} +</style> + +<style lang="scss"> +$borderSetting: solid 0.5px var(--MI_THEME-divider); + +// 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない +.mk_grid_border { + --rootBorderSetting: none; + --borderRadius: 0; + + border-spacing: 0; + + &.mk_grid_root_border { + --rootBorderSetting: #{$borderSetting}; + } + + &.mk_grid_root_rounded { + --borderRadius: var(--MI-radius); + } + + .mk_grid_thead { + .mk_grid_tr { + .mk_grid_th { + border-left: $borderSetting; + border-top: var(--rootBorderSetting); + + &:first-child { + // 左上セル + border-left: var(--rootBorderSetting); + border-top-left-radius: var(--borderRadius); + } + + &:last-child { + // 右上セル + border-top-right-radius: var(--borderRadius); + border-right: var(--rootBorderSetting); + } + } + } + } + + .mk_grid_tbody { + .mk_grid_tr { + .mk_grid_td, .mk_grid_th { + border-left: $borderSetting; + border-top: $borderSetting; + + &:first-child { + // 左端の列 + border-left: var(--rootBorderSetting); + } + + &:last-child { + // 一番右端の列 + border-right: var(--rootBorderSetting); + } + } + } + + .last_row { + .mk_grid_td, .mk_grid_th { + // 一番下の行 + border-bottom: var(--rootBorderSetting); + + &:first-child { + // 左下セル + border-bottom-left-radius: var(--borderRadius); + } + + &:last-child { + // 右下セル + border-bottom-right-radius: var(--borderRadius); + } + } + } + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue new file mode 100644 index 0000000000..aecfe7eaa3 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderCell.vue @@ -0,0 +1,216 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + class="mk_grid_th" + :class="$style.cell" + :style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]" + data-grid-cell + :data-grid-cell-row="-1" + :data-grid-cell-col="column.index" +> + <div :class="$style.root"> + <div :class="$style.left"></div> + <div :class="$style.wrapper"> + <div ref="contentEl" :class="$style.contentArea"> + <span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"></span> + <span v-else>{{ text }}</span> + </div> + </div> + <div + :class="$style.right" + @mousedown="onHandleMouseDown" + @dblclick="onHandleDoubleClick" + ></div> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginWidthChange', sender: GridColumn): void; + (ev: 'operation:endWidthChange', sender: GridColumn): void; + (ev: 'operation:widthLargest', sender: GridColumn): void; + (ev: 'change:width', sender: GridColumn, width: string): void; + (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void; +}>(); +const props = defineProps<{ + column: GridColumn, + bus: GridEventEmitter, +}>(); + +const { column, bus } = toRefs(props); + +const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>(); +const contentEl = ref<InstanceType<typeof HTMLDivElement>>(); + +const resizing = ref<boolean>(false); + +const text = computed(() => { + const result = column.value.setting.title ?? column.value.setting.bindTo; + return result.length > 0 ? result : ' '; +}); + +watch(column, () => { + // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する + nextTick(emitContentSizeChanged); +}, { immediate: true }); + +function onHandleDoubleClick(ev: MouseEvent) { + switch (ev.type) { + case 'dblclick': { + emit('operation:widthLargest', column.value); + break; + } + } +} + +function onHandleMouseDown(ev: MouseEvent) { + switch (ev.type) { + case 'mousedown': { + if (!resizing.value) { + registerHandleMouseUp(); + registerHandleMouseMove(); + resizing.value = true; + emit('operation:beginWidthChange', column.value); + } + break; + } + } +} + +function onHandleMouseMove(ev: MouseEvent) { + if (!rootEl.value) { + // 型ガード + return; + } + + switch (ev.type) { + case 'mousemove': { + if (resizing.value) { + const bounds = rootEl.value.getBoundingClientRect(); + const clientWidth = rootEl.value.clientWidth; + const clientRight = bounds.left + clientWidth; + const nextWidth = clientWidth + (ev.clientX - clientRight); + emit('change:width', column.value, `${nextWidth}px`); + } + break; + } + } +} + +function onHandleMouseUp(ev: MouseEvent) { + switch (ev.type) { + case 'mouseup': { + if (resizing.value) { + unregisterHandleMouseUp(); + unregisterHandleMouseMove(); + resizing.value = false; + emit('operation:endWidthChange', column.value); + } + break; + } + } +} + +function onForceRefreshContentSize() { + emitContentSizeChanged(); +} + +function registerHandleMouseMove() { + unregisterHandleMouseMove(); + addEventListener('mousemove', onHandleMouseMove); +} + +function unregisterHandleMouseMove() { + removeEventListener('mousemove', onHandleMouseMove); +} + +function registerHandleMouseUp() { + unregisterHandleMouseUp(); + addEventListener('mouseup', onHandleMouseUp); +} + +function unregisterHandleMouseUp() { + removeEventListener('mouseup', onHandleMouseUp); +} + +function emitContentSizeChanged() { + const clientWidth = contentEl.value?.clientWidth ?? 0; + const clientHeight = contentEl.value?.clientHeight ?? 0; + emit('change:contentSize', column.value, { + // バーの横幅も考慮したいので、+3px + width: clientWidth + 3 + 3, + height: clientHeight, + }); +} + +onMounted(() => { + bus.value.on('forceRefreshContentSize', onForceRefreshContentSize); +}); + +onUnmounted(() => { + bus.value.off('forceRefreshContentSize', onForceRefreshContentSize); +}); + +</script> + +<style module lang="scss"> +$handleWidth: 5px; +$cellHeight: 28px; + +.cell { + cursor: pointer; +} + +.root { + display: flex; + flex-direction: row; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + + .wrapper { + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; + justify-content: center; + } + + .contentArea { + display: flex; + padding: 6px 4px; + box-sizing: border-box; + overflow: hidden; + white-space: nowrap; + text-align: center; + } + + .left { + // rightのぶんだけズレるのでそれを相殺するためのネガティブマージン + margin-left: -$handleWidth; + margin-right: auto; + width: $handleWidth; + min-width: $handleWidth; + } + + .right { + margin-left: auto; + // 判定を罫線の上に重ねたいのでネガティブマージンを使う + margin-right: -$handleWidth; + width: $handleWidth; + min-width: $handleWidth; + cursor: w-resize; + z-index: 1; + } +} +</style> diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue new file mode 100644 index 0000000000..8affa08fd5 --- /dev/null +++ b/packages/frontend/src/components/grid/MkHeaderRow.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_tr" + :class="$style.root" + :data-grid-row="-1" +> + <MkNumberCell + v-if="gridSetting.showNumber" + content="#" + :top="true" + /> + <MkHeaderCell + v-for="column in columns" + :key="column.index" + :column="column" + :bus="bus" + @operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)" + @operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)" + @operation:widthLargest="(sender) => emit('operation:widthLargest', sender)" + @change:width="(sender, width) => emit('change:width', sender, width)" + @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)" + /> +</div> +</template> + +<script setup lang="ts"> +import { GridEventEmitter, Size } from '@/components/grid/grid.js'; +import MkHeaderCell from '@/components/grid/MkHeaderCell.vue'; +import MkNumberCell from '@/components/grid/MkNumberCell.vue'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +const emit = defineEmits<{ + (ev: 'operation:beginWidthChange', sender: GridColumn): void; + (ev: 'operation:endWidthChange', sender: GridColumn): void; + (ev: 'operation:widthLargest', sender: GridColumn): void; + (ev: 'operation:selectionColumn', sender: GridColumn): void; + (ev: 'change:width', sender: GridColumn, width: string): void; + (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void; +}>(); + +defineProps<{ + columns: GridColumn[], + gridSetting: GridRowSetting, + bus: GridEventEmitter, +}>(); +</script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: row; + align-items: center; +} +</style> diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue new file mode 100644 index 0000000000..674bba96bc --- /dev/null +++ b/packages/frontend/src/components/grid/MkNumberCell.vue @@ -0,0 +1,61 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="mk_grid_th" + :class="[$style.cell]" + :tabindex="-1" + data-grid-cell + :data-grid-cell-row="row?.index ?? -1" + :data-grid-cell-col="-1" +> + <div :class="[$style.root]"> + {{ content }} + </div> +</div> +</template> + +<script setup lang="ts"> + +import { GridRow } from '@/components/grid/row.js'; + +defineProps<{ + content: string, + row?: GridRow, +}>(); + +</script> + +<style module lang="scss"> +$cellHeight: 28px; +$cellWidth: 34px; + +.cell { + overflow: hidden; + white-space: nowrap; + height: $cellHeight; + max-height: $cellHeight; + min-height: $cellHeight; + min-width: $cellWidth; + width: $cellWidth; + cursor: pointer; +} + +.root { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 0 8px; + height: 100%; + border: solid 0.5px transparent; + + &.selected { + background-color: var(--MI_THEME-accentedBg); + } +} +</style> diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts new file mode 100644 index 0000000000..949cab2ec6 --- /dev/null +++ b/packages/frontend/src/components/grid/cell-validators.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { i18n } from '@/i18n.js'; + +export type ValidatorParams = { + column: GridColumn; + row: GridRow; + value: CellValue; + allCells: GridCell[]; +}; + +export type ValidatorResult = { + valid: boolean; + message?: string; +} + +export type GridCellValidator = { + name?: string; + ignoreViolation?: boolean; + validate: (params: ValidatorParams) => ValidatorResult; +} + +export type ValidateViolation = { + valid: boolean; + params: ValidatorParams; + violations: ValidateViolationItem[]; +} + +export type ValidateViolationItem = { + valid: boolean; + validator: GridCellValidator; + result: ValidatorResult; +} + +export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation { + const { column, row } = cell; + const validators = column.setting.validators ?? []; + + const params: ValidatorParams = { + column, + row, + value: newValue, + allCells, + }; + + const violations: ValidateViolationItem[] = validators.map(validator => { + const result = validator.validate(params); + return { + valid: result.valid, + validator, + result, + }; + }); + + return { + valid: violations.every(v => v.result.valid), + params, + violations, + }; +} + +class ValidatorPreset { + required(): GridCellValidator { + return { + name: 'required', + validate: ({ value }): ValidatorResult => { + return { + valid: value !== null && value !== undefined && value !== '', + message: i18n.ts._gridComponent._error.requiredValue, + }; + }, + }; + } + + regex(pattern: RegExp): GridCellValidator { + return { + name: 'regex', + validate: ({ value }): ValidatorResult => { + return { + valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''), + message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }), + }; + }, + }; + } + + unique(): GridCellValidator { + return { + name: 'unique', + validate: ({ column, row, value, allCells }): ValidatorResult => { + const bindTo = column.setting.bindTo; + const isUnique = allCells + .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index) + .every(cell => cell.value !== value); + return { + valid: isUnique, + message: i18n.ts._gridComponent._error.notUnique, + }; + }, + }; + } +} + +export const validators = new ValidatorPreset(); diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts new file mode 100644 index 0000000000..71b7a3e3f1 --- /dev/null +++ b/packages/frontend/src/components/grid/cell.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { Size } from '@/components/grid/grid.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>; + +export type CellAddress = { + row: number; + col: number; +} + +export const CELL_ADDRESS_NONE: CellAddress = { + row: -1, + col: -1, +}; + +export type GridCell = { + address: CellAddress; + value: CellValue; + column: GridColumn; + row: GridRow; + selected: boolean; + ranged: boolean; + contentSize: Size; + setting: GridCellSetting; + violation: ValidateViolation; +} + +export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[]; + +export type GridCellSetting = { + contextMenuFactory?: GridCellContextMenuFactory; +} + +export function createCell( + column: GridColumn, + row: GridRow, + value: CellValue, + setting: GridCellSetting, +): GridCell { + const newValue = (row.using && column.setting.valueTransformer) + ? column.setting.valueTransformer(row, column, value) + : value; + + return { + address: { row: row.index, col: column.index }, + value: newValue, + column, + row, + selected: false, + ranged: false, + contentSize: { width: 0, height: 0 }, + violation: { + valid: true, + params: { + column, + row, + value, + allCells: [], + }, + violations: [], + }, + setting, + }; +} + +export function resetCell(cell: GridCell): void { + cell.selected = false; + cell.ranged = false; + cell.violation = { + valid: true, + params: { + column: cell.column, + row: cell.row, + value: cell.value, + allCells: [], + }, + violations: [], + }; +} diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts new file mode 100644 index 0000000000..2f505756fe --- /dev/null +++ b/packages/frontend/src/components/grid/column.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { GridCellValidator } from '@/components/grid/cell-validators.js'; +import { Size, SizeStyle } from '@/components/grid/grid.js'; +import { calcCellWidth } from '@/components/grid/grid-utils.js'; +import { CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; + +export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>; +export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; +export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[]; + +export type GridColumnSetting = { + bindTo: string; + title?: string; + icon?: string; + type: ColumnType; + width: SizeStyle; + editable?: boolean; + validators?: GridCellValidator[]; + customValueEditor?: CustomValueEditor; + valueTransformer?: CellValueTransformer; + contextMenuFactory?: GridColumnContextMenuFactory; + events?: { + copy?: (value: CellValue) => string; + paste?: (text: string) => CellValue; + delete?: (cell: GridCell, context: GridContext) => void; + } +}; + +export type GridColumn = { + index: number; + setting: GridColumnSetting; + width: string; + contentSize: Size; +} + +export function createColumn(setting: GridColumnSetting, index: number): GridColumn { + return { + index, + setting, + width: calcCellWidth(setting.width), + contentSize: { width: 0, height: 0 }, + }; +} + diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts new file mode 100644 index 0000000000..074b72b956 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-event.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridState } from '@/components/grid/grid.js'; +import { ValidateViolation } from '@/components/grid/cell-validators.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { GridRow } from '@/components/grid/row.js'; + +export type GridContext = { + selectedCell?: GridCell; + rangedCells: GridCell[]; + rangedRows: GridRow[]; + randedBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + availableBounds: { + leftTop: CellAddress; + rightBottom: CellAddress; + }; + state: GridState; + rows: GridRow[]; + columns: GridColumn[]; +}; + +export type GridEvent = + GridCellValueChangeEvent | + GridCellValidationEvent + ; + +export type GridCellValueChangeEvent = { + type: 'cell-value-change'; + column: GridColumn; + row: GridRow; + oldValue: CellValue; + newValue: CellValue; +}; + +export type GridCellValidationEvent = { + type: 'cell-validation'; + violation?: ValidateViolation; + all: ValidateViolation[]; +}; diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts new file mode 100644 index 0000000000..a45bc88926 --- /dev/null +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isRef, Ref } from 'vue'; +import { DataSource, SizeStyle } from '@/components/grid/grid.js'; +import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import { GridRow } from '@/components/grid/row.js'; +import { GridContext } from '@/components/grid/grid-event.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; + +export function isCellElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-cell'); +} + +export function isRowElement(elem: HTMLElement): boolean { + return elem.hasAttribute('data-grid-row'); +} + +export function calcCellWidth(widthSetting: SizeStyle): string { + switch (widthSetting) { + case undefined: + case 'auto': { + return 'auto'; + } + default: { + return `${widthSetting}px`; + } + } +} + +function getCellRowByAttribute(elem: HTMLElement): number { + const row = elem.getAttribute('data-grid-cell-row'); + if (row === null) { + throw new Error('data-grid-cell-row attribute not found'); + } + return Number(row); +} + +function getCellColByAttribute(elem: HTMLElement): number { + const col = elem.getAttribute('data-grid-cell-col'); + if (col === null) { + throw new Error('data-grid-cell-col attribute not found'); + } + return Number(col); +} + +export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (!node.parentElement) { + break; + } + + if (isCellElement(node) && isRowElement(node.parentElement)) { + const row = getCellRowByAttribute(node); + const col = getCellColByAttribute(node); + + return { row, col }; + } + + node = node.parentElement; + } + + return CELL_ADDRESS_NONE; +} + +export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null { + let node = elem; + for (let i = 0; i < parentNodeCount; i++) { + if (isCellElement(node)) { + return node; + } + + if (!node.parentElement) { + break; + } + + node = node.parentElement; + } + + return null; +} + +export function equalCellAddress(a: CellAddress, b: CellAddress): boolean { + return a.row === b.row && a.col === b.col; +} + +/** + * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。 + */ +export function copyGridDataToClipboard( + gridItems: Ref<DataSource[]> | DataSource[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of<string>(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of<string>(); + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const { bindTo, events } = context.columns[col].setting; + const value = items[row][bindTo]; + const transformValue = events?.copy + ? events.copy(value) + : typeof value === 'object' || Array.isArray(value) + ? JSON.stringify(value) + : value?.toString() ?? ''; + rowItems.push(transformValue); + } + lines.push(rowItems.join('\t')); + } + + const text = lines.join('\n'); + copyToClipboard(text); + + if (_DEV_) { + console.log(`Copied to clipboard: ${text}`); + } +} + +/** + * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export async function pasteToGridFromClipboard( + context: GridContext, + callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void, +) { + function parseValue(value: string, setting: GridColumnSetting): CellValue { + if (setting.events?.paste) { + return setting.events.paste(value); + } else { + switch (setting.type) { + case 'number': { + return Number(value); + } + case 'boolean': { + return value === 'true'; + } + default: { + return value; + } + } + } + } + + const clipBoardText = await navigator.clipboard.readText(); + if (_DEV_) { + console.log(`Paste from clipboard: ${clipBoardText}`); + } + + const bounds = context.randedBounds; + const lines = clipBoardText.replace(/\r/g, '') + .split('\n') + .map(it => it.split('\t')); + + if (lines.length === 1 && lines[0].length === 1) { + // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける + const ranges = context.rangedCells; + for (const cell of ranges) { + if (cell.column.setting.editable) { + callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting)); + } + } + } else { + // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける + const offsetRow = bounds.leftTop.row; + const offsetCol = bounds.leftTop.col; + const { columns, rows } = context; + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowIdx = row - offsetRow; + if (lines.length <= rowIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + const items = lines[rowIdx]; + for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { + const colIdx = col - offsetCol; + if (items.length <= colIdx) { + // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る + break; + } + + if (columns[col].setting.editable) { + callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting)); + } + } + } + } +} + +/** + * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。 + * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。 + */ +export function removeDataFromGrid( + context: GridContext, + callback: (cell: GridCell) => void, +) { + for (const cell of context.rangedCells) { + const { editable, events } = cell.column.setting; + if (editable) { + if (events?.delete) { + events.delete(cell, context); + } else { + callback(cell); + } + } + } +} diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts new file mode 100644 index 0000000000..b82e12b304 --- /dev/null +++ b/packages/frontend/src/components/grid/grid.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import { CellValue, GridCellSetting } from '@/components/grid/cell.js'; +import { GridColumnSetting } from '@/components/grid/column.js'; +import { GridRowSetting } from '@/components/grid/row.js'; + +export type GridSetting = { + root?: { + noOverflowStyle?: boolean; + rounded?: boolean; + outerBorder?: boolean; + }; + row?: GridRowSetting; + cols: GridColumnSetting[]; + cells?: GridCellSetting; +}; + +export type DataSource = Record<string, CellValue>; + +export type GridState = + 'normal' | + 'cellSelecting' | + 'cellEditing' | + 'colResizing' | + 'colSelecting' | + 'rowSelecting' | + 'hidden' + ; + +export type Size = { + width: number; + height: number; +} + +export type SizeStyle = number | 'auto' | undefined; + +export type AdditionalStyle = { + className?: string; + style?: Record<string, string | number>; +} + +export class GridEventEmitter extends EventEmitter<{ + 'forceRefreshContentSize': void; +}> { +} diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts new file mode 100644 index 0000000000..e0a317c9d3 --- /dev/null +++ b/packages/frontend/src/components/grid/row.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AdditionalStyle } from '@/components/grid/grid.js'; +import { GridCell } from '@/components/grid/cell.js'; +import { GridColumn } from '@/components/grid/column.js'; +import { MenuItem } from '@/types/menu.js'; +import { GridContext } from '@/components/grid/grid-event.js'; + +export const defaultGridRowSetting: Required<GridRowSetting> = { + showNumber: true, + selectable: true, + minimumDefinitionCount: 100, + styleRules: [], + contextMenuFactory: () => [], + events: {}, +}; + +export type GridRowStyleRuleConditionParams = { + row: GridRow, + targetCols: GridColumn[], + cells: GridCell[] +}; + +export type GridRowStyleRule = { + condition: (params: GridRowStyleRuleConditionParams) => boolean; + applyStyle: AdditionalStyle; +} + +export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[]; + +export type GridRowSetting = { + showNumber?: boolean; + selectable?: boolean; + minimumDefinitionCount?: number; + styleRules?: GridRowStyleRule[]; + contextMenuFactory?: GridRowContextMenuFactory; + events?: { + delete?: (rows: GridRow[]) => void; + } +} + +export type GridRow = { + index: number; + ranged: boolean; + using: boolean; + setting: GridRowSetting; + additionalStyles: AdditionalStyle[]; +} + +export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow { + return { + index, + ranged: false, + using: using, + setting, + additionalStyles: [], + }; +} + +export function resetRow(row: GridRow): void { + row.ranged = false; + row.using = false; + row.additionalStyles = []; +} + diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts new file mode 100644 index 0000000000..6c6ff6ae0d --- /dev/null +++ b/packages/frontend/src/components/hook/useLoading.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, h, ref } from 'vue'; +import MkLoading from '@/components/global/MkLoading.vue'; + +export const useLoading = (props?: { + static?: boolean; + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}) => { + const showingCnt = ref(0); + + const show = () => { + showingCnt.value++; + }; + + const close = (force?: boolean) => { + if (force) { + showingCnt.value = 0; + } else { + showingCnt.value = Math.max(0, showingCnt.value - 1); + } + }; + + const scope = <T>(fn: () => T) => { + show(); + + const result = fn(); + if (result instanceof Promise) { + return result.finally(() => close()); + } else { + close(); + return result; + } + }; + + const showing = computed(() => showingCnt.value > 0); + const component = computed(() => showing.value ? h(MkLoading, props) : null); + + return { + show, + close, + scope, + component, + showing, + }; +}; diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html deleted file mode 100644 index f9ce113687..0000000000 --- a/packages/frontend/src/index.html +++ /dev/null @@ -1,39 +0,0 @@ -<!-- - SPDX-FileCopyrightText: syuilo and misskey-project - SPDX-License-Identifier: AGPL-3.0-only ---> - -<!-- - 開発モードのviteはこのファイルを起点にサーバーを起動します。 - このファイルに書かれた [t]js のリンクと (s)cssのリンクと、その依存関係にあるファイルはビルドされます ---> - -<!DOCTYPE html> -<html> -<head> - <meta charset="UTF-8" /> - <title>[DEV] Loading...</title> - <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> - <meta - http-equiv="Content-Security-Policy" - content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/; - worker-src 'self' blob:; - script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh https://cdn.jsdelivr.net https://raw.esm.sh; - style-src 'self' 'unsafe-inline'; - img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com; - media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; - connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org https://api.friendlycaptcha.com https://raw.esm.sh; - frame-src *;" - /> - <meta property="og:site_name" content="[DEV BUILD] Sharkey" /> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="theme-color-orig" content="#86b300"> - <link rel='stylesheet' href='/assets/phosphor-icons/bold/style.css'> - <link rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css'> -</head> - -<body> -<div id="sharkey_app"></div> -<script type="module" src="./_dev_boot_.ts"></script> -</body> -</html> diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index a81f67aef3..59af5ad2b3 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -11,6 +11,7 @@ import * as Misskey from 'misskey-js'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; import type { Form, GetFormResultType } from '@/scripts/form.js'; import type { MenuItem } from '@/types/menu.js'; +import type { PostFormProps } from '@/types/post-form.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -28,15 +29,15 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; import { focusParent } from '@/scripts/focus.js'; -import type { PostFormProps } from '@/types/post-form.js'; export const openingWindowsCount = ref(0); +export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>; export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( endpoint: E, data: P, token?: string | null | undefined, - customErrors?: Record<string, { title?: string; text: string; }>, + customErrors?: ApiWithDialogCustomErrors, ) => { const promise = misskeyApi(endpoint, data, token); promiseDialog(promise, null, async (err) => { @@ -610,6 +611,27 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti }); } +export async function selectRole(params: { + initialRoleIds?: string[], + title?: string, + infoMessage?: string, + publicOnly?: boolean, +}): Promise< + { canceled: true; result: undefined; } | + { canceled: false; result: Misskey.entities.Role[] } +> { + return new Promise((resolve) => { + popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + done: roles => { + resolve({ canceled: false, result: roles }); + }, + close: () => { + resolve({ canceled: true, result: undefined }); + }, + }, 'dispose'); + }); +} + export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { return new Promise(resolve => { const { dispose } = popup(MkEmojiPickerDialog, { @@ -740,4 +762,3 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> { }); }); }*/ - diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index f35cbe8d5a..1f36589a49 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> <XEmojis/> </MkSpacer> - <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> + <MkSpacer v-else-if="instance.federation !== 'none' && tab === 'federation'" :contentMax="1000" :marginMin="20"> <XFederation/> </MkSpacer> <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> @@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -51,22 +52,34 @@ watch(tab, () => { const headerActions = computed(() => []); -const headerTabs = computed(() => [{ - key: 'overview', - title: i18n.ts.overview, -}, { - key: 'emojis', - title: i18n.ts.customEmojis, - icon: 'ph-smiley ph-bold ph-lg', -}, { - key: 'federation', - title: i18n.ts.federation, - icon: 'ti ti-whirl', -}, { - key: 'charts', - title: i18n.ts.charts, - icon: 'ti ti-chart-line', -}]); +const headerTabs = computed(() => { + const items = []; + + items.push({ + key: 'overview', + title: i18n.ts.overview, + }, { + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'ph-smiley ph-bold ph-lg', + }); + + 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; +}); definePageMetadata(() => ({ title: i18n.ts.instanceInfo, diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 2f6dac8097..e37df40f2f 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="botProtectionForm.savedState.provider === 'fc'" #suffix>FriendlyCaptcha</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 :form="botProtectionForm"/> + <template #footer> + <MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/> </template> <div class="_gaps_m"> <MkRadios v-model="botProtectionForm.state.provider"> - <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> + <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> @@ -30,71 +30,126 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRadios> <template v-if="botProtectionForm.state.provider === 'hcaptcha'"> - <MkInput v-model="botProtectionForm.state.hcaptchaSiteKey"> + <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"> + <MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce> <template #prefix><i class="ti ti-key"></i></template> <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> </MkInput> - <FormSlot> - <template #label>{{ i18n.ts.preview }}</template> - <MkCaptcha provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + <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> + </MkInfo> </template> + <template v-else-if="botProtectionForm.state.provider === 'mcaptcha'"> - <MkInput v-model="botProtectionForm.state.mcaptchaSiteKey"> + <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"> + <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"> + <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.preview }}</template> - <MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="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"> + <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"> + <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.preview }}</template> - <MkCaptcha provider="recaptcha" :sitekey="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> + </MkInfo> </template> + <template v-else-if="botProtectionForm.state.provider === 'turnstile'"> - <MkInput v-model="botProtectionForm.state.turnstileSiteKey"> + <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"> + <MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce> <template #prefix><i class="ti ti-key"></i></template> <template #label>{{ i18n.ts.turnstileSecretKey }}</template> </MkInput> - <FormSlot> - <template #label>{{ i18n.ts.preview }}</template> - <MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/> + <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> + </MkInfo> </template> + <template v-else-if="botProtectionForm.state.provider === 'fc'"> - <MkInput v-model="botProtectionForm.state.fcSiteKey"> + <MkInput v-model="botProtectionForm.state.fcSiteKey" debounce> <template #prefix><i class="ti ti-key"></i></template> <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> </MkInput> - <MkInput v-model="botProtectionForm.state.fcSecretKey"> + <MkInput v-model="botProtectionForm.state.fcSecretKey" debounce> <template #prefix><i class="ti ti-key"></i></template> <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> </MkInput> @@ -102,12 +157,32 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.preview }}</template> <MkCaptcha provider="fc" :sitekey="botProtectionForm.state.fcSiteKey"/> </FormSlot> + <FormSlot v-if="botProtectionForm.state.fcSiteKey"> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha + v-model="captchaResult" + provider="fc" + :sitekey="botProtectionForm.state.fcSiteKey" + :secretKey="botProtectionForm.state.fcSecretKey" + /> + </FormSlot> + <MkInfo> + <div :class="$style.captchaInfoMsg"> + <div> + {{ i18n.ts._captcha.testSiteKeyMessage }} + </div> + <div> + <span>ref: </span><a href="https://docs.friendlycaptcha.com/#/installation?id=_3-verifying-the-captcha-solution-on-the-server" target="_blank">FriendlyCaptcha Docs</a> + </div> + </div> + </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.preview }}</template> - <MkCaptcha provider="testcaptcha"/> + <template #label>{{ i18n.ts._captcha.verify }}</template> + <MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/> </FormSlot> </template> </div> @@ -115,7 +190,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; +import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import MkRadios from '@/components/MkRadios.vue'; import MkInput from '@/components/MkInput.vue'; import FormSlot from '@/components/form/slot.vue'; @@ -127,56 +203,114 @@ import { useForm } from '@/scripts/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; +import { ApiWithDialogCustomErrors } from '@/os.js'; const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); -const meta = await misskeyApi('admin/meta'); +const errorHandler: ApiWithDialogCustomErrors = { + // 検証リクエストそのものに失敗 + '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd': { + title: i18n.ts._captcha._error._requestFailed.title, + text: i18n.ts._captcha._error._requestFailed.text, + }, + // 検証リクエストの結果が不正 + 'c41c067f-24f3-4150-84b2-b5a3ae8c2214': { + title: i18n.ts._captcha._error._verificationFailed.title, + text: i18n.ts._captcha._error._verificationFailed.text, + }, + // 不明なエラー + 'f868d509-e257-42a9-99c1-42614b031a97': { + title: i18n.ts._captcha._error._unknown.title, + text: i18n.ts._captcha._error._unknown.text, + }, +}; + +const captchaResult = ref<string | null>(null); +const meta = await misskeyApi('admin/captcha/current'); const botProtectionForm = useForm({ - provider: meta.enableHcaptcha - ? 'hcaptcha' - : meta.enableRecaptcha - ? 'recaptcha' - : meta.enableTurnstile - ? 'turnstile' - : meta.enableMcaptcha - ? 'mcaptcha' - : meta.enableFC - ? 'fc' - : meta.enableTestcaptcha - ? 'testcaptcha' - : null, - hcaptchaSiteKey: meta.hcaptchaSiteKey, - hcaptchaSecretKey: meta.hcaptchaSecretKey, - mcaptchaSiteKey: meta.mcaptchaSiteKey, - mcaptchaSecretKey: meta.mcaptchaSecretKey, - mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl, - recaptchaSiteKey: meta.recaptchaSiteKey, - recaptchaSecretKey: meta.recaptchaSecretKey, - turnstileSiteKey: meta.turnstileSiteKey, - turnstileSecretKey: meta.turnstileSecretKey, - fcSiteKey: meta.fcSiteKey, - fcSecretKey: meta.fcSecretKey, + provider: meta.provider, + hcaptchaSiteKey: meta.hcaptcha.siteKey, + hcaptchaSecretKey: meta.hcaptcha.secretKey, + mcaptchaSiteKey: meta.mcaptcha.siteKey, + mcaptchaSecretKey: meta.mcaptcha.secretKey, + mcaptchaInstanceUrl: meta.mcaptcha.instanceUrl, + recaptchaSiteKey: meta.recaptcha.siteKey, + recaptchaSecretKey: meta.recaptcha.secretKey, + turnstileSiteKey: meta.turnstile.siteKey, + turnstileSecretKey: meta.turnstile.secretKey, + fcSiteKey: meta.fc.siteKey, + fcSecretKey: meta.fc.secretKey, }, async (state) => { - await os.apiWithDialog('admin/update-meta', { - enableHcaptcha: state.provider === 'hcaptcha', - hcaptchaSiteKey: state.hcaptchaSiteKey, - hcaptchaSecretKey: state.hcaptchaSecretKey, - enableMcaptcha: state.provider === 'mcaptcha', - mcaptchaSiteKey: state.mcaptchaSiteKey, - mcaptchaSecretKey: state.mcaptchaSecretKey, - mcaptchaInstanceUrl: state.mcaptchaInstanceUrl, - enableRecaptcha: state.provider === 'recaptcha', - recaptchaSiteKey: state.recaptchaSiteKey, - recaptchaSecretKey: state.recaptchaSecretKey, - enableTurnstile: state.provider === 'turnstile', - turnstileSiteKey: state.turnstileSiteKey, - turnstileSecretKey: state.turnstileSecretKey, - enableFC: state.provider === 'fc', - fcSiteKey: state.fcSiteKey, - fcSecretKey: state.fcSecretKey, - enableTestcaptcha: state.provider === 'testcaptcha', - }); - fetchInstance(true); + const provider = state.provider; + if (provider === 'none') { + await os.apiWithDialog( + 'admin/captcha/save', + { provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'] }, + undefined, + errorHandler, + ); + } else { + const sitekey = provider === 'hcaptcha' + ? state.hcaptchaSiteKey + : provider === 'mcaptcha' + ? state.mcaptchaSiteKey + : provider === 'recaptcha' + ? state.recaptchaSiteKey + : provider === 'turnstile' + ? state.turnstileSiteKey + : provider === 'fc' + ? state.fcSiteKey + : null; + const secret = provider === 'hcaptcha' + ? state.hcaptchaSecretKey + : provider === 'mcaptcha' + ? state.mcaptchaSecretKey + : provider === 'recaptcha' + ? state.recaptchaSecretKey + : provider === 'turnstile' + ? state.turnstileSecretKey + : provider === 'fc' + ? state.fcSecretKey + : null; + + await os.apiWithDialog( + 'admin/captcha/save', + { + provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'], + sitekey: sitekey, + secret: secret, + instanceUrl: state.mcaptchaInstanceUrl, + captchaResult: captchaResult.value, + }, + undefined, + errorHandler, + ); + } + + await fetchInstance(true); }); + +watch(botProtectionForm.state, () => { + captchaResult.value = null; +}); + +const canSaving = computed((): boolean => { + return (botProtectionForm.state.provider === 'none') || + (botProtectionForm.state.provider === 'hcaptcha' && !!captchaResult.value) || + (botProtectionForm.state.provider === 'mcaptcha' && !!captchaResult.value) || + (botProtectionForm.state.provider === 'recaptcha' && !!captchaResult.value) || + (botProtectionForm.state.provider === 'turnstile' && !!captchaResult.value) || + (botProtectionForm.state.provider === 'fc' && !!captchaResult.value) || + (botProtectionForm.state.provider === 'testcaptcha' && !!captchaResult.value); +}); + </script> + +<style lang="scss" module> +.captchaInfoMsg { + display: flex; + flex-direction: column; + gap: 8px; +} +</style> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts new file mode 100644 index 0000000000..141ab858d3 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type RequestLogItem = { + failed: boolean; + url: string; + name: string; + error?: string; +}; + +export const gridSortOrderKeys = [ + 'name', + 'category', + 'aliases', + 'type', + 'license', + 'host', + 'uri', + 'publicUrl', + 'isSensitive', + 'localOnly', + 'updatedAt', +] as const satisfies string[]; + +export type GridSortOrderKey = typeof gridSortOrderKeys[number]; + +export function emptyStrToUndefined(value: string | null) { + return value ? value : undefined; +} + +export function emptyStrToNull(value: string) { + return value === '' ? null : value; +} + +export function emptyStrToEmptyArray(value: string) { + return value === '' ? [] : value.split(' ').map(it => it.trim()); +} + +export function roleIdsParser(text: string): { id: string, name: string }[] { + // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない + try { + const obj = JSON.parse(text); + if (!Array.isArray(obj)) { + return []; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return []; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + console.warn(ex); + return []; + } +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue new file mode 100644 index 0000000000..4b145db0ed --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue @@ -0,0 +1,39 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkWindow + ref="uiWindow" + :initialWidth="400" + :initialHeight="500" + :canResize="true" + @closed="emit('closed')" +> + <template #header> + <i class="ti ti-notes" style="margin-right: 0.5em;"></i> {{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }} + </template> + <MkSpacer> + <XRegisterLogs :logs="logs"/> + </MkSpacer> +</MkWindow> +</template> + +<script setup lang="ts"> +import MkWindow from '@/components/MkWindow.vue'; +import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; + +import { i18n } from '@/i18n.js'; + +import type { RequestLogItem } from './custom-emojis-manager.impl.js'; + +defineProps<{ + logs: RequestLogItem[]; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +</script> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue new file mode 100644 index 0000000000..ae43507d66 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue @@ -0,0 +1,213 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkWindow + ref="uiWindow" + :initialWidth="400" + :initialHeight="500" + :canResize="true" + @closed="emit('closed')" +> + <template #header> + <i class="ti ti-search" style="margin-right: 0.5em;"></i> {{ i18n.ts.search }} + </template> + <div :class="$style.root"> + <MkSpacer> + <div class="_gaps"> + <div class="_gaps_s"> + <MkInput + v-model="model.name" + type="search" + autocapitalize="off" + > + <template #label>name</template> + </MkInput> + <MkInput + v-model="model.category" + type="search" + autocapitalize="off" + > + <template #label>category</template> + </MkInput> + <MkInput + v-model="model.aliases" + type="search" + autocapitalize="off" + > + <template #label>aliases</template> + </MkInput> + + <MkInput + v-model="model.type" + type="search" + autocapitalize="off" + > + <template #label>type</template> + </MkInput> + <MkInput + v-model="model.license" + type="search" + autocapitalize="off" + > + <template #label>license</template> + </MkInput> + <MkSelect + v-model="model.sensitive" + > + <template #label>sensitive</template> + <option :value="null">-</option> + <option :value="true">true</option> + <option :value="false">false</option> + </MkSelect> + + <MkSelect + v-model="model.localOnly" + > + <template #label>localOnly</template> + <option :value="null">-</option> + <option :value="true">true</option> + <option :value="false">false</option> + </MkSelect> + <MkInput + v-model="model.updatedAtFrom" + type="date" + autocapitalize="off" + > + <template #label>updatedAt(from)</template> + </MkInput> + <MkInput + v-model="model.updatedAtTo" + type="date" + autocapitalize="off" + > + <template #label>updatedAt(to)</template> + </MkInput> + + <MkInput + v-model="queryRolesText" + type="text" + readonly + autocapitalize="off" + @click="onQueryRolesEditClicked" + > + <template #label>role</template> + <template #suffix><i class="ti ti-pencil"></i></template> + </MkInput> + </div> + <MkFolder :spacerMax="8" :spacerMin="8"> + <template #icon><i class="ti ti-arrows-sort"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template> + <MkSortOrderEditor + :baseOrderKeyNames="gridSortOrderKeys" + :currentOrders="sortOrders" + @update="onSortOrderUpdate" + /> + </MkFolder> + </div> + </MkSpacer> + <div :class="$style.footerActions"> + <MkButton primary @click="onSearchRequest"> + {{ i18n.ts.search }} + </MkButton> + <MkButton @click="onQueryResetButtonClicked"> + {{ i18n.ts.reset }} + </MkButton> + </div> + </div> +</MkWindow> +</template> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue'; +import MkWindow from '@/components/MkWindow.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue'; + +import { + gridSortOrderKeys, +} from './custom-emojis-manager.impl.js'; + +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +import type { EmojiSearchQuery } from './custom-emojis-manager.local.list.vue'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; +import type { GridSortOrderKey } from './custom-emojis-manager.impl.js'; + +const props = defineProps<{ + query: EmojiSearchQuery; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'queryUpdated', query: EmojiSearchQuery): void; + (ev: 'sortOrderUpdated', orders: SortOrder<GridSortOrderKey>[]): void; + (ev: 'search'): void; +}>(); + +const model = ref<EmojiSearchQuery>(props.query); +const queryRolesText = computed(() => model.value.roles.map(it => it.name).join(',')); + +watch(model, () => { + emit('queryUpdated', model.value); +}, { deep: true }); + +const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]); + +function onSortOrderUpdate(orders: SortOrder<GridSortOrderKey>[]) { + sortOrders.value = orders; + emit('sortOrderUpdated', orders); +} + +function onSearchRequest() { + emit('search'); +} + +function onQueryResetButtonClicked() { + model.value.name = ''; + model.value.category = ''; + model.value.aliases = ''; + model.value.type = ''; + model.value.license = ''; + model.value.sensitive = null; + model.value.localOnly = null; + model.value.updatedAtFrom = ''; + model.value.updatedAtTo = ''; + sortOrders.value = []; +} + +async function onQueryRolesEditClicked() { + const result = await os.selectRole({ + initialRoleIds: model.value.roles.map(it => it.id), + title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle, + publicOnly: true, + }); + if (result.canceled) { + return; + } + + model.value.roles = result.result; +} +</script> + +<style module> +.root { + position: relative; +} + +.footerActions { + position: sticky; + bottom: 0; + padding: var(--MI-margin); + background-color: var(--MI_THEME-bg); + display: flex; + gap: 8px; + z-index: 1; +} +</style> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue new file mode 100644 index 0000000000..c4ea3b93e3 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -0,0 +1,660 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header> + <MkPageHeader :overridePageMetadata="headerPageMetadata" :actions="headerActions"/> + </template> + <template #default> + <div class="_gaps" :class="$style.main"> + <component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/> + <template v-else> + <div v-if="gridItems.length === 0" style="text-align: center"> + {{ i18n.ts._customEmojisManager._local._list.emojisNothing }} + </div> + + <template v-else> + <div :class="$style.grid"> + <MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/> + </div> + </template> + </template> + </div> + </template> + + <template #footer> + <div v-if="gridItems.length > 0" :class="$style.footer"> + <div :class="$style.left"> + <MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked"> + {{ i18n.ts.delete }} ({{ deleteItemsCount }}) + </MkButton> + </div> + + <div :class="$style.center"> + <MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/> + </div> + + <div :class="$style.right"> + <MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked"> + {{ i18n.ts.update }} ({{ updatedItemsCount }}) + </MkButton> + <MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton> + </div> + </div> + </template> +</MkStickyContainer> +</template> + +<script lang="ts"> +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; +import type { GridSortOrderKey } from './custom-emojis-manager.impl.js'; + +export type EmojiSearchQuery = { + name: string | null; + category: string | null; + aliases: string | null; + type: string | null; + license: string | null; + updatedAtFrom: string | null; + updatedAtTo: string | null; + sensitive: string | null; + localOnly: string | null; + roles: { id: string, name: string }[]; + sortOrders: SortOrder<GridSortOrderKey>[]; + limit: number; +}; +</script> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, onMounted, ref, nextTick, useCssModule } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; +import { + emptyStrToEmptyArray, + emptyStrToNull, + emptyStrToUndefined, + RequestLogItem, + roleIdsParser, +} from '@/pages/admin/custom-emojis-manager.impl.js'; +import MkGrid from '@/components/grid/MkGrid.vue'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import { validators } from '@/components/grid/cell-validators.js'; +import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import MkPagingButtons from '@/components/MkPagingButtons.vue'; +import { GridSetting } from '@/components/grid/grid.js'; +import { selectFile } from '@/scripts/select-file.js'; +import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; +import { useLoading } from "@/components/hook/useLoading.js"; + +type GridItem = { + checked: boolean; + id: string; + url: string; + name: string; + host: string; + category: string; + aliases: string; + license: string; + isSensitive: boolean; + localOnly: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; + fileId?: string; + updatedAt: string | null; + publicUrl?: string | null; + originalUrl?: string | null; + type: string | null; +} + +function setupGrid(): GridSetting { + const $style = useCssModule(); + + const required = validators.required(); + const regex = validators.regex(/^[a-zA-Z0-9_]+$/); + const unique = validators.unique(); + return { + root: { + noOverflowStyle: true, + rounded: false, + outerBorder: false, + }, + row: { + showNumber: true, + selectable: true, + // グリッドの行数をあらかじめ100行確保する + minimumDefinitionCount: 100, + styleRules: [ + { + // 初期値から変わっていたら背景色を変更 + condition: ({ row }) => JSON.stringify(gridItems.value[row.index]) !== JSON.stringify(originGridItems.value[row.index]), + applyStyle: { className: $style.changedRow }, + }, + { + // バリデーションに引っかかっていたら背景色を変更 + condition: ({ cells }) => cells.some(it => !it.violation.valid), + applyStyle: { className: $style.violationRow }, + }, + ], + // 行のコンテキストメニュー設定 + contextMenuFactory: (row, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows, + icon: 'ti ti-copy', + action: () => copyGridDataToClipboard(gridItems, context), + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRows, + icon: 'ti ti-trash', + action: () => { + for (const rangedRow of context.rangedRows) { + gridItems.value[rangedRow.index].checked = true; + } + }, + }, + ]; + }, + events: { + delete(rows) { + // 行削除時は元データの行を消さず、削除対象としてマークするのみにする + for (const row of rows) { + gridItems.value[row.index].checked = true; + } + }, + }, + }, + cols: [ + { bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 }, + { + bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required], + async customValueEditor(row, col, value, cellElement) { + const file = await selectFile(cellElement); + gridItems.value[row.index].url = file.url; + gridItems.value[row.index].fileId = file.id; + + return file.url; + }, + }, + { + bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, + validators: [required, regex, unique], + }, + { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, + { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 }, + { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, + { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 }, + { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 }, + { + bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140, + valueTransformer(row) { + // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする + return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction + .map((it) => it.name) + .join(','); + }, + async customValueEditor(row) { + // ID直記入は体験的に最悪なのでモーダルを使って入力する + const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction; + const result = await os.selectRole({ + initialRoleIds: current.map(it => it.id), + title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction, + infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription, + publicOnly: true, + }); + if (result.canceled) { + return current; + } + + const transform = result.result.map(it => ({ id: it.id, name: it.name })); + gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform; + + return transform; + }, + events: { + paste: roleIdsParser, + delete(cell) { + // デフォルトはundefinedになるが、このプロパティは空配列にしたい + gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = []; + }, + }, + }, + { bindTo: 'type', type: 'text', editable: false, width: 90 }, + { bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' }, + { bindTo: 'publicUrl', type: 'text', editable: false, width: 180 }, + { bindTo: 'originalUrl', type: 'text', editable: false, width: 180 }, + ], + cells: { + // セルのコンテキストメニュー設定 + contextMenuFactory(col, row, value, context) { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges, + icon: 'ti ti-copy', + action: () => { + return copyGridDataToClipboard(gridItems, context); + }, + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges, + icon: 'ti ti-trash', + action: () => { + removeDataFromGrid(context, (cell) => { + gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined; + }); + }, + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRanges, + icon: 'ti ti-trash', + action: () => { + for (const rowIdx of [...new Set(context.rangedCells.map(it => it.row.index)).values()]) { + gridItems.value[rowIdx].checked = true; + } + }, + }, + ]; + }, + }, + }; +} + +const loadingHandler = useLoading(); + +const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]); +const allPages = ref<number>(0); +const currentPage = ref<number>(0); + +const searchQuery = ref<EmojiSearchQuery>({ + name: null, + category: null, + aliases: null, + type: null, + license: null, + updatedAtFrom: null, + updatedAtTo: null, + sensitive: null, + localOnly: null, + roles: [], + sortOrders: [], + limit: 25, +}); +let searchWindowOpening = false; + +const previousQuery = ref<string | undefined>(undefined); +const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]); +const requestLogs = ref<RequestLogItem[]>([]); + +const gridItems = ref<GridItem[]>([]); +const originGridItems = ref<GridItem[]>([]); +const updateButtonDisabled = ref<boolean>(false); + +const updatedItemsCount = computed(() => { + return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length; +}); +const deleteItemsCount = computed(() => gridItems.value.filter(it => it.checked).length); + +async function onUpdateButtonClicked() { + const _items = gridItems.value; + const _originItems = originGridItems.value; + if (_items.length !== _originItems.length) { + throw new Error('The number of items has been changed. Please refresh the page and try again.'); + } + + const updatedItems = _items.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(_originItems[idx])); + if (updatedItems.length === 0) { + await os.alert({ + type: 'info', + text: i18n.ts._customEmojisManager._local._list.alertUpdateEmojisNothingDescription, + }); + return; + } + + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }), + }); + if (canceled) { + return; + } + + const action = () => { + return updatedItems.map(item => + misskeyApi( + 'admin/emoji/update', + { + // eslint-disable-next-line + id: item.id!, + name: item.name, + category: emptyStrToNull(item.category), + aliases: emptyStrToEmptyArray(item.aliases), + license: emptyStrToNull(item.license), + isSensitive: item.isSensitive, + localOnly: item.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id), + fileId: item.fileId, + }) + .then(() => ({ item, success: true, err: undefined })) + .catch(err => ({ item, success: false, err })), + ); + }; + + const result = await os.promiseDialog(Promise.all(action())); + const failedItems = result.filter(it => !it.success); + + if (failedItems.length > 0) { + await os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription, + }); + } + + requestLogs.value = result.map(it => ({ + failed: !it.success, + url: it.item.url, + name: it.item.name, + error: it.err ? JSON.stringify(it.err) : undefined, + })); + + await refreshCustomEmojis(); +} + +async function onDeleteButtonClicked() { + const _items = gridItems.value; + const _originItems = originGridItems.value; + if (_items.length !== _originItems.length) { + throw new Error('The number of items has been changed. Please refresh the page and try again.'); + } + + const deleteItems = _items.filter((it) => it.checked); + if (deleteItems.length === 0) { + await os.alert({ + type: 'info', + text: i18n.ts._customEmojisManager._local._list.alertDeleteEmojisNothingDescription, + }); + return; + } + + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }), + }); + if (canceled) { + return; + } + + async function action() { + const deleteIds = deleteItems.map(it => it.id!); + await misskeyApi('admin/emoji/delete-bulk', { ids: deleteIds }); + } + + await os.promiseDialog( + action(), + ); +} + +async function onGridResetButtonClicked() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.resetAreYouSure, + text: i18n.ts._customEmojisManager._local._list.confirmResetDescription, + }); + + if (canceled) return; + + refreshGridItems(); +} + +async function onSearchRequest() { + await refreshCustomEmojis(); +} + +async function onPageChanged(pageNumber: number) { + if (updatedItemsCount.value > 0) { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._customEmojisManager._local._list.confirmMovePage, + text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption, + }); + if (canceled) return; + } + + currentPage.value = pageNumber; + await nextTick(); + refreshCustomEmojis(); +} + +function onGridEvent(event: GridEvent) { + switch (event.type) { + case 'cell-validation': + onGridCellValidation(event); + break; + case 'cell-value-change': + onGridCellValueChange(event); + break; + } +} + +function onGridCellValidation(event: GridCellValidationEvent) { + updateButtonDisabled.value = event.all.filter(it => !it.valid).length > 0; +} + +function onGridCellValueChange(event: GridCellValueChangeEvent) { + const { row, column, newValue } = event; + if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { + gridItems.value[row.index][column.setting.bindTo] = newValue; + } +} + +async function refreshCustomEmojis() { + const limit = searchQuery.value.limit; + + const query: Misskey.entities.V2AdminEmojiListRequest['query'] = { + name: emptyStrToUndefined(searchQuery.value.name), + type: emptyStrToUndefined(searchQuery.value.type), + aliases: emptyStrToUndefined(searchQuery.value.aliases), + category: emptyStrToUndefined(searchQuery.value.category), + license: emptyStrToUndefined(searchQuery.value.license), + isSensitive: searchQuery.value.sensitive ? Boolean(searchQuery.value.sensitive).valueOf() : undefined, + localOnly: searchQuery.value.localOnly ? Boolean(searchQuery.value.localOnly).valueOf() : undefined, + updatedAtFrom: emptyStrToUndefined(searchQuery.value.updatedAtFrom), + updatedAtTo: emptyStrToUndefined(searchQuery.value.updatedAtTo), + roleIds: searchQuery.value.roles.map(it => it.id), + hostType: 'local', + }; + + if (JSON.stringify(query) !== previousQuery.value) { + currentPage.value = 1; + } + + const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', { + query: query, + limit: limit, + page: currentPage.value, + sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}` as any), + })); + + customEmojis.value = result.emojis; + allPages.value = result.allPages; + + previousQuery.value = JSON.stringify(query); + + refreshGridItems(); +} + +function refreshGridItems() { + gridItems.value = customEmojis.value.map(it => ({ + checked: false, + id: it.id, + fileId: undefined, + url: it.publicUrl, + name: it.name, + host: it.host ?? '', + category: it.category ?? '', + aliases: it.aliases.join(','), + license: it.license ?? '', + isSensitive: it.isSensitive, + localOnly: it.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction, + updatedAt: it.updatedAt, + publicUrl: it.publicUrl, + originalUrl: it.originalUrl, + type: it.type, + })); + originGridItems.value = JSON.parse(JSON.stringify(gridItems.value)); +} + +onMounted(async () => { + await refreshCustomEmojis(); +}); + +const headerPageMetadata = computed(() => ({ + title: i18n.ts._customEmojisManager._local.tabTitleList, + icon: 'ti ti-icons', +})); + +const headerActions = computed(() => [{ + icon: 'ti ti-search', + text: i18n.ts.search, + handler: () => { + if (searchWindowOpening) return; + searchWindowOpening = true; + const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), { + query: searchQuery.value, + }, { + queryUpdated: (query: EmojiSearchQuery) => { + searchQuery.value = query; + }, + sortOrderUpdated: (orders: SortOrder<GridSortOrderKey>[]) => { + sortOrders.value = orders; + }, + search: () => { + onSearchRequest(); + }, + closed: () => { + dispose(); + searchWindowOpening = false; + }, + }); + }, +}, { + icon: 'ti ti-list-numbers', + text: i18n.ts._customEmojisManager._gridCommon.searchLimit, + handler: (ev: MouseEvent) => { + async function changeSearchLimit(to: number) { + if (updatedItemsCount.value > 0) { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._customEmojisManager._local._list.confirmChangeView, + text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption, + }); + if (canceled) return; + } + + searchQuery.value.limit = to; + refreshCustomEmojis(); + } + + os.popupMenu([{ + type: 'radioOption', + text: '25', + active: computed(() => searchQuery.value.limit === 25), + action: () => changeSearchLimit(25), + }, { + type: 'radioOption', + text: '50', + active: computed(() => searchQuery.value.limit === 50), + action: () => changeSearchLimit(50), + }, { + type: 'radioOption', + text: '100', + active: computed(() => searchQuery.value.limit === 100), + action: () => changeSearchLimit(100), + }], ev.currentTarget ?? ev.target); + }, +}, { + icon: 'ti ti-notes', + text: i18n.ts._customEmojisManager._gridCommon.registrationLogs, + handler: () => { + const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), { + logs: requestLogs.value, + }, { + closed: () => { + dispose(); + }, + }); + } +}]); +</script> + +<style module lang="scss"> +.violationRow { + background-color: var(--MI_THEME-infoWarnBg); +} + +.changedRow { + background-color: var(--MI_THEME-infoBg); +} + +.editedRow { + background-color: var(--MI_THEME-infoBg); +} + +.main { + height: calc(100vh - var(--MI-stickyTop) - var(--MI-stickyBottom)); + overflow: scroll; +} + +.grid { + width: max-content; + border-bottom: 1px solid var(--MI_THEME-divider); +} + +.footer { + background-color: var(--MI_THEME-bg); + + padding: var(--MI-margin); + border-top: 1px solid var(--MI_THEME-divider); + + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + + & .left { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + } + + & .center { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + & .right { + display: flex; + align-items: center; + justify-content: flex-end; + flex-direction: row; + gap: 8px; + } +} + +.divider { + margin: 8px 0; + border-top: solid 0.5px var(--MI_THEME-divider); +} + +</style> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue new file mode 100644 index 0000000000..cc8b625cd5 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -0,0 +1,481 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkFolder> + <template #icon><i class="ti ti-settings"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._local._register.uploadSettingTitle }}</template> + <template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template> + + <div class="_gaps"> + <MkSelect v-model="selectedFolderId"> + <template #label>{{ i18n.ts.uploadFolder }}</template> + <option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id"> + {{ folder.name }} + </option> + </MkSelect> + + <MkSwitch v-model="keepOriginalUploading"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </MkSwitch> + + <MkSwitch v-model="directoryToCategory"> + <template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template> + <template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-notes"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template> + <template #caption> + {{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }} + </template> + <XRegisterLogs :logs="requestLogs"/> + </MkFolder> + + <div + :class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]" + @dragover.prevent="isDragOver = true" + @dragleave.prevent="isDragOver = false" + @drop.prevent.stop="onDrop" + > + <div style="margin-top: 1em"> + {{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }} + </div> + <ul> + <li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li> + <li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li> + <li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li> + </ul> + </div> + + <div v-if="gridItems.length > 0" :class="$style.gridArea"> + <MkGrid + :data="gridItems" + :settings="setupGrid()" + @event="onGridEvent" + /> + </div> + + <div v-if="gridItems.length > 0" :class="$style.footer"> + <MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked"> + {{ i18n.ts.registration }} + </MkButton> + <MkButton @click="onClearClicked"> + {{ i18n.ts.clear }} + </MkButton> + </div> +</div> +</template> + +<script setup lang="ts"> +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import * as Misskey from 'misskey-js'; +import { onMounted, ref, useCssModule } from 'vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { + emptyStrToEmptyArray, + emptyStrToNull, + RequestLogItem, + roleIdsParser, +} from '@/pages/admin/custom-emojis-manager.impl.js'; +import MkGrid from '@/components/grid/MkGrid.vue'; +import { i18n } from '@/i18n.js'; +import MkSelect from '@/components/MkSelect.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { defaultStore } from '@/store.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { validators } from '@/components/grid/cell-validators.js'; +import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; +import { uploadFile } from '@/scripts/upload.js'; +import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js'; +import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; +import { GridSetting } from '@/components/grid/grid.js'; +import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; +import { GridRow } from '@/components/grid/row.js'; + +const MAXIMUM_EMOJI_REGISTER_COUNT = 100; + +type FolderItem = { + id?: string; + name: string; +}; + +type GridItem = { + fileId: string; + url: string; + name: string; + host: string; + category: string; + aliases: string; + license: string; + isSensitive: boolean; + localOnly: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; + type: string | null; +} + +function setupGrid(): GridSetting { + const $style = useCssModule(); + + const required = validators.required(); + const regex = validators.regex(/^[a-zA-Z0-9_]+$/); + const unique = validators.unique(); + + function removeRows(rows: GridRow[]) { + const idxes = [...new Set(rows.map(it => it.index))]; + gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i)); + } + + return { + row: { + showNumber: true, + selectable: true, + minimumDefinitionCount: 100, + styleRules: [ + { + // 1つでもバリデーションエラーがあれば行全体をエラー表示する + condition: ({ cells }) => cells.some(it => !it.violation.valid), + applyStyle: { className: $style.violationRow }, + }, + ], + // 行のコンテキストメニュー設定 + contextMenuFactory: (row, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows, + icon: 'ti ti-copy', + action: () => copyGridDataToClipboard(gridItems, context), + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRows, + icon: 'ti ti-trash', + action: () => removeRows(context.rangedRows), + }, + ]; + }, + events: { + delete(rows) { + removeRows(rows); + }, + }, + }, + cols: [ + { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] }, + { + bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, + validators: [required, regex, unique], + }, + { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, + { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 }, + { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, + { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 }, + { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 }, + { + bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140, + valueTransformer: (row) => { + // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする + return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction + .map((it) => it.name) + .join(','); + }, + customValueEditor: async (row) => { + // ID直記入は体験的に最悪なのでモーダルを使って入力する + const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction; + const result = await os.selectRole({ + initialRoleIds: current.map(it => it.id), + title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction, + infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription, + publicOnly: true, + }); + if (result.canceled) { + return current; + } + + const transform = result.result.map(it => ({ id: it.id, name: it.name })); + gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform; + + return transform; + }, + events: { + paste: roleIdsParser, + delete(cell) { + // デフォルトはundefinedになるが、このプロパティは空配列にしたい + gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = []; + }, + }, + }, + { bindTo: 'type', type: 'text', editable: false, width: 90 }, + ], + cells: { + // セルのコンテキストメニュー設定 + contextMenuFactory: (col, row, value, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges, + icon: 'ti ti-copy', + action: () => copyGridDataToClipboard(gridItems, context), + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges, + icon: 'ti ti-trash', + action: () => removeRows(context.rangedCells.map(it => it.row)), + }, + ]; + }, + }, + }; +} + +const uploadFolders = ref<FolderItem[]>([]); +const gridItems = ref<GridItem[]>([]); +const selectedFolderId = ref(defaultStore.state.uploadFolder); +const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading); +const directoryToCategory = ref<boolean>(false); +const registerButtonDisabled = ref<boolean>(false); +const requestLogs = ref<RequestLogItem[]>([]); +const isDragOver = ref<boolean>(false); + +async function onRegistryClicked() { + const dialogSelection = await os.confirm({ + type: 'info', + text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }), + }); + + if (dialogSelection.canceled) { + return; + } + + const items = gridItems.value; + const upload = () => { + return items.slice(0, MAXIMUM_EMOJI_REGISTER_COUNT) + .map(item => + misskeyApi( + 'admin/emoji/add', { + name: item.name, + category: emptyStrToNull(item.category), + aliases: emptyStrToEmptyArray(item.aliases), + license: emptyStrToNull(item.license), + isSensitive: item.isSensitive, + localOnly: item.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id), + fileId: item.fileId!, + }) + .then(() => ({ item, success: true, err: undefined })) + .catch(err => ({ item, success: false, err })), + ); + }; + + const result = await os.promiseDialog(Promise.all(upload())); + const failedItems = result.filter(it => !it.success); + + if (failedItems.length > 0) { + await os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription, + }); + } + + requestLogs.value = result.map(it => ({ + failed: !it.success, + url: it.item.url, + name: it.item.name, + error: it.err ? JSON.stringify(it.err) : undefined, + })); + + // 登録に成功したものは一覧から除く + const successItems = result.filter(it => it.success).map(it => it.item); + gridItems.value = gridItems.value.filter(it => !successItems.includes(it)); +} + +async function onClearClicked() { + const result = await os.confirm({ + type: 'warning', + text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription, + }); + + if (!result.canceled) { + gridItems.value = []; + } +} + +async function onDrop(ev: DragEvent) { + isDragOver.value = false; + + const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it)); + const confirm = await os.confirm({ + type: 'info', + text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }), + }); + if (confirm.canceled) { + return; + } + + const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>(); + try { + uploadedItems.push( + ...await os.promiseDialog( + Promise.all( + droppedFiles.map(async (it) => ({ + droppedFile: it, + driveFile: await uploadFile( + it.file, + selectedFolderId.value, + it.file.name.replace(/\.[^.]+$/, ''), + keepOriginalUploading.value, + ), + }), + ), + ), + () => { + }, + () => { + }, + ), + ); + } catch (err) { + // ダイアログは共通部品側で出ているはずなので何もしない + return; + } + + const items = uploadedItems.map(({ droppedFile, driveFile }) => { + const item = fromDriveFile(driveFile); + if (directoryToCategory.value) { + item.category = droppedFile.path + .replace(/^\//, '') + .replace(/\/[^/]+$/, '') + .replace(droppedFile.file.name, ''); + } + return item; + }); + + gridItems.value.push(...items); +} + +async function onFileSelectClicked() { + const driveFiles = await chooseFileFromPc( + true, + { + uploadFolder: selectedFolderId.value, + keepOriginal: keepOriginalUploading.value, + // 拡張子は消す + nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + }, + ); + + gridItems.value.push(...driveFiles.map(fromDriveFile)); +} + +async function onDriveSelectClicked() { + const driveFiles = await chooseFileFromDrive(true); + gridItems.value.push(...driveFiles.map(fromDriveFile)); +} + +function onGridEvent(event: GridEvent) { + switch (event.type) { + case 'cell-validation': + onGridCellValidation(event); + break; + case 'cell-value-change': + onGridCellValueChange(event); + break; + } +} + +function onGridCellValidation(event: GridCellValidationEvent) { + registerButtonDisabled.value = event.all.filter(it => !it.valid).length > 0; +} + +function onGridCellValueChange(event: GridCellValueChangeEvent) { + const { row, column, newValue } = event; + if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { + gridItems.value[row.index][column.setting.bindTo] = newValue; + } +} + +function fromDriveFile(it: Misskey.entities.DriveFile): GridItem { + return { + fileId: it.id, + url: it.url, + name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''), + host: '', + category: '', + aliases: '', + license: '', + isSensitive: it.isSensitive, + localOnly: false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + type: it.type, + }; +} + +async function refreshUploadFolders() { + const result = await misskeyApi('drive/folders', {}); + uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result); +} + +onMounted(async () => { + await refreshUploadFolders(); +}); +</script> + +<style module lang="scss"> +.violationRow { + background-color: var(--MI_THEME-infoWarnBg); +} + +.uploadBox { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: auto; + border: 0.5px dotted var(--MI_THEME-accentedBg); + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-accentedBg); + box-sizing: border-box; + + &.dragOver { + cursor: copy; + } +} + +.gridArea { + padding-top: 8px; + padding-bottom: 8px; +} + +.footer { + background-color: var(--MI_THEME-bg); + + position: sticky; + left:0; + bottom:0; + z-index: 1; + // stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している + margin-top: calc(var(--MI-margin) * -1); + margin-bottom: calc(var(--MI-margin) * -1); + padding-top: var(--MI-margin); + padding-bottom: var(--MI-margin); + + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +</style> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue new file mode 100644 index 0000000000..6e7e7e53e3 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue @@ -0,0 +1,35 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header> + <MkPageHeader v-model:tab="headerTab" :tabs="headerTabs" hideTitle thin/> + </template> + <XListComponent v-if="headerTab === 'list'" key="localList"/> + <MkSpacer v-else key="localRegister"> + <XRegisterComponent/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script setup lang="ts"> +import { ref, computed } from 'vue'; +import { i18n } from '@/i18n.js'; +import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue'; +import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue'; + +type PageMode = 'list' | 'register'; + +const headerTab = ref<PageMode>('list'); + +const headerTabs = computed(() => [{ + key: 'list', + title: i18n.ts._customEmojisManager._local.tabTitleList, +}, { + key: 'register', + title: i18n.ts._customEmojisManager._local.tabTitleRegister, +}]); +</script> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue new file mode 100644 index 0000000000..eef55a9f7e --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue @@ -0,0 +1,88 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;"> + <MkSwitch v-model="showingSuccessLogs"> + <template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template> + </MkSwitch> + <div> + <div v-if="filteredLogs.length > 0"> + <MkGrid + :data="filteredLogs" + :settings="setupGrid()" + /> + </div> + <div v-else> + {{ i18n.ts._customEmojisManager._logs.failureLogNothing }} + </div> + </div> + </div> + <div v-else> + {{ i18n.ts._customEmojisManager._logs.logNothing }} + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, ref, toRefs } from 'vue'; +import { i18n } from '@/i18n.js'; +import MkGrid from '@/components/grid/MkGrid.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; + +import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridSetting } from '@/components/grid/grid.js'; + +function setupGrid(): GridSetting { + return { + row: { + showNumber: false, + selectable: false, + contextMenuFactory: (row, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows, + icon: 'ti ti-copy', + action: () => copyGridDataToClipboard(logs, context), + }, + ]; + }, + }, + cols: [ + { bindTo: 'failed', title: 'failed', type: 'boolean', editable: false, width: 50 }, + { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' }, + { bindTo: 'name', title: 'name', type: 'text', editable: false, width: 140 }, + { bindTo: 'error', title: 'log', type: 'text', editable: false, width: 'auto' }, + ], + cells: { + contextMenuFactory: (col, row, value, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges, + icon: 'ti ti-copy', + action: () => copyGridDataToClipboard(logs, context), + }, + ]; + }, + }, + }; +} + +const props = defineProps<{ + logs: RequestLogItem[]; +}>(); + +const { logs } = toRefs(props); +const showingSuccessLogs = ref<boolean>(false); + +const filteredLogs = computed(() => { + const forceShowing = showingSuccessLogs.value; + return logs.value.filter((log) => forceShowing || log.failed); +}); +</script> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue new file mode 100644 index 0000000000..eecf8d7390 --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -0,0 +1,503 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #default> + <div :class="$style.root" class="_gaps"> + <MkFolder> + <template #icon><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template> + <template #caption> + {{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }} + </template> + + <div class="_gaps"> + <div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]"> + <MkInput + v-model="queryName" + type="search" + autocapitalize="off" + :class="[$style.col1, $style.row1]" + @enter="onSearchRequest" + > + <template #label>name</template> + </MkInput> + <MkInput + v-model="queryHost" + type="search" + autocapitalize="off" + :class="[$style.col2, $style.row1]" + @enter="onSearchRequest" + > + <template #label>host</template> + </MkInput> + <MkInput + v-model="queryLicense" + type="search" + autocapitalize="off" + :class="[$style.col3, $style.row1]" + @enter="onSearchRequest" + > + <template #label>license</template> + </MkInput> + + <MkInput + v-model="queryUri" + type="search" + autocapitalize="off" + :class="[$style.col1, $style.row2]" + @enter="onSearchRequest" + > + <template #label>uri</template> + </MkInput> + <MkInput + v-model="queryPublicUrl" + type="search" + autocapitalize="off" + :class="[$style.col2, $style.row2]" + @enter="onSearchRequest" + > + <template #label>publicUrl</template> + </MkInput> + </div> + + <hr> + + <MkFolder :spacerMax="8" :spacerMin="8"> + <template #icon><i class="ti ti-arrows-sort"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template> + <MkSortOrderEditor + :baseOrderKeyNames="gridSortOrderKeys" + :currentOrders="sortOrders" + @update="onSortOrderUpdate" + /> + </MkFolder> + + <MkInput + v-model="queryLimit" + type="number" + :max="100" + > + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchLimit }}</template> + </MkInput> + + <div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]"> + <MkButton primary @click="onSearchRequest"> + {{ i18n.ts.search }} + </MkButton> + <MkButton @click="onQueryResetButtonClicked"> + {{ i18n.ts.reset }} + </MkButton> + </div> + </div> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-notes"></i></template> + <template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template> + <template #caption> + {{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }} + </template> + <XRegisterLogs :logs="requestLogs"/> + </MkFolder> + + <component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/> + <template v-else> + <div v-if="gridItems.length === 0" style="text-align: center"> + {{ i18n.ts._customEmojisManager._local._list.emojisNothing }} + </div> + + <template v-else> + <div v-if="gridItems.length > 0" :class="$style.gridArea"> + <MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/> + </div> + + <div :class="$style.footer"> + <div> + <!-- レイアウト調整用のスペース --> + </div> + + <div :class="$style.center"> + <MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/> + </div> + + <div :class="$style.right"> + <MkButton primary @click="onImportClicked"> + {{ + i18n.ts._customEmojisManager._remote.importEmojisButton + }} ({{ checkedItemsCount }}) + </MkButton> + </div> + </div> + </template> + </template> + </div> + </template> +</MkStickyContainer> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, useCssModule } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkGrid from '@/components/grid/MkGrid.vue'; +import { + emptyStrToUndefined, + GridSortOrderKey, + gridSortOrderKeys, + RequestLogItem, +} from '@/pages/admin/custom-emojis-manager.impl.js'; +import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import MkFolder from '@/components/MkFolder.vue'; +import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; +import * as os from '@/os.js'; +import { GridSetting } from '@/components/grid/grid.js'; +import { deviceKind } from '@/scripts/device-kind.js'; +import MkPagingButtons from '@/components/MkPagingButtons.vue'; +import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue'; +import { SortOrder } from '@/components/MkSortOrderEditor.define.js'; +import { useLoading } from '@/components/hook/useLoading.js'; + +type GridItem = { + checked: boolean; + id: string; + url: string; + name: string; + host: string; +} + +function setupGrid(): GridSetting { + const $style = useCssModule(); + + return { + row: { + // グリッドの行数をあらかじめ100行確保する + minimumDefinitionCount: 100, + styleRules: [ + { + // チェックされたら背景色を変える + condition: ({ row }) => gridItems.value[row.index].checked, + applyStyle: { className: $style.changedRow }, + }, + ], + contextMenuFactory: (row, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._remote.importSelectionRows, + icon: 'ti ti-download', + action: async () => { + const targets = context.rangedRows.map(it => gridItems.value[it.index]); + await importEmojis(targets); + }, + }, + ]; + }, + }, + cols: [ + { bindTo: 'checked', icon: 'ti-download', type: 'boolean', editable: true, width: 34 }, + { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' }, + { bindTo: 'name', title: 'name', type: 'text', editable: false, width: 'auto' }, + { bindTo: 'host', title: 'host', type: 'text', editable: false, width: 'auto' }, + { bindTo: 'license', title: 'license', type: 'text', editable: false, width: 200 }, + { bindTo: 'uri', title: 'uri', type: 'text', editable: false, width: 'auto' }, + { bindTo: 'publicUrl', title: 'publicUrl', type: 'text', editable: false, width: 'auto' }, + ], + cells: { + contextMenuFactory: (col, row, value, context) => { + return [ + { + type: 'button', + text: i18n.ts._customEmojisManager._remote.selectionRowDetail, + icon: 'ti ti-info-circle', + action: async () => { + const target = customEmojis.value[row.index]; + const { dispose } = os.popup(MkRemoteEmojiEditDialog, { + emoji: { + id: target.id, + name: target.name, + host: target.host!, + license: target.license, + url: target.publicUrl, + }, + }, { + done: () => { + dispose(); + }, + closed: () => { + dispose(); + }, + }); + }, + }, + { + type: 'button', + text: i18n.ts._customEmojisManager._remote.importSelectionRangesRows, + icon: 'ti ti-download', + action: async () => { + const targets = context.rangedCells.map(it => gridItems.value[it.row.index]); + await importEmojis(targets); + }, + }, + ]; + }, + }, + }; +} + +const loadingHandler = useLoading(); + +const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]); +const allPages = ref<number>(0); +const currentPage = ref<number>(0); + +const queryName = ref<string | null>(null); +const queryHost = ref<string | null>(null); +const queryLicense = ref<string | null>(null); +const queryUri = ref<string | null>(null); +const queryPublicUrl = ref<string | null>(null); +const queryLimit = ref<number>(25); +const previousQuery = ref<string | undefined>(undefined); +const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]); +const requestLogs = ref<RequestLogItem[]>([]); + +const gridItems = ref<GridItem[]>([]); + +const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind)); +const checkedItemsCount = computed(() => gridItems.value.filter(it => it.checked).length); + +function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) { + sortOrders.value = _sortOrders; +} + +async function onSearchRequest() { + await refreshCustomEmojis(); +} + +function onQueryResetButtonClicked() { + queryName.value = null; + queryHost.value = null; + queryLicense.value = null; + queryUri.value = null; + queryPublicUrl.value = null; +} + +async function onPageChanged(pageNumber: number) { + currentPage.value = pageNumber; + await refreshCustomEmojis(); +} + +async function onImportClicked() { + const targets = gridItems.value.filter(it => it.checked); + await importEmojis(targets); +} + +function onGridEvent(event: GridEvent) { + switch (event.type) { + case 'cell-value-change': + onGridCellValueChange(event); + break; + } +} + +function onGridCellValueChange(event: GridCellValueChangeEvent) { + const { row, column, newValue } = event; + if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { + gridItems.value[row.index][column.setting.bindTo] = newValue; + } +} + +async function importEmojis(targets: GridItem[]) { + const confirm = await os.confirm({ + type: 'info', + title: i18n.ts._customEmojisManager._remote.confirmImportEmojisTitle, + text: i18n.tsx._customEmojisManager._remote.confirmImportEmojisDescription({ count: targets.length }), + }); + + if (confirm.canceled) { + return; + } + + const result = await os.promiseDialog( + Promise.all( + targets.map(item => + misskeyApi( + 'admin/emoji/copy', + { + emojiId: item.id!, + }) + .then(() => ({ item, success: true, err: undefined })) + .catch(err => ({ item, success: false, err })), + ), + ), + ); + const failedItems = result.filter(it => !it.success); + + if (failedItems.length > 0) { + await os.alert({ + type: 'error', + title: i18n.ts.somethingHappened, + text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription, + }); + } + + requestLogs.value = result.map(it => ({ + failed: !it.success, + url: it.item.url, + name: it.item.name, + error: it.err ? JSON.stringify(it.err) : undefined, + })); + + await refreshCustomEmojis(); +} + +async function refreshCustomEmojis() { + const query: Misskey.entities.V2AdminEmojiListRequest['query'] = { + name: emptyStrToUndefined(queryName.value), + host: emptyStrToUndefined(queryHost.value), + license: emptyStrToUndefined(queryLicense.value), + uri: emptyStrToUndefined(queryUri.value), + publicUrl: emptyStrToUndefined(queryPublicUrl.value), + hostType: 'remote', + }; + + if (JSON.stringify(query) !== previousQuery.value) { + currentPage.value = 1; + } + + const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', { + limit: queryLimit.value, + query: query, + page: currentPage.value, + sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[], + })); + + customEmojis.value = result.emojis; + allPages.value = result.allPages; + previousQuery.value = JSON.stringify(query); + gridItems.value = customEmojis.value.map(it => ({ + checked: false, + id: it.id, + url: it.publicUrl, + name: it.name, + license: it.license, + host: it.host!, + })); +} + +onMounted(async () => { + await refreshCustomEmojis(); +}); +</script> + +<style module lang="scss"> +.row1 { + grid-row: 1 / 2; +} + +.row2 { + grid-row: 2 / 3; +} + +.col1 { + grid-column: 1 / 2; +} + +.col2 { + grid-column: 2 / 3; +} + +.col3 { + grid-column: 3 / 4; +} + +.root { + padding: 16px; +} + +.changedRow { + background-color: var(--MI_THEME-infoBg); +} + +.searchArea { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; +} + +.searchButtons { + display: flex; + justify-content: flex-end; + align-items: flex-end; + gap: 8px; +} + +.searchButtonsSp { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; +} + +.searchAreaSp { + display: flex; + flex-direction: column; + gap: 8px; +} + +.gridArea { + padding-top: 8px; + padding-bottom: 8px; +} + +.pages { + display: flex; + justify-content: center; + align-items: center; + + button { + background-color: var(--MI_THEME-buttonBg); + border-radius: 9999px; + border: none; + margin: 0 4px; + padding: 8px; + } +} + +.footer { + background-color: var(--MI_THEME-bg); + + position: sticky; + left:0; + bottom:0; + z-index: 1; + // stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している + margin-top: calc(var(--MI-margin) * -1); + margin-bottom: calc(var(--MI-margin) * -1); + padding-top: var(--MI-margin); + padding-bottom: var(--MI-margin); + + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + + & .center { + display: flex; + justify-content: center; + align-items: center; + } + + & .right { + display: flex; + justify-content: flex-end; + align-items: center; + } +} +</style> diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts new file mode 100644 index 0000000000..f62304277a --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { delay, http, HttpResponse } from 'msw'; +import { StoryObj } from '@storybook/vue3'; +import { entities } from 'misskey-js'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import { emoji } from '../../../.storybook/fakes.js'; +import { fakeId } from '../../../.storybook/fake-utils.js'; +import custom_emojis_manager2 from './custom-emojis-manager2.vue'; + +function createRender(params: { + emojis: entities.EmojiDetailedAdmin[]; +}) { + const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis]; + const storedDriveFiles: entities.DriveFile[] = []; + + return { + render(args) { + return { + components: { + custom_emojis_manager2, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<custom_emojis_manager2 v-bind="props" />', + }; + }, + args: { + + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/v2/admin/emoji/list', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest; + + const emojis = storedEmojis; + const limit = body.limit ?? 10; + const page = body.page ?? 1; + const result = emojis.slice((page - 1) * limit, page * limit); + + return HttpResponse.json({ + emojis: result, + count: Math.min(emojis.length, limit), + allCount: emojis.length, + allPages: Math.ceil(emojis.length / limit), + }); + }), + http.post('/api/drive/folders', () => { + return HttpResponse.json([]); + }), + http.post('/api/drive/files', () => { + return HttpResponse.json(storedDriveFiles); + }), + http.post('/api/drive/files/create', async ({ request }) => { + const data = await request.formData(); + const file = data.get('file'); + if (!file || !(file instanceof File)) { + return HttpResponse.json({ error: 'file is required' }, { + status: 400, + }); + } + + // FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある + const base64 = await new Promise<string>((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(new Blob([file], { type: 'image/webp' })); + }); + + const driveFile: entities.DriveFile = { + id: fakeId(file.name), + createdAt: new Date().toISOString(), + name: file.name, + type: file.type, + md5: '', + size: file.size, + isSensitive: false, + blurhash: null, + properties: {}, + url: base64, + thumbnailUrl: null, + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; + + storedDriveFiles.push(driveFile); + + return HttpResponse.json(driveFile); + }), + http.post('api/admin/emoji/add', async ({ request }) => { + await delay(100); + + const bodyStream = request.body as ReadableStream; + const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest; + + const fileId = body.fileId; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const file = storedDriveFiles.find(f => f.id === fileId)!; + + const em = emoji({ + id: fakeId(file.name), + name: body.name, + publicUrl: file.url, + originalUrl: file.url, + type: file.type, + aliases: body.aliases, + category: body.category ?? undefined, + license: body.license ?? undefined, + localOnly: body.localOnly, + isSensitive: body.isSensitive, + }); + storedEmojis.push(em); + + return HttpResponse.json(null); + }), + ], + }, + }, + } satisfies StoryObj<typeof custom_emojis_manager2>; +} + +export const Default = createRender({ + emojis: [], +}); + +export const List10 = createRender({ + emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List100 = createRender({ + emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); + +export const List1000 = createRender({ + emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())), +}); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue new file mode 100644 index 0000000000..fb930064ff --- /dev/null +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -0,0 +1,51 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkStickyContainer> + <template #header> + <MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/> + </template> + <XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/> + <XGridRemoteComponent v-else/> + </MkStickyContainer> +</div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue'; +import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue'; +import MkPageHeader from '@/components/global/MkPageHeader.vue'; +import MkStickyContainer from '@/components/global/MkStickyContainer.vue'; + +type PageMode = 'local' | 'remote'; + +const headerTab = ref<PageMode>('local'); + +const headerTabs = computed(() => [{ + key: 'local', + title: i18n.ts.local, +}, { + key: 'remote', + title: i18n.ts.remote, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.customEmojis, + icon: 'ti ti-icons', + needWideArea: true, +}))); +</script> + +<style lang="css" module> +.local { + height: calc(100dvh - var(--MI-stickyTop) - var(--MI-stickyBottom)); + overflow: clip; +} +</style> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index ef6bbb865b..188678c183 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="federating">{{ i18n.ts.federating }}</option> <option value="subscribing">{{ i18n.ts.subscribing }}</option> <option value="publishing">{{ i18n.ts.publishing }}</option> + <!-- TODO translate --> <option value="nsfw">NSFW</option> <option value="suspended">{{ i18n.ts.suspended }}</option> <option value="blocked">{{ i18n.ts.blocked }}</option> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 6cdf0eda7a..cbd0d12dcc 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; import { lookup } from '@/scripts/lookup.js'; @@ -56,7 +57,7 @@ const indexInfo = { provide('shouldOmitHeaderTitle', false); -const INFO = ref(indexInfo); +const INFO = ref<PageMetadata>(indexInfo); const childInfo = ref<null | PageMetadata>(null); const narrow = ref(false); const view = ref(null); @@ -91,7 +92,7 @@ const ro = new ResizeObserver((entries, observer) => { narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; }); -const menuDef = computed(() => [{ +const menuDef = computed<SuperMenuDef[]>(() => [{ title: i18n.ts.quickAction, items: [{ type: 'button', @@ -99,7 +100,7 @@ const menuDef = computed(() => [{ text: i18n.ts.lookup, action: adminLookup, }, ...(instance.disableRegistration ? [{ - type: 'button', + type: 'button' as const, icon: 'ti ti-user-plus', text: i18n.ts.createInviteCode, action: invite, @@ -137,6 +138,11 @@ const menuDef = computed(() => [{ to: '/admin/emojis', active: currentPage.value?.route.name === 'emojis', }, { + icon: 'ti ti-icons', + text: i18n.ts.customEmojis + '(beta)', + to: '/admin/emojis2', + active: currentPage.value?.route.name === 'emojis2', + }, { icon: 'ti ti-sparkles', text: i18n.ts.avatarDecorations, to: '/admin/avatar-decorations', @@ -343,12 +349,14 @@ defineExpose({ height: 100%; > .nav { + position: sticky; + top: 0; width: 32%; max-width: 280px; box-sizing: border-box; border-right: solid 0.5px var(--MI_THEME-divider); overflow: auto; - height: 100%; + height: 100dvh; } > .main { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 5d896db98c..6bab594d36 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -641,7 +641,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts._role.useBaseValue }}</template> </MkSwitch> - <MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0"> + <MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit"> <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template> </MkInput> <MkRange v-model="role.policies.avatarDecorationLimit.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 : ''"> @@ -757,6 +757,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref, computed } from 'vue'; import { throttle } from 'throttle-debounce'; +import { ROLE_POLICIES } from '@@/js/const.js'; import RolesEditorFormula from './RolesEditorFormula.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; @@ -767,7 +768,6 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRange from '@/components/MkRange.vue'; import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; -import { ROLE_POLICIES } from '@@/js/const.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/scripts/clone.js'; @@ -793,6 +793,12 @@ for (const ROLE_POLICY of ROLE_POLICIES) { } } +function updateAvatarDecorationLimit(value: string | number) { + const numValue = Number(value); + const limited = Math.min(16, Math.max(0, numValue)); + role.value.policies.avatarDecorationLimit.value = limited; +} + const rolePermission = computed({ get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal', set: (val) => { diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 036f18fe0d..f67b1cd582 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -239,7 +239,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])"> <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template> <template #suffix>{{ policies.avatarDecorationLimit }}</template> - <MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0"> + <MkInput v-model="avatarDecorationLimit" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit"> </MkInput> </MkFolder> @@ -334,6 +334,17 @@ for (const ROLE_POLICY of ROLE_POLICIES) { policies[ROLE_POLICY] = instance.policies[ROLE_POLICY]; } +const avatarDecorationLimit = computed({ + get: () => Math.min(16, Math.max(0, policies.avatarDecorationLimit)), + set: (value) => { + policies.avatarDecorationLimit = Math.min(Number(value), 16); + }, +}); + +function updateAvatarDecorationLimit(value: string | number) { + avatarDecorationLimit.value = Number(value); +} + function matchQuery(keywords: string[]): boolean { if (baseRoleQ.value.trim().length === 0) return true; return keywords.some(keyword => keyword.toLowerCase().includes(baseRoleQ.value.toLowerCase())); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 9cd2546312..fb99379a0a 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNotes :pagination="featuredPagination"/> </div> <div v-else-if="tab === 'search'" key="search"> - <div class="_gaps"> + <div v-if="notesSearchAvailable" class="_gaps"> <div> <MkInput v-model="searchQuery" @enter="search()"> <template #prefix><i class="ti ti-search"></i></template> @@ -54,6 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> + <div v-else> + <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> + </div> </div> </MkHorizontalSwipe> </MkSpacer> @@ -94,6 +97,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { PageHeaderItem } from '@/types/page-header.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { notesSearchAvailable } from '@/scripts/check-permissions.js'; import { miLocalStorage } from '@/local-storage.js'; import { useRouter } from '@/router/supplier.js'; import { deepMerge } from '@/scripts/merge.js'; diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index bde1650754..6830c1ace4 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> + <MkSpacer :contentMax="1200"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'search'" key="search"> + <div v-if="tab === 'search'" key="search" :class="$style.searchRoot"> <div class="_gaps"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <template #prefix><i class="ti ti-search"></i></template> @@ -27,23 +27,31 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-if="tab === 'featured'" key="featured"> <MkPagination v-slot="{items}" :pagination="featuredPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + <div :class="$style.root"> + <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> + </div> </MkPagination> </div> <div v-else-if="tab === 'favorites'" key="favorites"> <MkPagination v-slot="{items}" :pagination="favoritesPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + <div :class="$style.root"> + <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> + </div> </MkPagination> </div> <div v-else-if="tab === 'following'" key="following"> <MkPagination v-slot="{items}" :pagination="followingPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + <div :class="$style.root"> + <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> + </div> </MkPagination> </div> <div v-else-if="tab === 'owned'" key="owned"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="ownedPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + <div :class="$style.root"> + <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> + </div> </MkPagination> </div> </MkHorizontalSwipe> @@ -85,6 +93,7 @@ onMounted(() => { const featuredPagination = { endpoint: 'channels/featured' as const, + limit: 10, noPaging: true, }; const favoritesPagination = { @@ -157,3 +166,17 @@ definePageMetadata(() => ({ icon: 'ti ti-device-tv', })); </script> + +<style lang="scss" module> +.searchRoot { + width: 100%; + max-width: 700px; + margin: 0 auto; +} + +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: var(--MI-margin); +} +</style> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 716cd9a73f..240f395e04 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -46,9 +46,10 @@ import { clipsCache } from '@/cache.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { genEmbedCode } from '@/scripts/get-embed-code.js'; -import { getServerContext } from '@/server-context.js'; +import { assertServerContext, serverContext } from '@/server-context.js'; -const CTX_CLIP = getServerContext('clip'); +// contextは非ログイン状態の情報しかないためログイン時は利用できない +const CTX_CLIP = !$i && assertServerContext(serverContext, 'clip') ? serverContext.clip : null; const props = defineProps<{ clipId: string, diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 850c1c5eb0..107a0d760c 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{items}"> <div class="ldhfsamy"> <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> - <img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/> + <img :src="emoji.url" class="img" :alt="emoji.name"/> <div class="body"> <div class="name _monospace">{{ emoji.name }}</div> <div class="info">{{ emoji.category }}</div> @@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{items}"> <div class="ldhfsamy"> <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> - <img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/> + <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> <div class="body"> <div class="name _monospace">{{ emoji.name }}</div> <div class="info">{{ emoji.host }}</div> @@ -78,11 +78,13 @@ import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; +import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; import { selectFile } from '@/scripts/select-file.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -161,6 +163,19 @@ const edit = (emoji) => { }); }; +const detailRemoteEmoji = (emoji) => { + const { dispose } = os.popup(MkRemoteEmojiEditDialog, { + emoji: emoji, + }, { + done: () => { + dispose(); + }, + closed: () => { + dispose(); + }, + }); +}; + const importEmoji = (emoji) => { os.apiWithDialog('admin/emoji/copy', { emojiId: emoji.id, @@ -171,13 +186,15 @@ const remoteMenu = (emoji, ev: MouseEvent) => { os.popupMenu([{ type: 'label', text: ':' + emoji.name + ':', - }, - { + }, { + text: i18n.ts.details, + icon: 'ti ti-info-circle', + action: () => { detailRemoteEmoji(emoji); }, + }, { text: i18n.ts.import, icon: 'ti ti-plus', action: () => { importEmoji(emoji); }, - }, - { + }, { text: i18n.ts.delete, icon: 'ph-trash ph-bold ph-lg', action: () => { diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index d3e9ca0dcf..c8e6dfb05a 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -118,7 +118,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); }, { immediate: true }); -const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); +const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null); async function changeImage(ev: Event) { file.value = await selectFile(ev.currentTarget ?? ev.target, null); diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 2550100a42..0d2c6217d4 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkSpacer :contentMax="1200"> - <MkTab v-model="origin" style="margin-bottom: var(--MI-margin);"> + <MkTab v-if="instance.federation !== 'none'" v-model="origin" style="margin-bottom: var(--MI-margin);"> <option value="local">{{ i18n.ts.local }}</option> <option value="remote">{{ i18n.ts.remote }}</option> </MkTab> @@ -69,6 +69,7 @@ import MkUserList from '@/components/MkUserList.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkTab from '@/components/MkTab.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; +import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index e85d2c29c1..ab060587c5 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -59,18 +59,18 @@ async function onAccept(token: string) { name: props.name, iconUrl: props.icon, permission: _permissions.value, - }, token).catch(() => { + }, token).then(() => { + if (props.callback && props.callback !== '') { + const cbUrl = new URL(props.callback); + if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url'); + cbUrl.searchParams.set('session', props.session); + location.href = cbUrl.toString(); + } else { + authRoot.value?.showUI('success'); + } + }).catch(() => { authRoot.value?.showUI('failed'); }); - - if (props.callback && props.callback !== '') { - const cbUrl = new URL(props.callback); - if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url'); - cbUrl.searchParams.set('session', props.session); - location.href = cbUrl.toString(); - } else { - authRoot.value?.showUI('success'); - } } function onDeny() { @@ -117,5 +117,6 @@ definePageMetadata(() => ({ border-radius: var(--MI-radius); background-color: var(--MI_THEME-panel); overflow-x: scroll; + white-space: nowrap; } </style> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 737b0eea4c..b70bff052a 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { host } from '@@/js/config.js'; import type { Paging } from '@/components/MkPagination.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; @@ -61,9 +62,11 @@ import { dateString } from '@/filters/date.js'; import MkClipPreview from '@/components/MkClipPreview.vue'; import { defaultStore } from '@/store.js'; import { pleaseLogin } from '@/scripts/please-login.js'; -import { getServerContext } from '@/server-context.js'; +import { serverContext, assertServerContext } from '@/server-context.js'; +import { $i } from '@/account.js'; -const CTX_NOTE = getServerContext('note'); +// contextは非ログイン状態の情報しかないためログイン時は利用できない +const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null; const MkNoteDetailed = defineAsyncComponent(() => (defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') : @@ -146,7 +149,12 @@ function fetchNote() { }).catch(err => { if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') { pleaseLogin({ + path: '/', message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor, + openOnRemote: { + type: 'lookup', + url: `https://${host}/notes/${props.noteId}`, + }, }); } error.value = err; diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index ac9f3e7401..8597654375 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -96,7 +96,7 @@ const summary = ref<string | null>(null); const name = ref(Date.now().toString()); const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null); const eyeCatchingImageId = ref<string | null>(null); -const font = ref('sans-serif'); +const font = ref<'sans-serif' | 'serif'>('sans-serif'); const content = ref<Misskey.entities.Page['content']>([]); const alignCenter = ref(false); const hideTitleWhenPinned = ref(false); @@ -113,7 +113,7 @@ watch(eyeCatchingImageId, async () => { } }); -function getSaveOptions() { +function getSaveOptions(): Misskey.entities.PagesCreateRequest { return { title: title.value.trim(), name: name.value.trim(), @@ -128,80 +128,69 @@ function getSaveOptions() { }; } -function save() { +async function save() { const options = getSaveOptions(); - const onError = err => { - if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { - if (err.info.param === 'name') { - os.alert({ - type: 'error', - title: i18n.ts._pages.invalidNameTitle, - text: i18n.ts._pages.invalidNameText, - }); - } - } else if (err.code === 'NAME_ALREADY_EXISTS') { - os.alert({ - type: 'error', + if (pageId.value) { + const updateOptions: Misskey.entities.PagesUpdateRequest = { + pageId: pageId.value, + ...options, + }; + + await os.apiWithDialog('pages/update', updateOptions, undefined, { + '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab': { + title: i18n.ts.somethingHappened, text: i18n.ts._pages.nameAlreadyExists, - }); - } - }; + }, + }); - if (pageId.value) { - options.pageId = pageId.value; - misskeyApi('pages/update', options) - .then(page => { - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.updated, - }); - }).catch(onError); + currentName.value = name.value.trim(); } else { - misskeyApi('pages/create', options) - .then(created => { - pageId.value = created.id; - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.created, - }); - mainRouter.push(`/pages/edit/${pageId.value}`); - }).catch(onError); + const created = await os.apiWithDialog('pages/create', options, undefined, { + '4650348e-301c-499a-83c9-6aa988c66bc1': { + title: i18n.ts.somethingHappened, + text: i18n.ts._pages.nameAlreadyExists, + }, + }); + + pageId.value = created.id; + currentName.value = name.value.trim(); + mainRouter.replace(`/pages/edit/${pageId.value}`); } } -function del() { - os.confirm({ +async function del() { + if (!pageId.value) return; + + const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }), - }).then(({ canceled }) => { - if (canceled) return; - misskeyApi('pages/delete', { - pageId: pageId.value, - }).then(() => { - os.alert({ - type: 'success', - text: i18n.ts._pages.deleted, - }); - mainRouter.push('/pages'); - }); }); + + if (canceled) return; + + await os.apiWithDialog('pages/delete', { + pageId: pageId.value, + }); + + mainRouter.replace('/pages'); } -function duplicate() { +async function duplicate() { title.value = title.value + ' - copy'; name.value = name.value + '-copy'; - misskeyApi('pages/create', getSaveOptions()).then(created => { - pageId.value = created.id; - currentName.value = name.value.trim(); - os.alert({ - type: 'success', - text: i18n.ts._pages.created, - }); - mainRouter.push(`/pages/edit/${pageId.value}`); + + const created = await os.apiWithDialog('pages/create', getSaveOptions(), undefined, { + '4650348e-301c-499a-83c9-6aa988c66bc1': { + title: i18n.ts.somethingHappened, + text: i18n.ts._pages.nameAlreadyExists, + }, }); + + pageId.value = created.id; + currentName.value = name.value.trim(); + + mainRouter.push(`/pages/edit/${pageId.value}`); } async function add() { @@ -216,7 +205,7 @@ async function add() { content.value.push({ id, type }); } -function setEyeCatchingImage(img) { +function setEyeCatchingImage(img: Event) { selectFile(img.currentTarget ?? img.target, null).then(file => { eyeCatchingImageId.value = file.id; }); diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 544e112111..d27a4f121d 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -266,7 +266,7 @@ function showMenu(ev: MouseEvent) { if ($i && $i.id === page.value.userId) { menuItems.push({ icon: 'ti ti-pencil', - text: i18n.ts._pages.editThisPage, + text: i18n.ts.edit, action: () => router.push(`/pages/edit/${page.value.id}`), }); @@ -285,10 +285,6 @@ function showMenu(ev: MouseEvent) { } } else if ($i && $i.id !== page.value.userId) { menuItems.push({ - icon: 'ti ti-code', - text: i18n.ts._pages.viewSource, - action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`), - }, { icon: 'ti ti-exclamation-circle', text: i18n.ts.reportAbuse, action: reportAbuse, diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index e080aea064..d5f96efb8e 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -13,16 +13,20 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts.options }}</template> <div class="_gaps_m"> - <MkRadios v-model="hostSelect"> - <template #label>{{ i18n.ts.host }}</template> - <option value="all" default>{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option> - </MkRadios> - <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search"> - <template #prefix><i class="ti ti-server"></i></template> - </MkInput> + <template v-if="instance.federation !== 'none'"> + <MkRadios v-model="hostSelect"> + <template #label>{{ i18n.ts.host }}</template> + <option value="all" default>{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option> + </MkRadios> + <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search"> + <template #prefix><i class="ti ti-server"></i></template> + </MkInput> + </template> + <MkSwitch v-model="order">{{ i18n.ts._noteSearch.newestToOldest }}</MkSwitch> + <MkSelect v-model="filetype" small> <template #label>{{ i18n.ts._noteSearch.fileType }}</template> <option :value="null">{{ i18n.ts._noteSearch._fileType.none }}</option> @@ -116,7 +120,7 @@ setHostSelectWithInput(hostInput.value, undefined); watch(hostInput, setHostSelectWithInput); const searchHost = computed(() => { - if (hostSelect.value === 'local') return '.'; + if (hostSelect.value === 'local' || instance.federation === 'none') return '.'; if (hostSelect.value === 'specified') return hostInput.value; return null; }); diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index a355c0eeaa..772ee91d63 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkRadios v-model="searchOrigin" @update:modelValue="search()"> + <MkRadios v-if="instance.federation !== 'none'" v-model="searchOrigin" @update:modelValue="search()"> <option value="combined">{{ i18n.ts.all }}</option> <option value="local">{{ i18n.ts.local }}</option> <option value="remote">{{ i18n.ts.remote }}</option> @@ -33,6 +33,7 @@ import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; @@ -118,7 +119,7 @@ async function search() { limit: 10, params: { query: query, - origin: searchOrigin.value, + origin: instance.federation === 'none' ? 'local' : searchOrigin.value, }, }; diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 97e960675f..c2588736b3 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -12,7 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton> </div> - <MkUserCardMini v-for="user in accounts" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/> + <template v-for="[id, user] in accounts"> + <MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/> + <button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)"> + <div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div> + <div> + <div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div> + <div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div> + </div> + </button> + </template> </div> </FormSuspense> </div> @@ -29,9 +38,10 @@ import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWith import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { MenuItem } from '@/types/menu'; const storedAccounts = ref<{ id: string, token: string }[] | null>(null); -const accounts = ref<Misskey.entities.UserDetailed[]>([]); +const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>()); const init = async () => { getAccounts().then(accounts => { @@ -41,21 +51,35 @@ const init = async () => { userIds: storedAccounts.value.map(x => x.id), }); }).then(response => { - accounts.value = response; + if (storedAccounts.value == null) return; + accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null])); }); }; -function menu(account: Misskey.entities.UserDetailed, ev: MouseEvent) { - os.popupMenu([{ - text: i18n.ts.switch, - icon: 'ti ti-switch-horizontal', - action: () => switchAccount(account), - }, { - text: i18n.ts.logout, - icon: 'ti ti-trash', - danger: true, - action: () => removeAccount(account), - }], ev.currentTarget ?? ev.target); +function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) { + let menu: MenuItem[]; + + if (typeof account === 'string') { + menu = [{ + text: i18n.ts.logout, + icon: 'ti ti-trash', + danger: true, + action: () => removeAccount(account), + }]; + } else { + menu = [{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(account.id), + }, { + text: i18n.ts.logout, + icon: 'ti ti-trash', + danger: true, + action: () => removeAccount(account.id), + }]; + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); } function addAccount(ev: MouseEvent) { @@ -68,9 +92,9 @@ function addAccount(ev: MouseEvent) { }], ev.currentTarget ?? ev.target); } -async function removeAccount(account: Misskey.entities.UserDetailed) { - await _removeAccount(account.id); - accounts.value = accounts.value.filter(x => x.id !== account.id); +async function removeAccount(id: string) { + await _removeAccount(id); + accounts.value.delete(id); } function addExistingAccount() { @@ -90,9 +114,9 @@ function createAccount() { }); } -async function switchAccount(account: Misskey.entities.UserDetailed) { +async function switchAccount(id: string) { const fetchedAccounts = await getAccounts(); - const token = fetchedAccounts.find(x => x.id === account.id)!.token; + const token = fetchedAccounts.find(x => x.id === id)!.token; switchAccountWithToken(token); } @@ -112,6 +136,49 @@ definePageMetadata(() => ({ <style lang="scss" module> .user { - cursor: pointer; + cursor: pointer; +} + +.unknownUser { + display: flex; + align-items: center; + text-align: start; + padding: 16px; + background: var(--MI_THEME-panel); + border-radius: 8px; + font-size: 0.9em; +} + +.unknownUserAvatarMock { + display: block; + width: 34px; + height: 34px; + line-height: 34px; + text-align: center; + font-size: 16px; + margin-right: 12px; + background-color: color-mix(in srgb, var(--MI_THEME-fg), transparent 85%); + color: color-mix(in srgb, var(--MI_THEME-fg), transparent 25%); + border-radius: 50%; +} + +.unknownUserTitle { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 18px; +} + +.unknownUserSub { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 16px; } </style> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index cb0451c8b4..fc9c6aa669 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -14,14 +14,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> </template> </I18n> - <!-- - <br /> - <I18n :src="i18n.ts.i18nInfoSharkey" tag="span"> - <template #link> - <MkLink url="https://crowdin.com/project/misskey">INSERT THINGY</MkLink> - </template> - </I18n> - --> </template> </MkSelect> @@ -106,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch> </div> - <MkSelect v-model="instanceTicker"> + <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> <template #label>{{ i18n.ts.instanceTicker }}</template> <option value="none">{{ i18n.ts._instanceTicker.none }}</option> <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> @@ -152,11 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkSwitch> - <!-- {{ i18n.ts.notificationDotNotWorkingAdvice }} --> - - <!-- notificationDotNotWorkingAdvice --> <MkButton @click="testNotificationDot">{{ i18n.ts.verifyNotificationDotWorkingButton }}</MkButton> - <!-- <p class="caption">Testing Testing</p> --> <MkRadios v-model="notificationPosition"> <template #label>{{ i18n.ts.position }}</template> <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> @@ -340,7 +328,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, reactive, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { langs } from '@@/js/config.js'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -357,6 +345,7 @@ import MkInfo from '@/components/MkInfo.vue'; import { searchEngineMap } from '@/scripts/search-engine-map.js'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; +import { instance } from '@/instance.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { reloadAsk } from '@/scripts/reload-ask.js'; import { i18n } from '@/i18n.js'; @@ -364,7 +353,6 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { miLocalStorage } from '@/local-storage.js'; import { globalEvents } from '@/events.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { deepMerge } from '@/scripts/merge.js'; import { worksOnInstance } from '@/scripts/favicon-dot.js'; const lang = ref(miLocalStorage.getItem('lang')); @@ -422,7 +410,6 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); const showTickerOnReplies = computed(defaultStore.makeGetterSetter('showTickerOnReplies')); -//const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine')); const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine')); const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign')); @@ -470,7 +457,7 @@ watch(useSystemFont, () => { watch(noteDesign, async (newval) => { if (noteDesign.value === newval) { - await reloadAsk(); + await reloadAsk({}); } }); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 552b4ee028..b7bf8c5dc1 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -43,7 +43,7 @@ const indexInfo = { icon: 'ti ti-settings', hideHeader: true, }; -const INFO = ref(indexInfo); +const INFO = ref<PageMetadata>(indexInfo); const el = shallowRef<HTMLElement | null>(null); const childInfo = ref<null | PageMetadata>(null); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 82aeb6063f..d6ee45e074 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -9,17 +9,24 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.wordMute }}</template> - <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> + <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch> + <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> + </div> </MkFolder> <MkFolder> <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.hardWordMute }}</template> - <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> + <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> + </div> </MkFolder> - <MkFolder> + <MkFolder v-if="instance.federation !== 'none'"> <template #icon><i class="ti ti-planet-off"></i></template> <template #label>{{ i18n.ts.instanceMute }}</template> @@ -126,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import XInstanceMute from './mute-block.instance-mute.vue'; import XWordMute from './mute-block.word-mute.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -135,9 +142,13 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; -import { infoImageUrl } from '@/instance.js'; +import { instance, infoImageUrl } from '@/instance.js'; import { signinRequired } from '@/account.js'; +import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { defaultStore } from '@/store'; +import { reloadAsk } from '@/scripts/reload-ask.js'; const $i = signinRequired(); @@ -160,6 +171,14 @@ const expandedRenoteMuteItems = ref([]); const expandedMuteItems = ref([]); const expandedBlockItems = ref([]); +const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord')); + +watch([ + showSoftWordMutedWord, +], async () => { + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +}); + async function unrenoteMute(user, ev) { os.popupMenu([{ text: i18n.ts.renoteUnmute, diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index db51506596..0b8e89a6a5 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div> <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div> + <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div> </template> </MkSwitch> @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> + <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> </template> </FormSlot> @@ -129,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div> - <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> + <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> </template> </FormSlot> </div> @@ -185,6 +185,7 @@ import MkFolder from '@/components/MkFolder.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; import { signinRequired } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import FormSlot from '@/components/form/slot.vue'; @@ -240,7 +241,7 @@ watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => { }); async function update_requireSigninToViewContents(value: boolean) { - if (value) { + if (value === true && instance.federation !== 'none') { const { canceled } = await os.confirm({ type: 'warning', text: i18n.ts.acknowledgeNotesAndEnable, diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 67943524ef..140b6beb14 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="statusbar.type" placeholder="Please select"> <template #label>{{ i18n.ts.type }}</template> <option value="rss">RSS</option> - <option value="federation">Federation</option> + <option v-if="instance.federation !== 'none'" value="federation">Federation</option> <option value="userList">User list timeline</option> </MkSelect> @@ -96,6 +96,7 @@ import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; import { deepClone } from '@/scripts/clone.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index a530f4b5d6..e49d6af470 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref, computed } from 'vue'; -import { toUnicode } from 'punycode/'; +import { toUnicode } from 'punycode.js'; import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; diff --git a/packages/frontend/src/pages/user/files.vue b/packages/frontend/src/pages/user/files.vue new file mode 100644 index 0000000000..b6c7c1c777 --- /dev/null +++ b/packages/frontend/src/pages/user/files.vue @@ -0,0 +1,56 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> + <MkSpacer :contentMax="1100"> + <div :class="$style.root"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div :class="$style.stream"> + <MkNoteMediaGrid v-for="note in items" :note="note" square/> + </div> + </MkPagination> + </div> + </MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; + +import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: Misskey.entities.UserDetailed; +}>(); + +const pagination = { + endpoint: 'users/notes' as const, + limit: 15, + params: computed(() => ({ + userId: props.user.id, + withFiles: true, + })), +}; +</script> + +<style lang="scss" module> +.root { + padding: 8px; +} + +.stream { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--MI-marginHalf); +} + +@media screen and (min-width: 600px) { + .stream { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + +} +</style> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 2cd307fb68..4b3773e0ae 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -141,7 +141,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <template v-if="narrow"> <MkLazy> - <XFiles :key="user.id" :user="user" :collapsed="true"/> + <XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/> </MkLazy> <MkLazy> <XActivity :key="user.id" :user="user" :collapsed="true"/> @@ -183,7 +183,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> - <XFiles :key="user.id" :user="user"/> + <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/> <XActivity :key="user.id" :user="user"/> <XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/> </div> @@ -245,7 +245,6 @@ function calcAge(birthdate: string): number { const XFiles = defineAsyncComponent(() => import('./index.files.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue')); -//const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed; @@ -255,6 +254,10 @@ const props = withDefaults(defineProps<{ disableNotes: false, }); +const emit = defineEmits<{ + (ev: 'unfoldFiles'): void; +}>(); + const router = useRouter(); const user = ref(props.user); @@ -310,7 +313,7 @@ const pagination = { endpoint: 'users/featured-notes' as const, limit: 10, params: computed(() => ({ - userId: props.user.id + userId: props.user.id, })), }; diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index 7fe90da865..44e35e3479 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -4,30 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed"> +<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed" :onUnfold="unfoldContainer"> <template #icon><i class="ti ti-photo"></i></template> <template #header>{{ i18n.ts.files }}</template> <div :class="$style.root"> <MkLoading v-if="fetching"/> - <div v-if="!fetching && files.length > 0" :class="$style.stream"> - <template v-for="file in files" :key="file.note.id + file.file.id"> - <div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.img" @click="showingFiles.push(file.file.id)"> - <!-- TODO: 画像以外のファイルに対応 --> - <ImgWithBlurhash :class="$style.sensitiveImg" :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name" :forceBlurhash="true"/> - <div :class="$style.sensitive"> - <div> - <div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div> - <div>{{ i18n.ts.clickToShow }}</div> - </div> - </div> - </div> - <MkA v-else :class="$style.img" :to="notePage(file.note)"> - <!-- TODO: 画像以外のファイルに対応 --> - <ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/> - </MkA> - </template> + <div v-if="!fetching && notes.length > 0" :class="$style.stream"> + <MkNoteMediaGrid v-for="note in notes" :note="note"/> </div> - <p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> + <p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> </div> </MkContainer> </template> @@ -35,13 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { notePage } from '@/filters/note.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed; @@ -50,33 +32,25 @@ const props = withDefaults(defineProps<{ collapsed: false, }); +const emit = defineEmits<{ + (ev: 'unfold'): void; +}>(); + const fetching = ref(true); -const files = ref<{ - note: Misskey.entities.Note; - file: Misskey.entities.DriveFile; -}[]>([]); -const showingFiles = ref<string[]>([]); +const notes = ref<Misskey.entities.Note[]>([]); -function thumbnail(image: Misskey.entities.DriveFile): string { - return defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(image.url) - : image.thumbnailUrl; +function unfoldContainer(): boolean { + emit('unfold'); + return false; } onMounted(() => { misskeyApi('users/notes', { userId: props.user.id, withFiles: true, - limit: 15, - }).then(notes => { - for (const note of notes) { - for (const file of note.files) { - files.value.push({ - note, - file, - }); - } - } + limit: 10, + }).then(_notes => { + notes.value = _notes; fetching.value = false; }); }); diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index a35250bf5f..ba02559d68 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -9,10 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <div v-if="user"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <XHome v-if="tab === 'home'" key="home" :user="user"/> + <XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0"> <XTimeline :user="user"/> </MkSpacer> + <XFiles v-else-if="tab === 'files'" :user="user"/> <XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/> <XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/> <XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/> @@ -39,10 +40,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { getServerContext } from '@/server-context.js'; +import { serverContext, assertServerContext } from '@/server-context.js'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); +const XFiles = defineAsyncComponent(() => import('./files.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); @@ -53,7 +55,8 @@ const XFlashs = defineAsyncComponent(() => import('./flashs.vue')); const XGallery = defineAsyncComponent(() => import('./gallery.vue')); const XRaw = defineAsyncComponent(() => import('./raw.vue')); -const CTX_USER = getServerContext('user'); +// contextは非ログイン状態の情報しかないためログイン時は利用できない +const CTX_USER = !$i && assertServerContext(serverContext, 'user') ? serverContext.user : null; const props = withDefaults(defineProps<{ acct: string; @@ -103,6 +106,10 @@ const headerTabs = computed(() => user.value ? [{ title: i18n.ts.notes, icon: 'ti ti-pencil', }, { + key: 'files', + title: i18n.ts.files, + icon: 'ti ti-photo', +}, { key: 'activity', title: i18n.ts.activity, icon: 'ti ti-chart-line', diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index f1842255e0..c5731bd2a9 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -53,12 +53,14 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string if (!instance.iconUrl) { return ''; } + return getProxiedImageUrl(instance.iconUrl, 'preview'); } misskeyApiGet('federation/instances', { sort: '+pubSub', limit: 20, + blocked: 'false', }).then(_instances => { instances.value = _instances; }); diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index c0034d414c..27bb34da36 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -6,8 +6,6 @@ import { ref } from 'vue'; import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import * as os from '@/os.js'; -import { i18n } from '@/i18n.js'; import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index c7637a1db9..2d50a27dbf 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -17,10 +17,7 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ }); const routes: RouteDef[] = [{ - path: '/@:initUser/pages/:initPageName/view-source', - component: page(() => import('@/pages/page-editor/page-editor.vue')), -}, { - path: '/@:username/pages/:pageName', + path: '/@:username/pages/:pageName(*)', component: page(() => import('@/pages/page.vue')), }, { path: '/@:acct/following', @@ -391,6 +388,10 @@ const routes: RouteDef[] = [{ name: 'emojis', component: page(() => import('@/pages/custom-emojis-manager.vue')), }, { + path: '/emojis2', + name: 'emojis2', + component: page(() => import('@/pages/admin/custom-emojis-manager2.vue')), + }, { path: '/avatar-decorations', name: 'avatarDecorations', component: page(() => import('@/pages/avatar-decorations.vue')), diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 46aed49330..e203c51bba 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -3,14 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { utils, values } from '@syuilo/aiscript'; +import { errors, utils, values } from '@syuilo/aiscript'; import * as Misskey from 'misskey-js'; +import { url, lang } from '@@/js/config.js'; +import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { url, lang } from '@@/js/config.js'; + +const DIALOG_TYPES = [ + 'error', + 'info', + 'success', + 'warning', + 'waiting', + 'question', +] as const; export function aiScriptReadline(q: string): Promise<string> { return new Promise(ok => { @@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise<string> { }); } -export function createAiScriptEnv(opts) { +export function createAiScriptEnv(opts: { storageKey: string, token?: string }) { return { USER_ID: $i ? values.STR($i.id) : values.NULL, - USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_NAME: $i?.name ? values.STR($i.name) : values.NULL, USER_USERNAME: $i ? values.STR($i.username) : values.NULL, CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), LOCALE: values.STR(lang), SERVER_URL: values.STR(url), 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } await os.alert({ type: type ? type.value : 'info', title: title.value, @@ -39,6 +54,11 @@ export function createAiScriptEnv(opts) { return values.NULL; }), 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } const confirm = await os.confirm({ type: type ? type.value : 'question', title: title.value, @@ -48,14 +68,20 @@ export function createAiScriptEnv(opts) { }), 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { utils.assertString(ep); - if (ep.value.includes('://')) throw new Error('invalid endpoint'); + if (ep.value.includes('://')) { + throw new errors.AiScriptRuntimeError('invalid endpoint'); + } if (token) { utils.assertString(token); // バグがあればundefinedもあり得るため念のため if (typeof token.value !== 'string') throw new Error('invalid token'); } const actualToken: string|null = token?.value ?? opts.token ?? null; - return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => { + if (param == null) { + throw new errors.AiScriptRuntimeError('expected param'); + } + utils.assertObject(param); + return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => { return utils.jsToVal(res); }, err => { return values.ERROR('request_failed', utils.jsToVal(err)); @@ -75,12 +101,18 @@ export function createAiScriptEnv(opts) { */ 'Mk:save': values.FN_NATIVE(([key, value]) => { utils.assertString(key); + utils.expectAny(value); miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value))); return values.NULL; }), 'Mk:load': values.FN_NATIVE(([key]) => { utils.assertString(key); - return utils.jsToVal(JSON.parse(miLocalStorage.getItem(`aiscript:${opts.storageKey}:${key.value}`))); + return utils.jsToVal(miLocalStorage.getItemAsJson(`aiscript:${opts.storageKey}:${key.value}`) ?? null); + }), + 'Mk:remove': values.FN_NATIVE(([key]) => { + utils.assertString(key); + miLocalStorage.removeItem(`aiscript:${opts.storageKey}:${key.value}`); + return values.NULL; }), 'Mk:url': values.FN_NATIVE(() => { return values.STR(window.location.href); diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/scripts/aiscript/common.ts new file mode 100644 index 0000000000..de6fa1d633 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/common.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { errors, utils, type values } from '@syuilo/aiscript'; + +export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } { + utils.assertString(value); + const str = value.value; + if (!expects.includes(str)) { + const expected = expects.map((expect) => `"${expect}"`).join(', '); + throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`); + } +} diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2b386bebb8..ca92b27ff5 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -7,6 +7,15 @@ import { utils, values } from '@syuilo/aiscript'; import { v4 as uuid } from 'uuid'; import { ref, Ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { assertStringAndIsIn } from './common.js'; + +const ALIGNS = ['left', 'center', 'right'] as const; +const FONTS = ['serif', 'sans-serif', 'monospace'] as const; +const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const; + +type Align = (typeof ALIGNS)[number]; +type Font = (typeof FONTS)[number]; +type BorderStyle = (typeof BORDER_STYLES)[number]; export type AsUiComponentBase = { id: string; @@ -21,13 +30,13 @@ export type AsUiRoot = AsUiComponentBase & { export type AsUiContainer = AsUiComponentBase & { type: 'container'; children?: AsUiComponent['id'][]; - align?: 'left' | 'center' | 'right'; + align?: Align; bgColor?: string; fgColor?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; borderWidth?: number; borderColor?: string; - borderStyle?: 'hidden' | 'dotted' | 'dashed' | 'solid' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'; + borderStyle?: BorderStyle; borderRadius?: number; padding?: number; rounded?: boolean; @@ -40,7 +49,7 @@ export type AsUiText = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; }; export type AsUiMfm = AsUiComponentBase & { @@ -49,14 +58,14 @@ export type AsUiMfm = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; - onClickEv?: (evId: string) => void + font?: Font; + onClickEv?: (evId: string) => Promise<void>; }; export type AsUiButton = AsUiComponentBase & { type: 'button'; text?: string; - onClick?: () => void; + onClick?: () => Promise<void>; primary?: boolean; rounded?: boolean; disabled?: boolean; @@ -69,7 +78,7 @@ export type AsUiButtons = AsUiComponentBase & { export type AsUiSwitch = AsUiComponentBase & { type: 'switch'; - onChange?: (v: boolean) => void; + onChange?: (v: boolean) => Promise<void>; default?: boolean; label?: string; caption?: string; @@ -77,7 +86,7 @@ export type AsUiSwitch = AsUiComponentBase & { export type AsUiTextarea = AsUiComponentBase & { type: 'textarea'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -85,7 +94,7 @@ export type AsUiTextarea = AsUiComponentBase & { export type AsUiTextInput = AsUiComponentBase & { type: 'textInput'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -93,7 +102,7 @@ export type AsUiTextInput = AsUiComponentBase & { export type AsUiNumberInput = AsUiComponentBase & { type: 'numberInput'; - onInput?: (v: number) => void; + onInput?: (v: number) => Promise<void>; default?: number; label?: string; caption?: string; @@ -105,7 +114,7 @@ export type AsUiSelect = AsUiComponentBase & { text: string; value: string; }[]; - onChange?: (v: string) => void; + onChange?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -140,11 +149,15 @@ export type AsUiPostForm = AsUiComponentBase & { export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm; +type Options<T extends AsUiComponent> = T extends AsUiButtons + ? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] } + : Omit<T, 'id' | 'type'>; + export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) { // TODO } -function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> { +function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> { utils.assertObject(def); const children = def.value.get('children'); @@ -153,30 +166,32 @@ function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 't return { children: children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }), }; } -function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> { +function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> { utils.assertObject(def); const children = def.value.get('children'); if (children) utils.assertArray(children); const align = def.value.get('align'); - if (align) utils.assertString(align); + if (align) assertStringAndIsIn(align, ALIGNS); const bgColor = def.value.get('bgColor'); if (bgColor) utils.assertString(bgColor); const fgColor = def.value.get('fgColor'); if (fgColor) utils.assertString(fgColor); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); const borderWidth = def.value.get('borderWidth'); if (borderWidth) utils.assertNumber(borderWidth); const borderColor = def.value.get('borderColor'); if (borderColor) utils.assertString(borderColor); const borderStyle = def.value.get('borderStyle'); - if (borderStyle) utils.assertString(borderStyle); + if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES); const borderRadius = def.value.get('borderRadius'); if (borderRadius) utils.assertNumber(borderRadius); const padding = def.value.get('padding'); @@ -189,7 +204,9 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, return { children: children ? children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], align: align?.value, fgColor: fgColor?.value, @@ -205,7 +222,7 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, }; } -function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> { +function getTextOptions(def: values.Value | undefined): Options<AsUiText> { utils.assertObject(def); const text = def.value.get('text'); @@ -217,7 +234,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't const color = def.value.get('color'); if (color) utils.assertString(color); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); return { text: text?.value, @@ -228,7 +245,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't }; } -function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> { +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> { utils.assertObject(def); const text = def.value.get('text'); @@ -240,7 +257,7 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg const color = def.value.get('color'); if (color) utils.assertString(color); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); const onClickEv = def.value.get('onClickEv'); if (onClickEv) utils.assertFunction(onClickEv); @@ -250,13 +267,13 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg bold: bold?.value, color: color?.value, font: font?.value, - onClickEv: (evId: string) => { - if (onClickEv) call(onClickEv, [values.STR(evId)]); + onClickEv: async (evId: string) => { + if (onClickEv) await call(onClickEv, [values.STR(evId)]); }, }; } -function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> { +function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -269,8 +286,8 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -278,7 +295,7 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF }; } -function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> { +function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -291,8 +308,8 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -300,7 +317,7 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn }; } -function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> { +function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -313,8 +330,8 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -322,7 +339,7 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. }; } -function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> { +function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> { utils.assertObject(def); const text = def.value.get('text'); @@ -338,8 +355,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text?.value, - onClick: () => { - if (onClick) call(onClick, []); + onClick: async () => { + if (onClick) await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -347,7 +364,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> { +function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> { utils.assertObject(def); const buttons = def.value.get('buttons'); @@ -369,8 +386,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text.value, - onClick: () => { - call(onClick, []); + onClick: async () => { + await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -380,7 +397,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> { +function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> { utils.assertObject(def); const onChange = def.value.get('onChange'); @@ -393,8 +410,8 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, if (caption) utils.assertString(caption); return { - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -402,7 +419,7 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> { +function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> { utils.assertObject(def); const items = def.value.get('items'); @@ -428,8 +445,8 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, value: value ? value.value : text.value, }; }) : [], - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -437,7 +454,7 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' | 'type'> { +function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> { utils.assertObject(def); const children = def.value.get('children'); @@ -450,7 +467,9 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' return { children: children ? children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], title: title?.value ?? '', opened: opened?.value ?? true, @@ -475,7 +494,7 @@ function getPostFormProps(form: values.VObj): PostFormPropsForAsUi { }; } -function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> { +function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> { utils.assertObject(def); const text = def.value.get('text'); @@ -497,7 +516,7 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu }; } -function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostForm, 'id' | 'type'> { +function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> { utils.assertObject(def); const form = def.value.get('form'); @@ -511,18 +530,26 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn } export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) { + type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>; + const instances = {}; - function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) { + function createComponentInstance<T extends AsUiComponent, C>( + type: T['type'], + def: values.Value | undefined, + id: values.Value | undefined, + getOptions: OptionsConverter<T, C>, + call: C, + ) { if (id) utils.assertString(id); const _id = id?.value ?? uuid(); const component = ref({ ...getOptions(def, call), type, id: _id, - }); + } as T); components.push(component); - const instance = values.OBJ(new Map([ + const instance = values.OBJ(new Map<string, values.Value>([ ['id', values.STR(_id)], ['update', values.FN_NATIVE(([def], opts) => { utils.assertObject(def); @@ -547,7 +574,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R 'Ui:patch': values.FN_NATIVE(([id, val], opts) => { utils.assertString(id); utils.assertArray(val); - patch(id.value, val.value, opts.call); + // patch(id.value, val.value, opts.call); // TODO }), 'Ui:get': values.FN_NATIVE(([id], opts) => { @@ -566,7 +593,9 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R rootComponent.value.children = children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }); }), diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index d942402ffc..8a3a6bf6db 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -5,7 +5,7 @@ import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; import getCaretCoordinates from 'textarea-caret'; -import { toASCII } from 'punycode/'; +import { toASCII } from 'punycode.js'; import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 6525c207f7..194ef0f420 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): boolean { +export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false { // 自分自身 if (me && (note.userId === me.id)) return false; @@ -13,7 +13,7 @@ export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities. if (text === '') return false; - const matched = mutedWords.some(filter => { + const matched = mutedWords.filter(filter => { if (Array.isArray(filter)) { // Clean up const filteredFilter = filter.filter(keyword => keyword !== ''); @@ -36,7 +36,7 @@ export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities. } }); - if (matched) return true; + if (matched.length > 0) return matched; } return false; diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index 6710d9826e..4d57dcd944 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createHighlighterCore, loadWasm } from 'shiki/core'; +import { createHighlighterCore } from 'shiki/core'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; import darkPlus from 'shiki/themes/dark-plus.mjs'; import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; @@ -60,8 +61,6 @@ export async function getHighlighter(): Promise<HighlighterCore> { } async function initHighlighter() { - await loadWasm(import('shiki/onig.wasm?init')); - // テーマの重複を消す const themes = unique([ darkPlus, @@ -70,6 +69,7 @@ async function initHighlighter() { const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript'); const highlighter = await createHighlighterCore({ + engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')), themes, langs: [ ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []), diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts new file mode 100644 index 0000000000..c2e863c0dc --- /dev/null +++ b/packages/frontend/src/scripts/file-drop.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DroppedItem = DroppedFile | DroppedDirectory; + +export type DroppedFile = { + isFile: true; + path: string; + file: File; +}; + +export type DroppedDirectory = { + isFile: false; + path: string; + children: DroppedItem[]; +} + +export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of<DroppedFile>(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。 + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> { + async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { + return new Promise(async (resolve) => { + const allEntries = Array.of<FileSystemEntry>(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱いにくいので配列に変換 + const items = Array.of<DataTransferItem>(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。 + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of<DroppedFile>(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index c56fd185b6..9112daf49f 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -5,19 +5,19 @@ import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; +import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { url } from '@@/js/config.js'; import { defaultStore, noteActions } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import { clipsCache, favoritedChannelsCache } from '@/cache.js'; -import type { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { isSupportShare } from '@/scripts/navigator.js'; import { getAppearNote } from '@/scripts/get-appear-note.js'; @@ -205,7 +205,7 @@ export function getNoteMenu(props: { noteId: appearNote.id, }); - if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { claimAchievement('noteDeletedWithin1min'); } }); @@ -224,7 +224,7 @@ export function getNoteMenu(props: { os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); - if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { claimAchievement('noteDeletedWithin1min'); } }); @@ -259,11 +259,6 @@ export function getNoteMenu(props: { os.success(); } - function copyLink(): void { - copyToClipboard(`${url}/notes/${appearNote.id}`); - os.success(); - } - function togglePin(pin: boolean): void { os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { noteId: appearNote.id, @@ -347,6 +342,13 @@ export function getNoteMenu(props: { getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') ); menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { @@ -508,6 +510,13 @@ export function getNoteMenu(props: { getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') ); menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 090cffe203..2fbdaf5d3c 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toUnicode } from 'punycode'; +import { toUnicode } from 'punycode.js'; import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts new file mode 100644 index 0000000000..a72776d48c --- /dev/null +++ b/packages/frontend/src/scripts/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' + ; + +/** + * 修飾キーを表す文字列。不足分は適宜追加する。 + */ +export type KeyModifier = + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' + ; + +/** + * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 + */ +export type KeyState = + | 'composing' + | 'repeat' + ; + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +} + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index e20b23f166..54ec2ce39b 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -33,7 +33,43 @@ export async function lookup(router?: Router) { uri: query, }); - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + os.promiseDialog(promise, null, (err) => { + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + switch (err.id) { + case '974b799e-1a29-4889-b706-18d4dd93e266': + title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; + text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; + break; + case '1a5eab56-e47b-48c2-8d5e-217b897d70db': + title = i18n.ts._remoteLookupErrors._uriInvalid.title; + text = i18n.ts._remoteLookupErrors._uriInvalid.description; + break; + case '81b539cf-4f57-4b29-bc98-032c33c0792e': + title = i18n.ts._remoteLookupErrors._requestFailed.title; + text = i18n.ts._remoteLookupErrors._requestFailed.description; + break; + case '70193c39-54f3-4813-82f0-70a680f7495b': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalid.description; + break; + case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description; + break; + case 'dc94d745-1262-4e63-a17d-fecaa57efc82': + title = i18n.ts._remoteLookupErrors._noSuchObject.title; + text = i18n.ts._remoteLookupErrors._noSuchObject.description; + break; + } + + os.alert({ + type: 'error', + title, + text, + }); + }, i18n.ts.fetchingAsApObject); const res = await promise; diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts index 89fdda0cbb..004b6d42a4 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/scripts/merge.ts @@ -7,10 +7,10 @@ import { deepClone } from './clone.js'; import type { Cloneable } from './clone.js'; export type DeepPartial<T> = { - [P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P]; + [P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P]; }; -function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { +function isPureObject(value: unknown): value is Record<PropertyKey, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -18,14 +18,14 @@ function isPureObject(value: unknown): value is Record<string | number | symbol, * valueにないキーをdefからもらう(再帰的)\ * nullはそのまま、undefinedはdefの値 **/ -export function deepMerge<X extends object>(value: DeepPartial<X>, def: X): X { +export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X { if (isPureObject(value) && isPureObject(def)) { const result = deepClone(value as Cloneable) as X; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { result[k] = v; } else if (isPureObject(v) && isPureObject(result[k])) { - const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>; + const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>; result[k] = deepMerge<typeof v>(child, v); } } diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index e7a92e2d5c..dc07ad477b 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -9,12 +9,24 @@ import { apiUrl } from '@@/js/config.js'; import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); +export type Endpoint = keyof Misskey.Endpoints; + +export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req']; + +export type AnyRequest<E extends Endpoint | (string & unknown)> = + (E extends Endpoint ? Request<E> : never) | object; + +export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> = + E extends Endpoint + ? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never + : object; + // Implements Misskey.api.ApiClient.request export function misskeyApi< ResT = void, - E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, - P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], - _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, + E extends Endpoint | NonNullable<string> = Endpoint, + P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never, + _ResT = ResT extends void ? Response<E, P> : ResT, >( endpoint: E, data: P & { i?: string | null; } = {} as any, diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts index 43dcf11936..a8a330eb6d 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/scripts/please-login.ts @@ -5,6 +5,7 @@ import { defineAsyncComponent } from 'vue'; import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { popup } from '@/os.js'; @@ -51,10 +52,17 @@ export function pleaseLogin(opts: { } = {}) { if ($i) return; + let _openOnRemote: OpenOnRemoteOptions | undefined = undefined; + + // 連合できる場合と、(連合ができなくても)共有する場合は外部連携オプションを設定 + if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) { + _openOnRemote = opts.openOnRemote; + } + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { autoSet: true, - message: opts.message ?? (opts.openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), - openOnRemote: opts.openOnRemote, + message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), + openOnRemote: _openOnRemote, }, { cancelled: () => { if (opts.path) { diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..c25b4d73bd 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; -export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise<Misskey.entities.DriveFile[]> { + const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; + const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + return new Promise((res, rej) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); Promise.all(promises).then(driveFiles => { res(driveFiles); @@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 05f82fce7d..2008afe045 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -93,6 +93,10 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ctx == null) { ctx = new AudioContext(); + + window.addEventListener('beforeunload', () => { + ctx.close(); + }); } if (options?.useCache ?? true) { if (cache.has(url)) { diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index 89aa023f23..d15d9043c2 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -7,7 +7,6 @@ import { onUnmounted, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; import { $i } from '@/account.js'; -import * as os from '@/os.js'; import { misskeyApi } from './misskey-api.js'; export function useNoteCapture(props: { diff --git a/packages/frontend/src/server-context.ts b/packages/frontend/src/server-context.ts index aa44a10290..e79d3fa314 100644 --- a/packages/frontend/src/server-context.ts +++ b/packages/frontend/src/server-context.ts @@ -2,22 +2,20 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ + import * as Misskey from 'misskey-js'; -import { $i } from '@/account.js'; const providedContextEl = document.getElementById('misskey_clientCtx'); export type ServerContext = { clip?: Misskey.entities.Clip; note?: Misskey.entities.Note; - user?: Misskey.entities.UserLite; + user?: Misskey.entities.UserDetailed; } | null; export const serverContext: ServerContext = (providedContextEl && providedContextEl.textContent) ? JSON.parse(providedContextEl.textContent) : null; -export function getServerContext<K extends keyof NonNullable<ServerContext>>(entity: K): Required<Pick<NonNullable<ServerContext>, K>> | null { - // contextは非ログイン状態の情報しかないためログイン時は利用できない - if ($i) return null; - - return serverContext ? (serverContext[entity] ?? null) : null; +export function assertServerContext<K extends keyof NonNullable<ServerContext>>(ctx: ServerContext, entity: K): ctx is Required<Pick<NonNullable<ServerContext>, K>> { + if (ctx == null) return false; + return entity in ctx && ctx[entity] != null; } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index c34e0bbf48..69fcef32c2 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -10,11 +10,11 @@ import lightTheme from '@@/themes/l-cherry.json5'; import darkTheme from '@@/themes/d-ice.json5'; import { searchEngineMap } from './scripts/search-engine-map.js'; import type { SoundType } from '@/scripts/sound.js'; +import type { Ast } from '@syuilo/aiscript'; import { DEFAULT_DEVICE_KIND, type DeviceKind } from '@/scripts/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; import { defaultFollowingFeedState } from '@/scripts/following-feed-utils.js'; import { Storage } from '@/pizzax.js'; -import type { Ast } from '@syuilo/aiscript'; interface PostFormAction { title: string, @@ -561,6 +561,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, + showSoftWordMutedWord: { + where: 'device', + default: false, + }, sound_masterVolume: { where: 'device', diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 8355ae3061..6d36df9874 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -56,12 +56,18 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.customEmojis, icon: 'ph-smiley ph-bold ph-lg', to: '/about#emojis', - }, { - type: 'link', - text: i18n.ts.federation, - icon: 'ti ti-whirl', - to: '/about#federation', - }, { + }); + + if (instance.federation !== 'none') { + menuItems.push({ + type: 'link', + text: i18n.ts.federation, + icon: 'ti ti-whirl', + to: '/about#federation', + }); + } + + menuItems.push({ type: 'link', text: i18n.ts.charts, icon: 'ti ti-chart-line', @@ -134,7 +140,7 @@ export function openInstanceMenu(ev: MouseEvent) { }); } - if (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) { + if (instance.impressumUrl != null || instance.tosUrl != null || instance.privacyPolicyUrl != null || instance.donationUrl != null) { menuItems.push({ type: 'divider' }); } diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 5cc0e52f77..062a8faf3f 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> <div :class="$style.divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> + <MkA v-if="$i != null && ($i.isAdmin || $i.isModerator)" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> </MkA> <button class="_button" :class="$style.item" @click="more"> @@ -48,10 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </div> <div :class="$style.bottom"> - <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="os.post"> + <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }"> <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> - <button v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> + <button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> </button> </div> @@ -83,8 +83,12 @@ import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; -const iconOnly = ref(false); +const forceIconOnly = ref(window.innerWidth <= 1279); +const iconOnly = computed(() => { + return forceIconOnly.value || (defaultStore.reactiveState.menuDisplay.value === 'sideIcon'); +}); const menu = computed(() => defaultStore.state.menu); const otherMenuItemIndicated = computed(() => { @@ -95,14 +99,10 @@ const otherMenuItemIndicated = computed(() => { return false; }); -const forceIconOnly = window.innerWidth <= 1279; - function calcViewState() { - iconOnly.value = forceIconOnly || (defaultStore.state.menuDisplay === 'sideIcon'); + forceIconOnly.value = window.innerWidth <= 1279; } -calcViewState(); - window.addEventListener('resize', calcViewState); watch(defaultStore.reactiveState.menuDisplay, () => { @@ -120,8 +120,10 @@ function openAccountMenu(ev: MouseEvent) { } function more(ev: MouseEvent) { + const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target); + if (!target) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: ev.currentTarget ?? ev.target, + src: target, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index 5f9a938017..ed881bef22 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <span :class="$style.name">{{ x.name }}</span> <XRss v-if="x.type === 'rss'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/> - <XFederation v-else-if="x.type === 'federation'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> + <XFederation v-else-if="x.type === 'federation' && instance.federation !== 'none'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> <XUserList v-else-if="x.type === 'userList'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :userListId="x.props.userListId"/> </div> </div> @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; +import { instance } from '@/instance.js'; import { defaultStore } from '@/store.js'; const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index c0ea833546..36caca5fc0 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -100,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; -import { deckStore, columnTypes, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js'; +import { deckStore, columnTypes, addColumn as addColumnToStore, forceSaveDeck, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js'; import type { ColumnType } from './deck/deck-store.js'; import type { MenuItem } from '@/types/menu.js'; import XSidebar from '@/ui/_common_/navbar.vue'; @@ -240,10 +240,15 @@ function changeProfile(ev: MouseEvent) { title: i18n.ts._deck.profile, minLength: 1, }); + if (canceled || name == null) return; - deckStore.set('profile', name); - unisonReload(); + os.promiseDialog((async () => { + await deckStore.set('profile', name); + await forceSaveDeck(); + })(), () => { + unisonReload(); + }); }, }); }).then(() => { @@ -258,9 +263,18 @@ async function deleteProfile() { }); if (canceled) return; - deleteProfile_(deckStore.state.profile); - deckStore.set('profile', 'default'); - unisonReload(); + os.promiseDialog((async () => { + if (deckStore.state.profile === 'default') { + await deckStore.set('columns', []); + await deckStore.set('layout', []); + await forceSaveDeck(); + } else { + await deleteProfile_(deckStore.state.profile); + } + await deckStore.set('profile', 'default'); + })(), () => { + unisonReload(); + }); } </script> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 91859b46d7..8e5b1dd1ac 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -113,8 +113,7 @@ export const loadDeck = async () => { deckStore.set('layout', deck.layout); }; -// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する -export const saveDeck = throttle(1000, async () => { +export async function forceSaveDeck() { await misskeyApi('i/registry/set', { scope: ['client', 'deck', 'profiles'], key: deckStore.state.profile, @@ -123,6 +122,11 @@ export const saveDeck = throttle(1000, async () => { layout: deckStore.reactiveState.layout.value, }, }); +} + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + forceSaveDeck(); }); export async function getProfiles(): Promise<string[]> { diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index c552b65318..e8c71f61cf 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XWidgets/> </div> - <button v-if="(!isDesktop || pageMetadata?.needWideArea) && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> + <button v-if="!isDesktop && !pageMetadata?.needWideArea && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> <div v-if="isMobile" ref="navFooter" :class="$style.nav"> <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index 29e4558f1e..37742f8c2e 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -37,6 +37,12 @@ export default function(app: App) { app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue'))); } +// 連合関連のウィジェット(連合無効時に隠す) +export const federationWidgets = [ + 'federation', + 'instanceCloud', +]; + export const widgets = [ 'profile', 'instanceInfo', @@ -52,8 +58,6 @@ export const widgets = [ 'photos', 'digitalClock', 'unixClock', - 'federation', - 'instanceCloud', 'postForm', 'slideshow', 'serverMetric', @@ -67,4 +71,6 @@ export const widgets = [ 'clicker', 'search', 'birthdayFollowings', + + ...federationWidgets, ]; |