diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-03-22 09:55:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-03-22 09:55:38 +0900 |
| commit | 1e67e9c6616c6e87ae85ece71e5401006df2dd34 (patch) | |
| tree | a0d6df03a3d0ac2edf1fda7ed4bfb789b5a29720 /packages/frontend/src | |
| parent | Merge pull request #10218 from misskey-dev/develop (diff) | |
| parent | fix drive-cleaner (diff) | |
| download | misskey-1e67e9c6616c6e87ae85ece71e5401006df2dd34.tar.gz misskey-1e67e9c6616c6e87ae85ece71e5401006df2dd34.tar.bz2 misskey-1e67e9c6616c6e87ae85ece71e5401006df2dd34.zip | |
Merge pull request #10342 from misskey-dev/develop
Release: 13.10.0
Diffstat (limited to 'packages/frontend/src')
90 files changed, 1744 insertions, 622 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 610212b6ec..9b104391d7 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -26,11 +27,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise<Account> { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise<Account> { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); + .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, }); - } else { - alert({ + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial<Account>) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -155,6 +210,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -175,8 +232,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -188,7 +246,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -201,22 +259,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise<ReturnType<typeof createItem> | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -227,7 +292,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c72cc2ab1b..1875b507ca 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,7 +10,8 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vu import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; -type Captcha = { +// APIs provided by Captcha services +export type Captcha = { render(container: string | Node, options: { readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; }): string; @@ -32,7 +33,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; - sitekey: string; + sitekey: string | null; // null will show error on request modelValue?: string | null; }>(); diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue new file mode 100644 index 0000000000..c5fb718782 --- /dev/null +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -0,0 +1,39 @@ +<template> +<div :class="$style.root" class="_panel"> + <b>{{ clip.name }}</b> + <div v-if="clip.description" :class="$style.description">{{ clip.description }}</div> + <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div> + <div :class="$style.user"> + <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n'; + +defineProps<{ + clip: any; +}>(); +</script> + +<style lang="scss" module> +.root { + display: block; + padding: 16px; +} + +.description { + padding: 8px 0; +} + +.user { + padding-top: 16px; + border-top: solid 0.5px var(--divider); +} + +.userAvatar { + width: 32px; + height: 32px; +} +</style> diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 4525d3a009..d6303f9675 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,7 +1,9 @@ <script lang="ts"> import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue'; import MkAd from '@/components/global/MkAd.vue'; +import { isDebuggerEnabled, stackTraceInstances } from '@/debug'; import { i18n } from '@/i18n'; +import * as os from '@/os'; import { defaultStore } from '@/store'; import { MisskeyEntity } from '@/types/date-separated-list'; @@ -46,7 +48,7 @@ export default defineComponent({ if (props.items.length === 0) return; - const renderChildren = () => props.items.map((item, i) => { + const renderChildrenImpl = () => props.items.map((item, i) => { if (!slots || !slots.default) return; const el = slots.default({ @@ -95,6 +97,21 @@ export default defineComponent({ } }); + const renderChildren = () => { + const children = renderChildrenImpl(); + if (isDebuggerEnabled(6864)) { + const nodes = children.flatMap((node) => node ?? []); + const keys = new Set(nodes.map((node) => node.key)); + if (keys.size !== nodes.length) { + const id = crypto.randomUUID(); + const instances = stackTraceInstances(); + os.toast(instances.reduce((a, c) => `${a} at ${c.type.name}`, `[DEBUG_6864 (${id})]: ${nodes.length - keys.size} duplicated keys found`)); + console.warn({ id, debugId: 6864, stack: instances }); + } + } + return children; + }; + function onBeforeLeave(el: HTMLElement) { el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 8c17c0530a..ab408b5008 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -32,14 +32,14 @@ </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref } from 'vue'; +import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import copyToClipboard from '@/scripts/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { getDriveFileMenu } from '@/scripts/get-drive-file-menu'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; @@ -60,48 +60,16 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function getMenu() { - return [{ - text: i18n.ts.rename, - icon: 'ti ti-forms', - action: rename, - }, { - text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, - icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off', - action: toggleSensitive, - }, { - text: i18n.ts.describeFile, - icon: 'ti ti-text-caption', - action: describe, - }, null, { - text: i18n.ts.copyUrl, - icon: 'ti ti-link', - action: copyUrl, - }, { - type: 'a', - href: props.file.url, - target: '_blank', - text: i18n.ts.download, - icon: 'ti ti-download', - download: props.file.name, - }, null, { - text: i18n.ts.delete, - icon: 'ti ti-trash', - danger: true, - action: deleteFile, - }]; -} - function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } } function onContextmenu(ev: MouseEvent) { - os.contextMenu(getMenu(), ev); + os.contextMenu(getDriveFileMenu(props.file), ev); } function onDragstart(ev: DragEvent) { @@ -118,62 +86,6 @@ function onDragend() { isDragging.value = false; emit('dragend'); } - -function rename() { - os.inputText({ - title: i18n.ts.renameFile, - placeholder: i18n.ts.inputNewFileName, - default: props.file.name, - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/files/update', { - fileId: props.file.id, - name: name, - }); - }); -} - -function describe() { - os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { - default: props.file.comment != null ? props.file.comment : '', - file: props.file, - }, { - done: caption => { - os.api('drive/files/update', { - fileId: props.file.id, - comment: caption.length === 0 ? null : caption, - }); - }, - }, 'closed'); -} - -function toggleSensitive() { - os.api('drive/files/update', { - fileId: props.file.id, - isSensitive: !props.file.isSensitive, - }); -} - -function copyUrl() { - copyToClipboard(props.file.url); - os.success(); -} -/* -function addApp() { - alert('not implemented yet'); -} -*/ -async function deleteFile() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }), - }); - - if (canceled) return; - os.api('drive/files/delete', { - fileId: props.file.id, - }); -} </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index c418ac2c52..89abf1d946 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -8,7 +8,9 @@ <button v-for="emoji in emojis" :key="emoji" + :data-emoji="emoji" class="_button item" + @pointerenter="computeButtonTitle" @click="emit('chosen', emoji, $event)" > <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> @@ -20,6 +22,7 @@ <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; +import { getEmojiName } from '@/scripts/emojilist'; const props = defineProps<{ emojis: string[] | Ref<string[]>; @@ -33,4 +36,12 @@ const emit = defineEmits<{ const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value); const shown = ref(!!props.initialShown); + +/** @see MkEmojiPicker.vue */ +function computeButtonTitle(ev: MouseEvent): void { + const elm = ev.target as HTMLElement; + const emoji = elm.dataset.emoji as string; + elm.title = getEmojiName(emoji) ?? emoji; +} + </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 7d280f2f4b..a5a39108d6 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -35,8 +35,10 @@ <button v-for="emoji in pinned" :key="emoji" + :data-emoji="emoji" class="_button item" tabindex="0" + @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> @@ -52,6 +54,8 @@ v-for="emoji in recentlyUsedEmojis" :key="emoji" class="_button item" + :data-emoji="emoji" + @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> @@ -90,7 +94,7 @@ import { ref, shallowRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import XSection from '@/components/MkEmojiPicker.section.vue'; -import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; +import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; @@ -291,6 +295,13 @@ function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; } +/** @see MkEmojiPicker.section.vue */ +function computeButtonTitle(ev: MouseEvent): void { + const elm = ev.target as HTMLElement; + const emoji = elm.dataset.emoji as string; + elm.title = getEmojiName(emoji) ?? emoji; +} + function chosen(emoji: any, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el) { diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index b777a1329b..a4065dcd07 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -3,21 +3,24 @@ <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b> - <span style="display: block;">{{ $ts.clickToShow }}</span> + <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> </div> -<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'"> +<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'"> <a :class="$style.imageContainer" :href="image.url" :title="image.name" > <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> - <div v-if="image.type === 'image/gif'" :class="$style.gif">GIF</div> </a> - <button v-tooltip="$ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> + <div :class="$style.indicators"> + <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> + <div v-if="image.comment" :class="$style.indicator">ALT</div> + </div> + <button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> </div> </template> @@ -27,6 +30,7 @@ import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/media-proxy'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const props = defineProps<{ image: misskey.entities.DriveFile; @@ -34,11 +38,12 @@ const props = defineProps<{ }>(); let hide = $ref(true); +let darkMode = $ref(defaultStore.state.darkMode); const url = (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(props.image.thumbnailUrl) + ? getStaticImageUrl(props.image.url) : props.image.thumbnailUrl; // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする @@ -108,18 +113,25 @@ watch(() => props.image, () => { background-repeat: no-repeat; } -.gif { - background-color: var(--fg); +.indicators { + display: inline-flex; + position: absolute; + top: 12px; + left: 12px; + text-align: center; + pointer-events: none; + opacity: .5; + font-size: 14px; + gap: 6px; +} + +.indicator { + /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + background-color: black; border-radius: 6px; color: var(--accentLighten); display: inline-block; - font-size: 14px; font-weight: bold; - left: 12px; - opacity: .5; padding: 0 6px; - text-align: center; - top: 12px; - pointer-events: none; } </style> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index c768a086cd..d36cc2d26b 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -118,7 +118,7 @@ onMounted(() => { }); lightbox.init(); - + window.addEventListener('popstate', () => { if (lightbox.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); @@ -239,5 +239,6 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { max-height: 8em; overflow-y: auto; text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px; + white-space: pre-line; } </style> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 4529d61c2f..852c72f6ff 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -8,7 +8,7 @@ :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened" > <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> - <div class="_modalBg data-cy-bg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent, 'data-cy-transparent': isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> + <div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick"> <slot :max-height="maxHeight" :type="type"></slot> </div> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index bb1269562d..af81051a54 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -103,7 +103,8 @@ <i class="ti ti-ban"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> - <i class="ti ti-plus"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> </button> <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> @@ -329,18 +330,32 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); - blur(); - reactionPicker.show(reactButton.value, reaction => { + if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction, + reaction: '❤️', }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - }, () => { - focus(); - }); + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } } function undoReact(note): void { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f5f4a2afc1..ea72e1b517 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -108,7 +108,8 @@ <i class="ti ti-ban"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()"> - <i class="ti ti-plus"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> </button> <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> @@ -323,18 +324,32 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); - blur(); - reactionPicker.show(reactButton.value, reaction => { + if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction, + reaction: '❤️', }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - }, () => { - focus(); - }); + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } } function undoReact(note): void { diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index b38a4afa8b..2b541e6094 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -47,6 +47,9 @@ const showContent = $ref(false); width: 34px; height: 34px; border-radius: 8px; + position: sticky !important; + top: calc(16px + var(--stickyTop, 0px)); + left: 0; } .main { diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 38bf416ea8..b60967de02 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -69,8 +69,9 @@ <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <template v-else-if="notification.type === 'receiveFollowRequest'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span> - <div v-if="full && !followRequestDone"> - <button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button> + <div v-if="full && !followRequestDone" :class="$style.followRequestCommands"> + <MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> </div> </template> <span v-else-if="notification.type === 'app'" :class="$style.text"> @@ -87,6 +88,7 @@ import * as misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import XReactionTooltip from '@/components/MkReactionTooltip.vue'; +import MkButton from '@/components/MkButton.vue'; import { getNoteSummary } from '@/scripts/get-note-summary'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; @@ -294,6 +296,16 @@ useTooltip(reactionRef, (showing) => { margin-left: 4px; } +.followRequestCommands { + display: flex; + gap: 8px; + max-width: 300px; + margin-top: 8px; +} +.followRequestCommandButton { + flex: 1; +} + @container (max-width: 600px) { .root { padding: 16px; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 09f672be7b..b1800f3af7 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -53,14 +53,23 @@ <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> + <div v-if="showingOptions" style="padding: 0 16px;"> + <MkSelect v-model="reactionAcceptance" small> + <template #label>{{ i18n.ts.reactionAcceptance }}</template> + <option :value="null">{{ i18n.ts.all }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> + <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + </MkSelect> + </div> + <button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.emojiButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <footer :class="$style.footer"> <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> - <button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.footerButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> + <button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button> </footer> <datalist id="hashtags"> <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> @@ -76,6 +85,7 @@ import * as misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import * as Acct from 'misskey-js/built/acct'; +import MkSelect from './MkSelect.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import XNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; @@ -151,12 +161,14 @@ let visibleUsers = $ref([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(pushVisibleUser); } +let reactionAcceptance = $ref(defaultStore.state.reactionAcceptance); let autocomplete = $ref(null); let draghover = $ref(false); let quoteId = $ref(null); let hasNotSpecifiedMentions = $ref(false); let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]')); let imeText = $ref(''); +let showingOptions = $ref(false); const draftKey = $computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -166,7 +178,7 @@ const draftKey = $computed((): string => { } else if (props.reply) { key += `reply:${props.reply.id}`; } else { - key += 'note'; + key += `note:${$i.id}`; } return key; @@ -422,6 +434,10 @@ function pushVisibleUser(user) { function addVisibleUser() { os.selectUser().then(user => { pushVisibleUser(user); + + if (!text.toLowerCase().includes(`@${user.username.toLowerCase()}`)) { + text = `@${Acct.toString(user)} ${text}`; + } }); } @@ -610,6 +626,7 @@ async function post(ev?: MouseEvent) { localOnly: localOnly, visibility: visibility, visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, + reactionAcceptance, }; if (withHashtags && hashtags && hashtags.trim() !== '') { @@ -1026,6 +1043,18 @@ defineExpose({ } } +.emojiButton { + position: absolute; + top: 55px; + right: 13px; + display: inline-block; + padding: 0; + margin: 0; + font-size: 1em; + width: 32px; + height: 32px; +} + @container (max-width: 500px) { .header { height: 50px; diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 8326ec7ef3..85c009f746 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -36,9 +36,11 @@ async function renderChart() { const wide = rootEl.offsetWidth > 600; const narrow = rootEl.offsetWidth < 400; - const maxDays = wide ? 15 : narrow ? 5 : 10; + const maxDays = wide ? 10 : narrow ? 5 : 7; - const raw = await os.api('retention', { }); + let raw = await os.api('retention', { }); + + raw = raw.slice(0, maxDays); const data = []; for (const record of raw) { @@ -60,10 +62,9 @@ async function renderChart() { const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする - //const max = raw.readWrite.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; - const max = 4; + const max = raw.map(x => x.users).slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; - const marginEachCell = 6; + const marginEachCell = 12; chartInstance = new Chart(chartEl, { type: 'matrix', diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 83506b8f66..08e41d6ae5 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -20,7 +20,7 @@ import MkSignin from '@/components/MkSignin.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n'; -const props = withDefaults(defineProps<{ +withDefaults(defineProps<{ autoSet?: boolean; message?: string, }>(), { @@ -29,7 +29,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done'): void; + (ev: 'done', v: any): void; (ev: 'closed'): void; (ev: 'cancelled'): void; }>(); @@ -38,11 +38,11 @@ const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); function onClose() { emit('cancelled'); - dialog.close(); + if (dialog) dialog.close(); } function onLogin(res) { emit('done', res); - dialog.close(); + if (dialog) dialog.close(); } </script> diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue index 62ada6b736..30279148f8 100644 --- a/packages/frontend/src/components/MkSignup.vue +++ b/packages/frontend/src/components/MkSignup.vue @@ -72,7 +72,7 @@ import { toUnicode } from 'punycode/'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkSwitch from './MkSwitch.vue'; -import MkCaptcha from '@/components/MkCaptcha.vue'; +import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; import * as config from '@/config'; import * as os from '@/os'; import { login } from '@/account'; @@ -92,9 +92,9 @@ const emit = defineEmits<{ const host = toUnicode(config.host); -let hcaptcha = $ref(); -let recaptcha = $ref(); -let turnstile = $ref(); +let hcaptcha = $ref<Captcha | undefined>(); +let recaptcha = $ref<Captcha | undefined>(); +let turnstile = $ref<Captcha | undefined>(); let username: string = $ref(''); let password: string = $ref(''); @@ -110,6 +110,8 @@ let ToSAgreement: boolean = $ref(false); let hCaptchaResponse = $ref(null); let reCaptchaResponse = $ref(null); let turnstileResponse = $ref(null); +let usernameAbortController: null | AbortController = $ref(null); +let emailAbortController: null | AbortController = $ref(null); const shouldDisableSubmitting = $computed((): boolean => { return submitting || @@ -117,7 +119,9 @@ const shouldDisableSubmitting = $computed((): boolean => { instance.enableHcaptcha && !hCaptchaResponse || instance.enableRecaptcha && !reCaptchaResponse || instance.enableTurnstile && !turnstileResponse || - passwordRetypeState === 'not-match'; + instance.emailRequiredForSignup && emailState !== 'ok' || + usernameState !== 'ok' || + passwordRetypeState !== 'match'; }); function onChangeUsername(): void { @@ -139,14 +143,20 @@ function onChangeUsername(): void { } } + if (usernameAbortController != null) { + usernameAbortController.abort(); + } usernameState = 'wait'; + usernameAbortController = new AbortController(); os.api('username/available', { username, - }).then(result => { + }, undefined, usernameAbortController.signal).then(result => { usernameState = result.available ? 'ok' : 'unavailable'; - }).catch(() => { - usernameState = 'error'; + }).catch((err) => { + if (err.name !== 'AbortError') { + usernameState = 'error'; + } }); } @@ -156,11 +166,15 @@ function onChangeEmail(): void { return; } + if (emailAbortController != null) { + emailAbortController.abort(); + } emailState = 'wait'; + emailAbortController = new AbortController(); os.api('email-address/available', { emailAddress: email, - }).then(result => { + }, undefined, emailAbortController.signal).then(result => { emailState = result.available ? 'ok' : result.reason === 'used' ? 'unavailable:used' : result.reason === 'format' ? 'unavailable:format' : @@ -168,8 +182,10 @@ function onChangeEmail(): void { result.reason === 'mx' ? 'unavailable:mx' : result.reason === 'smtp' ? 'unavailable:smtp' : 'unavailable'; - }).catch(() => { - emailState = 'error'; + }).catch((err) => { + if (err.name !== 'AbortError') { + emailState = 'error'; + } }); } @@ -192,19 +208,20 @@ function onChangePasswordRetype(): void { passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; } -function onSubmit(): void { +async function onSubmit(): Promise<void> { if (submitting) return; submitting = true; - os.api('signup', { - username, - password, - emailAddress: email, - invitationCode, - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, - 'turnstile-response': turnstileResponse, - }).then(() => { + try { + await os.api('signup', { + username, + password, + emailAddress: email, + invitationCode, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + 'turnstile-response': turnstileResponse, + }); if (instance.emailRequiredForSignup) { os.alert({ type: 'success', @@ -213,28 +230,27 @@ function onSubmit(): void { }); emit('signupEmailPending'); } else { - os.api('signin', { + const res = await os.api('signin', { username, password, - }).then(res => { - emit('signup', res); - - if (props.autoSet) { - login(res.i); - } }); + emit('signup', res); + + if (props.autoSet) { + return login(res.i); + } } - }).catch(() => { + } catch { submitting = false; - hcaptcha.reset?.(); - recaptcha.reset?.(); - turnstile.reset?.(); + hcaptcha?.reset?.(); + recaptcha?.reset?.(); + turnstile?.reset?.(); os.alert({ type: 'error', text: i18n.ts.somethingHappened, }); - }); + } } </script> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 5381ecbfa5..094709e093 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -1,7 +1,18 @@ <template> -<template v-if="playerEnabled"> - <div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> - <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> +<template v-if="player.url && playerEnabled"> + <div + :class="$style.player" + :style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`" + > + <iframe + v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" + sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" + scrolling="no" + :allow="player.allow.join(';')" + :class="$style.playerIframe" + :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" + :style="{ border: 0 }" + ></iframe> <span v-else>invalid url</span> </div> <div :class="$style.action"> @@ -28,7 +39,7 @@ <header :class="$style.header"> <h1 v-if="unknownUrl" :class="$style.title">{{ url }}</h1> <h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1> - <h1 v-else :class="$style.title" :title="title">{{ title }}</h1> + <h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1> </header> <p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p> <p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p> @@ -37,7 +48,7 @@ <img v-if="icon" :class="$style.siteIcon" :src="icon"/> <p v-if="unknownUrl" :class="$style.siteName">?</p> <p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p> - <p v-else :class="$style.siteName" :title="sitename">{{ sitename }}</p> + <p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p> </footer> </article> </component> @@ -59,6 +70,7 @@ <script lang="ts" setup> import { defineAsyncComponent, onUnmounted } from 'vue'; +import type { summaly } from 'summaly'; import { url as local } from '@/config'; import { i18n } from '@/i18n'; import * as os from '@/os'; @@ -66,6 +78,8 @@ import { deviceKind } from '@/scripts/device-kind'; import MkButton from '@/components/MkButton.vue'; import { versatileLang } from '@/scripts/intl-const'; +type SummalyResult = Awaited<ReturnType<typeof summaly>>; + const props = withDefaults(defineProps<{ url: string; detail?: boolean; @@ -91,7 +105,7 @@ let player = $ref({ url: null, width: null, height: null, -}); +} as SummalyResult['player']); let playerEnabled = $ref(false); let tweetId = $ref<string | null>(null); let tweetExpanded = $ref(props.detail); @@ -114,11 +128,7 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/ requestUrl.hash = ''; window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => { - res.json().then(info => { - if (info.url == null) { - unknownUrl = true; - return; - } + res.json().then((info: SummalyResult) => { title = info.title; description = info.description; thumbnail = info.thumbnail; diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 19c735c5f8..d074fdd150 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -19,9 +19,9 @@ @update:model-value="v => emit('updateWidgets', v)" > <template #item="{element}"> - <div :class="[$style.widget, $style['customize-container']]" class="data-cy-customize-container"> + <div :class="[$style.widget, $style['customize-container']]" data-cy-customize-container> <button :class="$style['customize-container-config']" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> - <button :class="$style['customize-container-remove']" class="_button data-cy-customize-container-remove" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> + <button :class="$style['customize-container-remove']" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> <div class="handle"> <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style['customize-container-handle-widget']" :widget="element" @update-props="updateWidget(element.id, $event)"/> </div> diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 2bb432e15f..e0304c8bc5 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -82,7 +82,7 @@ const choseAd = (): Ad | null => { }; const chosen = ref(choseAd()); -const shouldHide = $ref($i && $i.policies.canHideAds); +const shouldHide = $ref($i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index d392ec6d6f..7fb830d537 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,5 +1,5 @@ <template> -<span v-if="!link" v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> +<span v-if="!link" v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> <img :class="$style.inner" :src="url" decoding="async"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <template v-if="user.isCat"> @@ -7,7 +7,7 @@ <div :class="$style.earRight"/> </template> </span> -<MkA v-else v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: $store.state.squareAvatars }]" :style="{ color }" :title="acct(user)" :to="userPage(user)" :target="target"> +<MkA v-else v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" :to="userPage(user)" :target="target"> <img :class="$style.inner" :src="url" decoding="async"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <template v-if="user.isCat"> @@ -26,6 +26,8 @@ import { acct, userPage } from '@/filters/user'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store'; +const squareAvatars = $ref(defaultStore.state.squareAvatars); + const props = withDefaults(defineProps<{ user: misskey.entities.User; target?: string | null; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 560870f84c..63e8fc225c 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -20,26 +20,32 @@ import MkSpacer from './global/MkSpacer.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; export default function(app: App) { - app.component('I18n', I18n); - app.component('RouterView', RouterView); - app.component('Mfm', Mfm); - app.component('MkA', MkA); - app.component('MkAcct', MkAcct); - app.component('MkAvatar', MkAvatar); - app.component('MkEmoji', MkEmoji); - app.component('MkCustomEmoji', MkCustomEmoji); - app.component('MkUserName', MkUserName); - app.component('MkEllipsis', MkEllipsis); - app.component('MkTime', MkTime); - app.component('MkUrl', MkUrl); - app.component('MkLoading', MkLoading); - app.component('MkError', MkError); - app.component('MkAd', MkAd); - app.component('MkPageHeader', MkPageHeader); - app.component('MkSpacer', MkSpacer); - app.component('MkStickyContainer', MkStickyContainer); + for (const [key, value] of Object.entries(components)) { + app.component(key, value); + } } +export const components = { + I18n: I18n, + RouterView: RouterView, + Mfm: Mfm, + MkA: MkA, + MkAcct: MkAcct, + MkAvatar: MkAvatar, + MkEmoji: MkEmoji, + MkCustomEmoji: MkCustomEmoji, + MkUserName: MkUserName, + MkEllipsis: MkEllipsis, + MkTime: MkTime, + MkUrl: MkUrl, + MkLoading: MkLoading, + MkError: MkError, + MkAd: MkAd, + MkPageHeader: MkPageHeader, + MkSpacer: MkSpacer, + MkStickyContainer: MkStickyContainer, +}; + declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 46ebc7d6a3..1d1b8fcea4 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -53,6 +53,7 @@ export const ROLE_POLICIES = [ 'canPublicNote', 'canInvite', 'canManageCustomEmojis', + 'canSearchNotes', 'canHideAds', 'driveCapacityMb', 'pinLimit', diff --git a/packages/frontend/src/debug.ts b/packages/frontend/src/debug.ts new file mode 100644 index 0000000000..5715acf674 --- /dev/null +++ b/packages/frontend/src/debug.ts @@ -0,0 +1,27 @@ +import { type ComponentInternalInstance, getCurrentInstance } from 'vue'; + +export function isDebuggerEnabled(id: number): boolean { + try { + return localStorage.getItem(`DEBUG_${id}`) !== null; + } catch { + return false; + } +} + +export function switchDebuggerEnabled(id: number, enabled: boolean): void { + if (enabled) { + localStorage.setItem(`DEBUG_${id}`, ''); + } else { + localStorage.removeItem(`DEBUG_${id}`); + } +} + +export function stackTraceInstances(): ComponentInternalInstance[] { + let instance = getCurrentInstance(); + const stack: ComponentInternalInstance[] = []; + while (instance) { + stack.push(instance); + instance = instance.parent; + } + return stack; +} diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index 854f0a544e..064ee4f64b 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -14,17 +14,23 @@ import adaptiveBg from './adaptive-bg'; import container from './container'; export default function(app: App) { - app.directive('userPreview', userPreview); - app.directive('user-preview', userPreview); - app.directive('get-size', getSize); - app.directive('ripple', ripple); - app.directive('tooltip', tooltip); - app.directive('hotkey', hotkey); - app.directive('appear', appear); - app.directive('anim', anim); - app.directive('click-anime', clickAnime); - app.directive('panel', panel); - app.directive('adaptive-border', adaptiveBorder); - app.directive('adaptive-bg', adaptiveBg); - app.directive('container', container); + for (const [key, value] of Object.entries(directives)) { + app.directive(key, value); + } } + +export const directives = { + 'userPreview': userPreview, + 'user-preview': userPreview, + 'get-size': getSize, + 'ripple': ripple, + 'tooltip': tooltip, + 'hotkey': hotkey, + 'appear': appear, + 'anim': anim, + 'click-anime': clickAnime, + 'panel': panel, + 'adaptive-border': adaptiveBorder, + 'adaptive-bg': adaptiveBg, + 'container': container, +}; diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index 0a626b36c6..a2dff87e8e 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -343,7 +343,9 @@ stream.on('_disconnected_', async () => { }); for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { - import('./plugin').then(({ install }) => { + import('./plugin').then(async ({ install }) => { + // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 + await new Promise(r => setTimeout(r, 0)); install(plugin); }); } diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index efc0e8c920..0e2f787d50 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -2,6 +2,7 @@ import { computed, reactive } from 'vue'; import { $i } from './account'; import { miLocalStorage } from './local-storage'; import { openInstanceMenu } from './ui/_common_/common'; +import { lookup } from './scripts/lookup'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { ui } from '@/config'; @@ -44,6 +45,13 @@ export const navbarItemDef = reactive({ icon: 'ti ti-search', to: '/search', }, + lookup: { + title: i18n.ts.lookup, + icon: 'ti ti-world-search', + action: (ev) => { + lookup(); + }, + }, lists: { title: i18n.ts.lists, icon: 'ti ti-list', @@ -136,4 +144,10 @@ export const navbarItemDef = reactive({ location.reload(); }, }, + profile: { + title: i18n.ts.profile, + icon: 'ti ti-user', + show: computed(() => $i != null), + to: `/@${$i?.username}`, + }, }); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 3c073fc7c4..60f61ed293 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -19,7 +19,7 @@ <div style="text-align: center;"> {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a> </div> - <div style="text-align: center;"> + <div v-if="$i != null" style="text-align: center;"> <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton> </div> <FormSection> @@ -126,6 +126,12 @@ const patronsWithIcon = [{ }, { name: 'ぱーこ', icon: 'https://misskey-hub.net/patrons/79c6602ffade489e8df2fcf2c2bc5d9d.jpg', +}, { + name: 'わっほー☆', + icon: 'https://misskey-hub.net/patrons/d31d5d13924443a082f3da7966318a0a.jpg', +}, { + name: 'mollinaca', + icon: 'https://misskey-hub.net/patrons/ceb36b8f66e549bdadb3b90d5da62314.jpg', }]; const patrons = [ @@ -210,6 +216,7 @@ const patrons = [ 'あめ玉', '氷月氷華里', 'Ebise Lutica', + '巣黒るい@リスケモ男の娘VTuber!', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index b054999303..8aae39cba1 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -12,7 +12,7 @@ <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> </div> </MkSpacer> </div> @@ -23,7 +23,7 @@ </template> <script lang="ts" setup> -import { onMounted, onUnmounted, provide, watch } from 'vue'; +import { onActivated, onMounted, onUnmounted, provide, watch } from 'vue'; import { i18n } from '@/i18n'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -144,6 +144,11 @@ const menuDef = $computed(() => [{ to: '/admin/settings', active: currentPage?.route.name === 'settings', }, { + icon: 'ti ti-shield', + text: i18n.ts.moderation, + to: '/admin/moderation', + active: currentPage?.route.name === 'moderation', + }, { icon: 'ti ti-mail', text: i18n.ts.emailServer, to: '/admin/email-settings', @@ -204,10 +209,23 @@ onMounted(() => { } }); +onActivated(() => { + narrow = el.offsetWidth < NARROW_THRESHOLD; + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + onUnmounted(() => { ro.disconnect(); }); +watch(router.currentRef, (to) => { + if (to.route.path === "/admin" && to.child?.route.name == null && !narrow) { + router.replace('/admin/overview'); + } +}); + provideMetadataReceiver((info) => { if (info == null) { childInfo = null; diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue new file mode 100644 index 0000000000..7c2f04a9ab --- /dev/null +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -0,0 +1,73 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_gaps_m"> + <FormSection first> + <div class="_gaps_m"> + <MkTextarea v-model="sensitiveWords"> + <template #label>{{ i18n.ts.sensitiveWords }}</template> + <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> + </MkTextarea> + </div> + </FormSection> + </div> + </FormSuspense> + </MkSpacer> + <template #footer> + <div :class="$style.footer"> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="16"> + <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </MkSpacer> + </div> + </template> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; + +let sensitiveWords: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + sensitiveWords = meta.pinnedUsers.join('\n'); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + sensitiveWords: sensitiveWords.split('\n'), + }).then(() => { + fetchInstance(); + }); +} + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.moderation, + icon: 'ti ti-shield', +}); +</script> + +<style lang="scss" module> +.footer { + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} +</style> diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue index 80e97fed93..509d329eb1 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/queue.vue @@ -4,6 +4,8 @@ <MkSpacer :content-max="800"> <XQueue v-if="tab === 'deliver'" domain="deliver"/> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> + <br> + <MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton> </MkSpacer> </MkStickyContainer> </template> @@ -15,6 +17,7 @@ import * as os from '@/os'; import * as config from '@/config'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; let tab = $ref('deliver'); @@ -30,6 +33,18 @@ function clear() { }); } +function promoteAllQueues() { + os.confirm({ + type: 'warning', + title: i18n.ts.retryAllQueuesConfirmTitle, + text: i18n.ts.retryAllQueuesConfirmText, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/promote', { type: tab }); + }); +} + const headerActions = $computed(() => [{ asFullButton: true, icon: 'ti ti-external-link', diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index ac6cca84c1..e6896237f8 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -55,6 +55,7 @@ if (props.id) { isPublic: false, asBadge: false, canEditMembersByModerator: false, + displayOrder: 0, policies: {}, }; } diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 2fb605f8c0..873ff02feb 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -17,6 +17,11 @@ <template #label>{{ i18n.ts._role.iconUrl }}</template> </MkInput> + <MkInput v-model="role.displayOrder" type="number"> + <template #label>{{ i18n.ts._role.displayOrder }}</template> + <template #caption>{{ i18n.ts._role.descriptionOfDisplayOrder }}</template> + </MkInput> + <MkSelect v-model="rolePermission" :readonly="readonly"> <template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template> <template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template> @@ -182,6 +187,26 @@ </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> + <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> + <template #suffix> + <span v-if="role.policies.canSearchNotes.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canSearchNotes.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canSearchNotes)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canSearchNotes.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canSearchNotes.value" :disabled="role.policies.canSearchNotes.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canSearchNotes.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #suffix> @@ -444,6 +469,7 @@ const save = throttle(100, () => { description: role.description, color: role.color === '' ? null : role.color, iconUrl: role.iconUrl === '' ? null : role.iconUrl, + displayOrder: role.displayOrder, target: role.target, condFormula: role.condFormula, isAdministrator: role.isAdministrator, diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 25d8f3ad6e..a1e467edbd 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -7,7 +7,11 @@ <MkFolder> <template #label>{{ i18n.ts._role.baseRole }}</template> <div class="_gaps_s"> - <MkFolder> + <MkInput v-model="baseRoleQ" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])"> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> <MkRange :model-value="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor = (v / 100)"> @@ -15,7 +19,7 @@ </MkRange> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.gtlAvailable, 'gtlAvailable'])"> <template #label>{{ i18n.ts._role._options.gtlAvailable }}</template> <template #suffix>{{ policies.gtlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.gtlAvailable"> @@ -23,7 +27,7 @@ </MkSwitch> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])"> <template #label>{{ i18n.ts._role._options.ltlAvailable }}</template> <template #suffix>{{ policies.ltlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.ltlAvailable"> @@ -31,7 +35,7 @@ </MkSwitch> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])"> <template #label>{{ i18n.ts._role._options.canPublicNote }}</template> <template #suffix>{{ policies.canPublicNote ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.canPublicNote"> @@ -39,7 +43,7 @@ </MkSwitch> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])"> <template #label>{{ i18n.ts._role._options.canInvite }}</template> <template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.canInvite"> @@ -47,7 +51,7 @@ </MkSwitch> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> <template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.canManageCustomEmojis"> @@ -55,7 +59,15 @@ </MkSwitch> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> + <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> + <template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canSearchNotes"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #suffix>{{ policies.driveCapacityMb }}MB</template> <MkInput v-model="policies.driveCapacityMb" type="number"> @@ -63,21 +75,21 @@ </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix>{{ policies.pinLimit }}</template> <MkInput v-model="policies.pinLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])"> <template #label>{{ i18n.ts._role._options.antennaMax }}</template> <template #suffix>{{ policies.antennaLimit }}</template> <MkInput v-model="policies.antennaLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.wordMuteMax, 'wordMuteLimit'])"> <template #label>{{ i18n.ts._role._options.wordMuteMax }}</template> <template #suffix>{{ policies.wordMuteLimit }}</template> <MkInput v-model="policies.wordMuteLimit" type="number"> @@ -85,42 +97,42 @@ </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.webhookMax, 'webhookLimit'])"> <template #label>{{ i18n.ts._role._options.webhookMax }}</template> <template #suffix>{{ policies.webhookLimit }}</template> <MkInput v-model="policies.webhookLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.clipMax, 'clipLimit'])"> <template #label>{{ i18n.ts._role._options.clipMax }}</template> <template #suffix>{{ policies.clipLimit }}</template> <MkInput v-model="policies.clipLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.noteEachClipsMax, 'noteEachClipsLimit'])"> <template #label>{{ i18n.ts._role._options.noteEachClipsMax }}</template> <template #suffix>{{ policies.noteEachClipsLimit }}</template> <MkInput v-model="policies.noteEachClipsLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.userListMax, 'userListLimit'])"> <template #label>{{ i18n.ts._role._options.userListMax }}</template> <template #suffix>{{ policies.userListLimit }}</template> <MkInput v-model="policies.userListLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.userEachUserListsMax, 'userEachUserListsLimit'])"> <template #label>{{ i18n.ts._role._options.userEachUserListsMax }}</template> <template #suffix>{{ policies.userEachUserListsLimit }}</template> <MkInput v-model="policies.userEachUserListsLimit" type="number"> </MkInput> </MkFolder> - <MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canHideAds, 'canHideAds'])"> <template #label>{{ i18n.ts._role._options.canHideAds }}</template> <template #suffix>{{ policies.canHideAds ? i18n.ts.yes : i18n.ts.no }}</template> <MkSwitch v-model="policies.canHideAds"> @@ -153,7 +165,7 @@ </template> <script lang="ts" setup> -import { computed, reactive } from 'vue'; +import { computed, reactive, ref } from 'vue'; import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -167,27 +179,10 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { instance } from '@/instance'; import { useRouter } from '@/router'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; - -const ROLE_POLICIES = [ - 'gtlAvailable', - 'ltlAvailable', - 'canPublicNote', - 'canInvite', - 'canManageCustomEmojis', - 'canHideAds', - 'driveCapacityMb', - 'pinLimit', - 'antennaLimit', - 'wordMuteLimit', - 'webhookLimit', - 'clipLimit', - 'noteEachClipsLimit', - 'userListLimit', - 'userEachUserListsLimit', - 'rateLimitFactor', -] as const; +import { ROLE_POLICIES } from '@/const'; const router = useRouter(); +const baseRoleQ = ref(''); const roles = await os.api('admin/roles/list'); @@ -196,6 +191,11 @@ for (const ROLE_POLICY of ROLE_POLICIES) { policies[ROLE_POLICY] = instance.policies[ROLE_POLICY]; } +function matchQuery(keywords: string[]): boolean { + if (baseRoleQ.value.trim().length === 0) return true; + return keywords.some(keyword => keyword.toLowerCase().includes(baseRoleQ.value.toLowerCase())); +} + async function updateBaseRole() { await os.apiWithDialog('admin/roles/update-default-policies', { policies, diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue new file mode 100644 index 0000000000..728ef3c0b1 --- /dev/null +++ b/packages/frontend/src/pages/ads.vue @@ -0,0 +1,25 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + + <MkSpacer :content-max="500"> + <div class="_gaps"> + <MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +definePageMetadata({ + title: i18n.ts.ads, + icon: 'ti ti-ad', +}); +</script> + diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 65edb97e83..76f11faab8 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -46,7 +46,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os'; import { useRouter } from '@/router'; -import { $i } from '@/account'; +import { $i, iAmModerator } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { deviceKind } from '@/scripts/device-kind'; @@ -90,21 +90,30 @@ function openPostForm() { }); } -const headerActions = $computed(() => channel && channel.userId ? [{ - icon: 'ti ti-share', - text: i18n.ts.share, - handler: async (): Promise<void> => { - navigator.share({ - title: channel.name, - text: channel.description, - url: `${url}/channels/${channel.id}`, - }); - }, -}, { - icon: 'ti ti-settings', - text: i18n.ts.edit, - handler: edit, -}] : null); +const headerActions = $computed(() => { + if (channel && channel.userId) { + const share = { + icon: 'ti ti-share', + text: i18n.ts.share, + handler: async (): Promise<void> => { + navigator.share({ + title: channel.name, + text: channel.description, + url: `${url}/channels/${channel.id}`, + }); + }, + }; + + const canEdit = ($i && $i.id === channel.userId) || iAmModerator; + return canEdit ? [share, { + icon: 'ti ti-settings', + text: i18n.ts.edit, + handler: edit, + }] : [share]; + } else { + return null; + } +}); const headerTabs = $computed(() => [{ key: 'overview', diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index d66088d33a..7515a9122a 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -7,6 +7,8 @@ <div v-if="clip.description" class="description"> <Mfm :text="clip.description" :is-note="false" :i="$i"/> </div> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> <div class="user"> <MkAvatar :user="clip.user" class="avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> </div> @@ -27,12 +29,14 @@ import { i18n } from '@/i18n'; import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { url } from '@/config'; +import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ clipId: string, }>(); let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>(); +let favorited = $ref(false); const pagination = { endpoint: 'clips/notes' as const, limit: 10, @@ -47,12 +51,34 @@ watch(() => props.clipId, async () => { clip = await os.api('clips/show', { clipId: props.clipId, }); + favorited = clip.isFavorited; }, { immediate: true, }); provide('currentClipPage', $$(clip)); +function favorite() { + os.apiWithDialog('clips/favorite', { + clipId: props.clipId, + }).then(() => { + favorited = true; + }); +} + +async function unfavorite() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unfavoriteConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('clips/unfavorite', { + clipId: props.clipId, + }).then(() => { + favorited = false; + }); +} + const headerActions = $computed(() => clip && isOwned ? [{ icon: 'ti ti-pencil', text: i18n.ts.edit, diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 9be30f76a0..84bc153b71 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -22,6 +22,9 @@ <template #label>{{ i18n.ts.tags }}</template> <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> </MkInput> + <MkInput v-model="license"> + <template #label>{{ i18n.ts.license }}</template> + </MkInput> <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> </MkSpacer> @@ -45,6 +48,7 @@ let dialog = $ref(null); let name: string = $ref(props.emoji.name); let category: string = $ref(props.emoji.category); let aliases: string = $ref(props.emoji.aliases.join(' ')); +let license: string = $ref(props.emoji.license ?? ''); const emit = defineEmits<{ (ev: 'done', v: { deleted?: boolean, updated?: any }): void, @@ -61,6 +65,7 @@ async function update() { name, category, aliases: aliases.split(' '), + license: license === '' ? null : license, }); emit('done', { @@ -69,6 +74,7 @@ async function update() { name, category, aliases: aliases.split(' '), + license: license === '' ? null : license, }, }); diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 0edc290801..bdd21b29ee 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -34,6 +34,17 @@ function menu(ev) { copyToClipboard(`:${props.emoji.name}:`); os.success(); }, + }, { + text: i18n.ts.info, + icon: 'ti ti-info-circle', + action: () => { + os.apiGet('emoji', { name: props.emoji.name }).then(res => { + os.alert({ + type: 'info', + text: `License: ${res.license}`, + }); + }); + }, }], ev.currentTarget ?? ev.target); } </script> diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue index 51177d079c..6ac469f7ba 100644 --- a/packages/frontend/src/pages/explore.roles.vue +++ b/packages/frontend/src/pages/explore.roles.vue @@ -1,5 +1,5 @@ <template> -<MkSpacer :content-max="1200"> +<MkSpacer :content-max="700"> <div class="_gaps_s"> <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/> </div> @@ -13,10 +13,8 @@ import * as os from '@/os'; let roles = $ref(); -os.api('roles/list', { - limit: 30, -}).then(res => { - roles = res.filter(x => x.target === 'manual'); +os.api('roles/list').then(res => { + roles = res.filter(x => x.target === 'manual').sort((a, b) => b.displayOrder - a.displayOrder); }); </script> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 2b7fcf74e1..35edcc7cda 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInput from '@/components/MkInput.vue'; import { useRouter } from '@/router'; -const PRESET_DEFAULT = `/// @ 0.12.4 +const PRESET_DEFAULT = `/// @ 0.13.1 var name = "" @@ -51,7 +51,7 @@ Ui:render([ ]) `; -const PRESET_OMIKUJI = `/// @ 0.12.4 +const PRESET_OMIKUJI = `/// @ 0.13.1 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -94,7 +94,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.12.4 +const PRESET_SHUFFLE = `/// @ 0.13.1 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -173,7 +173,7 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.12.4 +const PRESET_QUIZ = `/// @ 0.13.1 let title = '地理クイズ' let qas = [{ @@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({ Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.12.4 +const PRESET_TIMELINE = `/// @ 0.13.1 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 835dd0b54c..a51d1c78a4 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -18,12 +18,9 @@ <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> <p class="acct">@{{ acct(req.follower) }}</p> </div> - <div v-if="req.follower.description" class="description" :title="req.follower.description"> - <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :plain="true" :nowrap="true"/> - </div> - <div class="actions"> - <button class="_button" @click="accept(req.follower)"><i class="ti ti-check"></i></button> - <button class="_button" @click="reject(req.follower)"><i class="ti ti-x"></i></button> + <div class="commands"> + <MkButton class="command" rounded primary @click="accept(req.follower)"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton class="command" rounded danger @click="reject(req.follower)"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> </div> </div> </div> @@ -37,6 +34,7 @@ <script lang="ts" setup> import { shallowRef, computed } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -90,13 +88,11 @@ definePageMetadata(computed(() => ({ display: flex; width: calc(100% - 54px); position: relative; + flex-wrap: wrap; + gap: 8px; > .name { - width: 45%; - - @media (max-width: 500px) { - width: 100%; - } + flex: 1 1 50%; > .name, > .acct { @@ -136,6 +132,11 @@ definePageMetadata(computed(() => ({ } } + > .commands { + display: flex; + gap: 8px; + } + > .actions { position: absolute; top: 0; diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index a79601f32f..4c23985f3b 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -1,25 +1,30 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <div class="qtcaoidl"> - <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <div v-if="tab === 'my'" class="_gaps"> + <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> + <MkClipPreview :clip="item"/> </MkA> </MkPagination> </div> + <div v-else-if="tab === 'favorites'" class="_gaps"> + <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> + <MkClipPreview :clip="item"/> + </MkA> + </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { } from 'vue'; +import { watch } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; +import MkClipPreview from '@/components/MkClipPreview.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; @@ -29,8 +34,15 @@ const pagination = { limit: 10, }; +let tab = $ref('my'); +let favorites = $ref(); + const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); +watch($$(tab), async () => { + favorites = await os.api('clips/my-favorites'); +}); + async function create() { const { canceled, result } = await os.form(i18n.ts.createNewClip, { name: { @@ -66,7 +78,15 @@ function onClipDeleted() { const headerActions = $computed(() => []); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'my', + title: i18n.ts.myClips, + icon: 'ti ti-paperclip', +}, { + key: 'favorites', + title: i18n.ts.favorites, + icon: 'ti ti-heart', +}]); definePageMetadata({ title: i18n.ts.clip, @@ -78,23 +98,6 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.qtcaoidl { - > .add { - margin: 0 auto 16px auto; - } - - > .list { - > .item { - display: block; - padding: 16px; +<style lang="scss" module> - > .description { - margin-top: 8px; - padding-top: 8px; - border-top: solid 0.5px var(--divider); - } - } - } -} </style> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 165e357ebd..45efe655fb 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -17,13 +17,11 @@ </div> <div v-if="clips && clips.length > 0" class="clips _margin"> <div class="title">{{ i18n.ts.clip }}</div> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> - <div class="user"> - <MkAvatar :user="item.user" class="avatar" indicator link preview/> <MkUserName :user="item.user" :nowrap="false"/> - </div> - </MkA> + <div class="_gaps"> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`"> + <MkClipPreview :clip="item"/> + </MkA> + </div> </div> <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton> </div> @@ -51,6 +49,7 @@ import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { dateString } from '@/filters/date'; +import MkClipPreview from '@/components/MkClipPreview.vue'; const props = defineProps<{ noteId: string; @@ -178,27 +177,6 @@ definePageMetadata(computed(() => note ? { font-weight: bold; padding: 12px; } - - > .item { - display: block; - padding: 16px; - - > .description { - padding: 8px 0; - } - - > .user { - $height: 32px; - padding-top: 16px; - border-top: solid 0.5px var(--divider); - line-height: $height; - - > .avatar { - width: $height; - height: $height; - } - } - } } } } diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index da64a4c1e0..a5c7cdaa71 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -75,9 +75,11 @@ const headerActions = $computed(() => [tab === 'all' ? { const headerTabs = $computed(() => [{ key: 'all', title: i18n.ts.all, + icon: 'ti ti-point', }, { key: 'unread', title: i18n.ts.unread, + icon: 'ti ti-loader', }, { key: 'mentions', title: i18n.ts.mentions, diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 202244b34c..b26255ce61 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -75,6 +75,8 @@ import MkPagination from '@/components/MkPagination.vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { pageViewInterruptors } from '@/store'; +import { deepClone } from '@/scripts/clone'; const props = defineProps<{ pageName: string; @@ -97,8 +99,17 @@ function fetchPage() { os.api('pages/show', { name: props.pageName, username: props.username, - }).then(_page => { + }).then(async _page => { page = _page; + + // plugin + if (pageViewInterruptors.length > 0) { + let result = deepClone(_page); + for (const interruptor of pageViewInterruptors) { + result = await interruptor.handler(result); + } + page = result; + } }).catch(err => { error = err; }); diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 7e81cd2c0d..cc6f8cc0cc 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -1,26 +1,42 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :content-max="800"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" :debounce="true" type="search" style="margin-bottom: var(--margin);" @update:model-value="search()"> - <template #prefix><i class="ti ti-search"></i></template> - </MkInput> - <MkTab v-model="searchType" style="margin-bottom: var(--margin);" @update:model-value="search()"> - <option value="note">{{ i18n.ts.note }}</option> - <option value="user">{{ i18n.ts.user }}</option> - </MkTab> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="tab === 'note'" :content-max="800"> + <div v-if="notesSearchAvailable" class="_gaps"> + <div class="_gaps"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> + </div> - <div v-if="searchType === 'note'"> - <MkNotes v-if="searchQuery" ref="notes" :pagination="notePagination"/> + <MkFoldableSection v-if="notePagination"> + <template #header>{{ i18n.ts.searchResult }}</template> + <MkNotes :key="key" :pagination="notePagination"/> + </MkFoldableSection> </div> <div v-else> - <MkRadios v-model="searchOrigin" style="margin-bottom: var(--margin);" @update:model-value="search()"> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkRadios> + <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> + </div> + </MkSpacer> + <MkSpacer v-else-if="tab === 'user'" :content-max="800"> + <div class="_gaps"> + <div class="_gaps"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkRadios v-model="searchOrigin" @update:model-value="search()"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> + </div> - <MkUserList v-if="searchQuery" ref="users" :pagination="userPagination"/> + <MkFoldableSection v-if="userPagination"> + <template #header>{{ i18n.ts.searchResult }}</template> + <MkUserList :key="key" :pagination="userPagination"/> + </MkFoldableSection> </div> </MkSpacer> </MkStickyContainer> @@ -31,14 +47,15 @@ import { computed, onMounted } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; import MkUserList from '@/components/MkUserList.vue'; import MkInput from '@/components/MkInput.vue'; -import MkTab from '@/components/MkTab.vue'; import MkRadios from '@/components/MkRadios.vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import * as os from '@/os'; -import { useRouter, mainRouter } from '@/router'; - -const router = useRouter(); +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import MkInfo from '@/components/MkInfo.vue'; const props = defineProps<{ query: string; @@ -47,97 +64,60 @@ const props = defineProps<{ origin?: string; }>(); +let key = $ref(''); +let tab = $ref('note'); let searchQuery = $ref(''); -let searchType = $ref('note'); let searchOrigin = $ref('combined'); +let notePagination = $ref(); +let userPagination = $ref(); + +const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes)); onMounted(() => { + tab = props.type ?? 'note'; searchQuery = props.query ?? ''; - searchType = props.type ?? 'note'; searchOrigin = props.origin ?? 'combined'; - - if (searchQuery) { - search(); - } }); -const search = async () => { +async function search() { const query = searchQuery.toString().trim(); if (query == null || query === '') return; - if (query.startsWith('@') && !query.includes(' ')) { - mainRouter.push(`/${query}`); - return; - } - - if (query.startsWith('#')) { - mainRouter.push(`/tags/${encodeURIComponent(query.substr(1))}`); - return; + if (tab === 'note') { + notePagination = { + endpoint: 'notes/search', + limit: 10, + params: { + query: searchQuery, + channelId: props.channel, + }, + }; + } else if (tab === 'user') { + userPagination = { + endpoint: 'users/search', + limit: 10, + params: { + query: searchQuery, + origin: searchOrigin, + }, + }; } - // like 2018/03/12 - if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(query.replace(/-/g, '/'))) { - const date = new Date(query.replace(/-/g, '/')); - - // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは - // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので - // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の - // 結果になってしまい、2018/03/12 のコンテンツは含まれない) - if (query.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { - date.setHours(23, 59, 59, 999); - } - - // TODO - //v.$root.$emit('warp', date); - os.alert({ - icon: 'ti ti-history', - iconOnly: true, autoClose: true, - }); - return; - } - - if (query.startsWith('https://')) { - const promise = os.api('ap/show', { - uri: query, - }); - - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - - const res = await promise; - - if (res.type === 'User') { - mainRouter.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - mainRouter.push(`/notes/${res.object.id}`); - } - - return; - } - - window.history.replaceState('', '', `/search?q=${encodeURIComponent(query)}&type=${searchType}${searchType === 'user' ? `&origin=${searchOrigin}` : ''}`); -}; - -const notePagination = { - endpoint: 'notes/search' as const, - limit: 10, - params: computed(() => ({ - query: searchQuery, - channelId: props.channel, - })), -}; -const userPagination = { - endpoint: 'users/search' as const, - limit: 10, - params: computed(() => ({ - query: searchQuery, - origin: searchOrigin, - })), -}; + key = query; +} const headerActions = $computed(() => []); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'note', + title: i18n.ts.notes, + icon: 'ti ti-pencil', +}, { + key: 'user', + title: i18n.ts.users, + icon: 'ti ti-users', +}]); definePageMetadata(computed(() => ({ title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search, diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index a5eaae2bad..a58e74fe69 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -2,21 +2,12 @@ <div class=""> <FormSuspense :p="init"> <div class="_gaps"> - <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> - - <div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)"> - <div class="avatar"> - <MkAvatar :user="account" class="avatar"/> - </div> - <div class="body"> - <div class="name"> - <MkUserName :user="account"/> - </div> - <div class="acct"> - <MkAcct :user="account"/> - </div> - </div> + <div class="_buttons"> + <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> + <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)"/> </div> </FormSuspense> </div> @@ -30,9 +21,11 @@ import * as os from '@/os'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import type * as Misskey from 'misskey-js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; const storedAccounts = ref<any>(null); -const accounts = ref<any>(null); +const accounts = ref<Misskey.entities.UserDetailed[]>([]); const init = async () => { getAccounts().then(accounts => { @@ -52,7 +45,7 @@ function menu(account, ev) { icon: 'ti ti-switch-horizontal', action: () => switchAccount(account), }, { - text: i18n.ts.remove, + text: i18n.ts.logout, icon: 'ti ti-trash', danger: true, action: () => removeAccount(account), @@ -69,23 +62,25 @@ function addAccount(ev) { }], ev.currentTarget ?? ev.target); } -function removeAccount(account) { - _removeAccount(account.id); +async function removeAccount(account) { + await _removeAccount(account.id); + accounts.value = accounts.value.filter(x => x.id !== account.id); } function addExistingAccount() { os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: res => { - addAccounts(res.id, res.i); + done: async res => { + await addAccounts(res.id, res.i); os.success(); + init(); }, }, 'closed'); } function createAccount() { os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: res => { - addAccounts(res.id, res.i); + done: async res => { + await addAccounts(res.id, res.i); switchAccountWithToken(res.i); }, }, 'closed'); @@ -111,32 +106,8 @@ definePageMetadata({ }); </script> -<style lang="scss" scoped> -.lcjjdxlm { - display: flex; - padding: 16px; - - > .avatar { - display: block; - flex-shrink: 0; - margin: 0 12px 0 0; - - > .avatar { - width: 50px; - height: 50px; - } - } - - > .body { - display: flex; - flex-direction: column; - justify-content: center; - width: calc(100% - 62px); - position: relative; - - > .name { - font-weight: bold; - } - } +<style lang="scss" module> +.user { + cursor: pointer; } </style> diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue new file mode 100644 index 0000000000..8178343bbb --- /dev/null +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -0,0 +1,156 @@ +<template> +<div class="_gaps"> + <MkSelect v-model="sortModeSelect"> + <template #label>{{ i18n.ts.sort }}</template> + <option v-for="x in sortOptions" :key="x.value" :value="x.value">{{ x.displayName }}</option> + </MkSelect> + <div v-if="!fetching"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div class="_gaps"> + <div + v-for="file in items" :key="file.id" + class="_button" + @click="$event => onClick($event, file)" + @contextmenu.stop="$event => onContextMenu($event, file)" + > + <div :class="$style.file"> + <div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> + <MkDriveFileThumbnail :class="$style.fileThumbnail" :file="file" fit="contain"/> + <div :class="$style.fileBody"> + <div style="margin-bottom: 4px;"> + {{ file.name }} + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + <div v-if="sortModeSelect === 'sizeDesc'"> + <div :class="$style.meter"><div :class="$style.meterValue" :style="genUsageBar(file.size)"></div></div> + </div> + </div> + </div> + </div> + </div> + </MkPagination> + </div> + <div v-else> + <MkLoading/> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue'; +import tinycolor from 'tinycolor2'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import { i18n } from '@/i18n'; +import bytes from '@/filters/bytes'; +import { dateString } from '@/filters/date'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkSelect from '@/components/MkSelect.vue'; +import { getDriveFileMenu } from '@/scripts/get-drive-file-menu'; + +let sortMode = ref('+size'); +const pagination = { + endpoint: 'drive/files' as const, + limit: 10, + params: computed(() => ({ sort: sortMode.value })), +}; + +const sortOptions = [ + { value: 'sizeDesc', displayName: i18n.ts._drivecleaner.orderBySizeDesc }, + { value: 'createdAtAsc', displayName: i18n.ts._drivecleaner.orderByCreatedAtAsc }, +]; + +const capacity = ref<number>(0); +const usage = ref<number>(0); +const fetching = ref(true); +const sortModeSelect = ref('sizeDesc'); + +fetchDriveInfo(); + +watch(sortModeSelect, () => { + switch (sortModeSelect.value) { + case 'sizeDesc': + sortMode.value = '+size'; + fetchDriveInfo(); + break; + + case 'createdAtAsc': + sortMode.value = '-createdAt'; + fetchDriveInfo(); + break; + } +}); + +function fetchDriveInfo(): void { + fetching.value = true; + os.api('drive').then(info => { + capacity.value = info.capacity; + usage.value = info.usage; + fetching.value = false; + }); +} + +function genUsageBar(fsize: number): object { + return { + width: `${fsize / usage.value * 100}%`, + background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }), + }; +} + +function onClick(ev: MouseEvent, file) { + os.popupMenu(getDriveFileMenu(file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); +} + +function onContextMenu(ev: MouseEvent, file): void { + os.contextMenu(getDriveFileMenu(file), ev); +} + +definePageMetadata({ + title: i18n.ts.drivecleaner, + icon: 'ti ti-trash', +}); +</script> + +<style lang="scss" module> +.file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } +} + +.fileThumbnail { + width: 100px; + height: 100px; +} + +.fileBody { + margin-left: 0.3em; + padding: 8px; + flex: 1; +} + +.meter { + margin-top: 8px; + height: 12px; + background: rgba(0, 0, 0, 0.1); + overflow: clip; + border-radius: 999px; +} + +.meterValue { + height: 100%; +} +</style> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index a23bdfe69e..d3fb422e01 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -32,6 +32,9 @@ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> <template #suffixIcon><i class="ti ti-folder"></i></template> </FormLink> + <FormLink to="/settings/drive/cleaner"> + {{ i18n.ts.drivecleaner }} + </FormLink> <MkSwitch v-model="keepOriginalUploading"> <template #label>{{ i18n.ts.keepOriginalUploading }}</template> <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index a5619eab86..ae36466eec 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -7,7 +7,7 @@ <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <div class="baaadecd"> <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> </div> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> @@ -135,6 +135,11 @@ const menuDef = computed(() => [{ to: '/settings/import-export', active: currentPage?.route.name === 'import-export', }, { + icon: 'ti ti-badges', + text: i18n.ts.roles, + to: '/settings/roles', + active: currentPage?.route.name === 'roles', + }, { icon: 'ti ti-planet-off', text: i18n.ts.instanceMute, to: '/settings/instance-mute', @@ -225,6 +230,12 @@ onUnmounted(() => { ro.disconnect(); }); +watch(router.currentRef, (to) => { + if (to.route.name === "settings" && to.child?.route.name == null && !narrow) { + router.replace('/settings/profile'); + } +}); + const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); provideMetadataReceiver((info) => { diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index a08308f0ce..3d0463f708 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -1,26 +1,95 @@ <template> <div class="_gaps_m"> - <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <MkTab v-model="tab"> + <option value="renoteMute">{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</option> <option value="mute">{{ i18n.ts.mutedUsers }}</option> <option value="block">{{ i18n.ts.blockedUsers }}</option> </MkTab> - <div v-if="tab === 'mute'"> - <MkPagination :pagination="mutingPagination" class="muting"> - <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> - <template #default="{items}"> - <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> - <MkAcct :user="mute.mutee"/> - </FormLink> + + <div v-if="tab === 'renoteMute'"> + <MkPagination :pagination="renoteMutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedRenoteMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="`/user-info/${item.mutee.id}`"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleRenoteMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unrenoteMute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedRenoteMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + </div> + </div> + </div> </template> </MkPagination> </div> - <div v-if="tab === 'block'"> - <MkPagination :pagination="blockingPagination" class="blocking"> - <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> - <template #default="{items}"> - <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> - <MkAcct :user="block.blockee"/> - </FormLink> + + <div v-else-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="`/user-info/${item.mutee.id}`"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </div> + + <div v-else-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="`/user-info/${item.blockee.id}`"> + <MkUserCardMini :user="item.blockee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> + <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> + </div> + </div> </template> </MkPagination> </div> @@ -36,8 +105,15 @@ import FormLink from '@/components/form/link.vue'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import * as os from '@/os'; -let tab = $ref('mute'); +let tab = $ref('renoteMute'); + +const renoteMutingPagination = { + endpoint: 'renote-mute/list' as const, + limit: 10, +}; const mutingPagination = { endpoint: 'mute/list' as const, @@ -49,6 +125,67 @@ const blockingPagination = { limit: 10, }; +let expandedRenoteMuteItems = $ref([]); +let expandedMuteItems = $ref([]); +let expandedBlockItems = $ref([]); + +async function unrenoteMute(user, ev) { + os.popupMenu([{ + text: i18n.ts.renoteUnmute, + icon: 'ti ti-x', + action: async () => { + await os.apiWithDialog('renote-mute/delete', { userId: user.id }); + //role.users = role.users.filter(u => u.id !== user.id); + }, + }], ev.currentTarget ?? ev.target); +} + +async function unmute(user, ev) { + os.popupMenu([{ + text: i18n.ts.unmute, + icon: 'ti ti-x', + action: async () => { + await os.apiWithDialog('mute/delete', { userId: user.id }); + //role.users = role.users.filter(u => u.id !== user.id); + }, + }], ev.currentTarget ?? ev.target); +} + +async function unblock(user, ev) { + os.popupMenu([{ + text: i18n.ts.unblock, + icon: 'ti ti-x', + action: async () => { + await os.apiWithDialog('blocking/delete', { userId: user.id }); + //role.users = role.users.filter(u => u.id !== user.id); + }, + }], ev.currentTarget ?? ev.target); +} + +async function toggleRenoteMuteItem(item) { + if (expandedRenoteMuteItems.includes(item.id)) { + expandedRenoteMuteItems = expandedRenoteMuteItems.filter(x => x !== item.id); + } else { + expandedRenoteMuteItems.push(item.id); + } +} + +async function toggleMuteItem(item) { + if (expandedMuteItems.includes(item.id)) { + expandedMuteItems = expandedMuteItems.filter(x => x !== item.id); + } else { + expandedMuteItems.push(item.id); + } +} + +async function toggleBlockItem(item) { + if (expandedBlockItems.includes(item.id)) { + expandedBlockItems = expandedBlockItems.filter(x => x !== item.id); + } else { + expandedBlockItems.push(item.id); + } +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -58,3 +195,43 @@ definePageMetadata({ icon: 'ti ti-ban', }); </script> + +<style lang="scss" module> +.userItemMain { + display: flex; +} + +.userItemSub { + padding: 6px 12px; + font-size: 85%; + color: var(--fgTransparentWeak); +} + +.userItemMainBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} + +.userToggle, +.remove { + width: 32px; + height: 32px; + align-self: center; +} + +.chevron { + display: block; + transition: transform 0.1s ease-out; +} + +.userItem.userItemOpend { + .chevron { + transform: rotateX(180deg); + } +} +</style> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index ead551e7c4..b3b33b8026 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -1,9 +1,34 @@ <template> <div class="_gaps_m"> - <MkTextarea v-model="items" tall manual-save> + <FormSlot> <template #label>{{ i18n.ts.navbar }}</template> - <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template> - </MkTextarea> + <MkContainer :show-header="false"> + <Sortable + v-model="items" + item-key="id" + :animation="150" + :handle="'.' + $style.itemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element,index}"> + <div + v-if="element.type === '-' || navbarItemDef[element.type]" + :class="$style.item" + > + <button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span> + <button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button> + </div> + </template> + </Sortable> + </MkContainer> + </FormSlot> + <div class="_buttons"> + <MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton> + <MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> + <MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + </div> <MkRadios v-model="menuDisplay"> <template #label>{{ i18n.ts.display }}</template> @@ -12,26 +37,30 @@ <option value="top">{{ i18n.ts._menuDisplay.top }}</option> <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> </MkRadios> - - <MkButton danger @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> </div> </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import MkTextarea from '@/components/MkTextarea.vue'; +import { computed, defineAsyncComponent, ref, watch } from 'vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; +import FormSlot from '@/components/form/slot.vue'; +import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; import { defaultStore } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { deepClone } from '@/scripts/clone'; + +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -const items = ref(defaultStore.state.menu.join('\n')); +const items = ref(defaultStore.state.menu.map(x => ({ + id: Math.random().toString(), + type: x, +}))); -const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== '')); const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); async function reloadAsk() { @@ -55,23 +84,28 @@ async function addItem() { }], }); if (canceled) return; - items.value = [...split.value, item].join('\n'); + items.value = [...items.value, { + id: Math.random().toString(), + type: item, + }]; +} + +function removeItem(index: number) { + items.value.splice(index, 1); } async function save() { - defaultStore.set('menu', split.value); + defaultStore.set('menu', items.value.map(x => x.type)); await reloadAsk(); } function reset() { - defaultStore.reset('menu'); - items.value = defaultStore.state.menu.join('\n'); + items.value = defaultStore.def.menu.default.map(x => ({ + id: Math.random().toString(), + type: x, + })); } -watch(items, async () => { - await save(); -}); - watch(menuDisplay, async () => { await reloadAsk(); }); @@ -85,3 +119,44 @@ definePageMetadata({ icon: 'ti ti-list', }); </script> + +<style lang="scss" module> +.item { + position: relative; + display: block; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: var(--navFg); +} + +.itemIcon { + position: relative; + width: 32px; + margin-right: 8px; +} + +.itemText { + position: relative; + font-size: 0.9em; +} + +.itemRemove { + position: absolute; + z-index: 10000; + width: 32px; + height: 32px; + color: #ff2a2a; + right: 8px; + opacity: 0.8; +} + +.itemHandle { + cursor: move; + width: 32px; + height: 32px; + margin: 0 8px; + opacity: 0.5; +} +</style> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index f23a338179..98063d6ff8 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -49,7 +49,7 @@ async function install() { text: 'No language version annotation found :(', }); return; - } else if (!lv.startsWith('0.12.')) { + } else if (!(lv.startsWith('0.12.') || lv.startsWith('0.13.'))) { os.alert({ type: 'error', text: `aiscript version '${lv}' is not supported :(`, diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 41563c441f..a5f6c11f89 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -64,12 +64,19 @@ </div> </MkFolder> + <MkSelect v-model="reactionAcceptance"> + <template #label>{{ i18n.ts.reactionAcceptance }}</template> + <option :value="null">{{ i18n.ts.all }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> + <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + </MkSelect> + <MkSwitch v-model="profile.showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> </div> </template> <script lang="ts" setup> -import { reactive, watch } from 'vue'; +import { computed, reactive, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -85,6 +92,9 @@ import { $i } from '@/account'; import { langmap } from '@/scripts/langmap'; import { definePageMetadata } from '@/scripts/page-metadata'; import { claimAchievement } from '@/scripts/achievements'; +import { defaultStore } from '@/store'; + +const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); const profile = reactive({ name: $i.name, @@ -124,11 +134,17 @@ function saveFields() { function save() { os.apiWithDialog('i/update', { - name: profile.name ?? null, - description: profile.description ?? null, - location: profile.location ?? null, - birthday: profile.birthday ?? null, - lang: profile.lang ?? null, + // 空文字列をnullにしたいので??は使うな + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: profile.name || null, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: profile.description || null, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + location: profile.location || null, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + birthday: profile.birthday || null, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + lang: profile.lang || null, isBot: !!profile.isBot, isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue new file mode 100644 index 0000000000..ba510dced3 --- /dev/null +++ b/packages/frontend/src/pages/settings/roles.vue @@ -0,0 +1,56 @@ +<template> +<div class="_gaps_m"> + <FormSection first> + <template #label>{{ i18n.ts.rolesAssignedToMe }}</template> + <div class="_gaps_s"> + <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :for-moderation="false"/> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._role.policies }}</template> + <div class="_gaps_s"> + <div v-for="policy in Object.keys($i.policies)" :key="policy"> + {{ policy }} ... {{ $i.policies[policy] }} + </div> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import FormSplit from '@/components/form/split.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import FormSlot from '@/components/form/slot.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { defaultStore } from '@/store'; +import MkRolePreview from '@/components/MkRolePreview.vue'; + +function save() { + os.apiWithDialog('i/update', { + + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.roles, + icon: 'ti ti-badges', +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 373af193d7..571f058240 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -262,14 +262,21 @@ async function updateRemoteUser() { } async function resetPassword() { - const { password } = await os.api('admin/reset-password', { - userId: user.id, - }); - - os.alert({ - type: 'success', - text: i18n.t('newPasswordIs', { password }), + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.resetPasswordConfirm, }); + if (confirm.canceled) { + return; + } else { + const { password } = await os.api('admin/reset-password', { + userId: user.id, + }); + os.alert({ + type: 'success', + text: i18n.t('newPasswordIs', { password }), + }); + } } async function toggleSuspend(v) { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 02794175ae..7efaaebf5d 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -7,7 +7,7 @@ <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> <div class="profile _gaps"> - <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <div :key="user.id" class="main _panel"> <div class="banner-container" :style="style"> @@ -100,7 +100,7 @@ <XPhotos :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/> </template> - <MkNotes :class="$style.tl" :no-gap="true" :pagination="pagination"/> + <MkNotes v-if="!disableNotes" :class="$style.tl" :no-gap="true" :pagination="pagination"/> </div> </div> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> @@ -137,7 +137,10 @@ const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const props = withDefaults(defineProps<{ user: misskey.entities.UserDetailed; + /** Test only; MkNotes currently causes problems in vitest */ + disableNotes: boolean; }>(), { + disableNotes: false, }); const router = useRouter(); diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue index 607082c1e4..85f6591eee 100644 --- a/packages/frontend/src/pages/user/index.photos.vue +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -41,7 +41,7 @@ let images = $ref<{ function thumbnail(image: misskey.entities.DriveFile): string { return defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(image.thumbnailUrl) + ? getStaticImageUrl(image.url) : image.thumbnailUrl; } diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index a1a36480fd..9b6b01780c 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -1,7 +1,7 @@ import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import { inputText } from '@/os'; -import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; +import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store'; const parser = new Parser(); const pluginContexts = new Map<string, Interpreter>(); @@ -80,6 +80,9 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); }), + 'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => { + registerPageViewInterruptor({ pluginId: opts.plugin.id, handler }); + }), 'Plugin:open_url': values.FN_NATIVE(([url]) => { utils.assertString(url); window.open(url.value, '_blank'); @@ -156,3 +159,15 @@ function registerNotePostInterruptor({ pluginId, handler }): void { }, }); } + +function registerPageViewInterruptor({ pluginId, handler }): void { + pageViewInterruptors.push({ + handler: async (page) => { + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(page)])); + }, + }); +} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 70576688b1..c8077edd28 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -50,6 +50,10 @@ export const routes = [{ name: 'profile', component: page(() => import('./pages/settings/profile.vue')), }, { + path: '/roles', + name: 'roles', + component: page(() => import('./pages/settings/roles.vue')), + }, { path: '/privacy', name: 'privacy', component: page(() => import('./pages/settings/privacy.vue')), @@ -62,6 +66,10 @@ export const routes = [{ name: 'drive', component: page(() => import('./pages/settings/drive.vue')), }, { + path: '/drive/cleaner', + name: 'drive', + component: page(() => import('./pages/settings/drive-cleaner.vue')), + }, { path: '/notifications', name: 'notifications', component: page(() => import('./pages/settings/notifications.vue')), @@ -194,6 +202,9 @@ export const routes = [{ path: '/about-misskey', component: page(() => import('./pages/about-misskey.vue')), }, { + path: '/ads', + component: page(() => import('./pages/ads.vue')), +}, { path: '/theme-editor', component: page(() => import('./pages/theme-editor.vue')), loginRequired: true, @@ -381,6 +392,10 @@ export const routes = [{ name: 'settings', component: page(() => import('./pages/admin/settings.vue')), }, { + path: '/moderation', + name: 'moderation', + component: page(() => import('./pages/admin/moderation.vue')), + }, { path: '/email-settings', name: 'email-settings', component: page(() => import('./pages/admin/email-settings.vue')), diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts index 5f34f5333e..97081d170f 100644 --- a/packages/frontend/src/scripts/api.ts +++ b/packages/frontend/src/scripts/api.ts @@ -5,7 +5,7 @@ import { $i } from '@/account'; export const pendingApiRequestsCount = ref(0); // Implements Misskey.api.ApiClient.request -export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any, token?: string | null | undefined): Promise<Endpoints[E]['res']> { +export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal): Promise<Endpoints[E]['res']> { pendingApiRequestsCount.value++; const onFinally = () => { @@ -26,6 +26,7 @@ export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(en headers: { 'Content-Type': 'application/json', }, + signal, }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts new file mode 100644 index 0000000000..56ab516038 --- /dev/null +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -0,0 +1,93 @@ +import * as Misskey from 'misskey-js'; +import { defineAsyncComponent } from 'vue'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; + +function rename(file: Misskey.entities.DriveFile) { + os.inputText({ + title: i18n.ts.renameFile, + placeholder: i18n.ts.inputNewFileName, + default: file.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/files/update', { + fileId: file.id, + name: name, + }); + }); +} + +function describe(file: Misskey.entities.DriveFile) { + os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + default: file.comment != null ? file.comment : '', + file: file, + }, { + done: caption => { + os.api('drive/files/update', { + fileId: file.id, + comment: caption.length === 0 ? null : caption, + }); + }, + }, 'closed'); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +function copyUrl(file: Misskey.entities.DriveFile) { + copyToClipboard(file.url); + os.success(); +} +/* +function addApp() { + alert('not implemented yet'); +} +*/ +async function deleteFile(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('driveFileDeleteConfirm', { name: file.name }), + }); + + if (canceled) return; + os.api('drive/files/delete', { + fileId: file.id, + }); +} + +export function getDriveFileMenu(file: Misskey.entities.DriveFile) { + return [{ + text: i18n.ts.rename, + icon: 'ti ti-forms', + action: rename, + }, { + text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off', + action: toggleSensitive, + }, { + text: i18n.ts.describeFile, + icon: 'ti ti-text-caption', + action: describe, + }, null, { + text: i18n.ts.copyUrl, + icon: 'ti ti-link', + action: copyUrl, + }, { + type: 'a', + href: file.url, + target: '_blank', + text: i18n.ts.download, + icon: 'ti ti-download', + download: file.name, + }, null, { + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: deleteFile, + }]; +} diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 5170ca4c8c..d7eb331183 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -53,6 +53,14 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router } } + async function toggleRenoteMute() { + os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', { + userId: user.id, + }).then(() => { + user.isRenoteMuted = !user.isRenoteMuted; + }); + } + async function toggleBlock() { if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; @@ -111,7 +119,7 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router icon: 'ti ti-mail', text: i18n.ts.sendMessage, action: () => { - os.post({ specified: user }); + os.post({ specified: user, initialText: `@${user.username} ` }); }, }, null, { type: 'parent', @@ -180,6 +188,10 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, }, { + icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off', + text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute, + action: toggleRenoteMute, + }, { icon: 'ti ti-ban', text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, action: toggleBlock, diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts new file mode 100644 index 0000000000..ce5b03fc38 --- /dev/null +++ b/packages/frontend/src/scripts/lookup.ts @@ -0,0 +1,41 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { Router } from '@/nirax'; + +export async function lookup(router?: Router) { + const _router = router ?? mainRouter; + + const { canceled, result: query } = await os.inputText({ + title: i18n.ts.lookup, + }); + if (canceled) return; + + if (query.startsWith('@') && !query.includes(' ')) { + _router.push(`/${query}`); + return; + } + + if (query.startsWith('#')) { + _router.push(`/tags/${encodeURIComponent(query.substr(1))}`); + return; + } + + if (query.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + _router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + _router.push(`/notes/${res.object.id}`); + } + + return; + } +} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 2fe5bdcf8f..91ac14c06d 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -10,7 +10,10 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigi imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; } - return `${mustOrigin ? localProxy : instance.mediaProxy}/image.webp?${query({ + return `${mustOrigin ? localProxy : instance.mediaProxy}/${ + type === 'preview' ? 'preview.webp' + : 'image.webp' + }?${query({ url: imageUrl, fallback: '1', ...(type ? { [type]: '1' } : {}), diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index b08982facb..35fd007e64 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -72,8 +72,8 @@ export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioEle return audio; } -export function play(type: string) { - const sound = ColdDeviceStorage.get('sound_' + type as any); +export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') { + const sound = ColdDeviceStorage.get(`sound_${type}`); if (sound.type == null) return; playFile(sound.type, sound.volume); } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 2766b434fc..3d87234f41 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -24,11 +24,16 @@ interface NotePostInterruptor { handler: (note: FIXME) => unknown; } +interface PageViewInterruptor { + handler: (page: Page) => unknown; +} + export const postFormActions: PostFormAction[] = []; export const userActions: UserAction[] = []; export const noteActions: NoteAction[] = []; export const noteViewInterruptors: NoteViewInterruptor[] = []; export const notePostInterruptors: NotePostInterruptor[] = []; +export const pageViewInterruptors: PageViewInterruptor[] = []; // TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) // あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない @@ -81,6 +86,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, + reactionAcceptance: { + where: 'account', + default: null, + }, mutedWords: { where: 'account', default: [], @@ -314,7 +323,7 @@ interface Watcher { import { miLocalStorage } from './local-storage'; import lightTheme from '@/themes/l-light.json5'; import darkTheme from '@/themes/d-green-lime.json5'; -import { Note, UserDetailed } from 'misskey-js/built/entities'; +import { Note, UserDetailed, Page } from 'misskey-js/built/entities'; export class ColdDeviceStorage { public static default = { @@ -324,8 +333,8 @@ export class ColdDeviceStorage { plugins: [] as Plugin[], mediaVolume: 0.5, sound_masterVolume: 0.5, - sound_note: { type: 'syuilo/n-aec', volume: 0.5 }, - sound_noteMy: { type: 'syuilo/n-cea', volume: 0.5 }, + sound_note: { type: 'syuilo/n-eca', volume: 0.5 }, + sound_noteMy: { type: 'syuilo/n-cea-4va', volume: 0.5 }, sound_notification: { type: 'syuilo/n-ea', volume: 0.5 }, sound_chat: { type: 'syuilo/pope1', volume: 0.5 }, sound_chatBg: { type: 'syuilo/waon', volume: 0.5 }, diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index a90ec6172f..eae4f0091c 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -29,6 +29,11 @@ export function openInstanceMenu(ev: MouseEvent) { icon: 'ti ti-chart-line', to: '/about#charts', }, null, { + type: 'link', + text: i18n.ts.ads, + icon: 'ti ti-ad', + to: '/ads', + }, { type: 'parent', text: i18n.ts.tools, icon: 'ti ti-tool', diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index a947e27e57..c23943d4db 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -8,12 +8,12 @@ <span style="margin-left: 8px;">{{ column.name }}</span> </template> - <div v-if="disabled" :class="$style.disabled"> + <div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> <p :class="$style.disabledTitle"> - <i class="ti ti-minus-circle"></i> - {{ $t('disabled-timeline.title') }} + <i class="ti ti-circle-minus"></i> + {{ i18n.ts._disabledTimeline.title }} </p> - <p :class="$style.disabledDescription">{{ $t('disabled-timeline.description') }}</p> + <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> </div> <MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')"/> </XColumn> @@ -27,6 +27,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { instance } from '@/instance'; const props = defineProps<{ column: Column; @@ -40,11 +41,16 @@ const emit = defineEmits<{ let disabled = $ref(false); +const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); +const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); + onMounted(() => { if (props.column.tl == null) { setType(); } else if ($i) { - disabled = false; // TODO + disabled = ( + (!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) || + (!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl))); } }); diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index 7acf2140cf..e7f8819abd 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity data-cy-mkw-activity"> +<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity"> <template #icon><i class="ti ti-chart-line"></i></template> <template #header>{{ i18n.ts._widgets.activity }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template> diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index cb055a56f6..37326ee981 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-aichan data-cy-mkw-aichan"> +<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-aichan class="mkw-aichan"> <iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe> </MkContainer> </template> diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index 33218a110b..947dbe5e77 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript data-cy-mkw-aiscript"> +<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript"> <template #icon><i class="ti ti-terminal-2"></i></template> <template #header>{{ i18n.ts._widgets.aiscript }}</template> diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index 462f1e5a5d..9eee9680db 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -1,5 +1,5 @@ <template> -<div class="mkw-button data-cy-mkw-button"> +<div data-cy-mkw-button class="mkw-button"> <MkButton :primary="widgetProps.colored" full @click="run"> {{ widgetProps.label }} </MkButton> diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index 083d8588af..de2e4b179d 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -1,5 +1,5 @@ <template> -<div :class="[$style.root, { _panel: !widgetProps.transparent }]" class="data-cy-mkw-calendar"> +<div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-cy-mkw-calendar> <div :class="[$style.calendar, { [$style.isHoliday]: isHoliday }]"> <p :class="$style.monthAndYear"> <span :class="$style.year">{{ $t('yearX', { year }) }}</span> diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index ecbb03b570..ebd73cb9f5 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock data-cy-mkw-clock"> +<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-clock class="mkw-clock"> <div class="vubelbmv" :class="widgetProps.size"> <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div> <MkAnalogClock diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index 1780a1c8d2..cdd9c3a401 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -1,5 +1,5 @@ <template> -<div class="data-cy-mkw-digitalClock _monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }"> +<div data-cy-mkw-digitalClock class="_monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }"> <div v-if="widgetProps.showLabel" :class="$style.label">{{ tzAbbrev }}</div> <div> <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/> diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index a8095acf65..7dcd5cb42e 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation data-cy-mkw-federation"> +<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" data-cy-mkw-federation class="mkw-federation"> <template #icon><i class="ti ti-whirl"></i></template> <template #header>{{ i18n.ts._widgets.federation }}</template> diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 69912e21f7..84043cf13f 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -1,5 +1,5 @@ <template> -<div class="mkw-jobQueue data-cy-mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }"> +<div data-cy-mkw-jobQueue class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }"> <div class="inbox"> <div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="ti ti-alert-triangle icon"></i></div> <div class="values"> diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 149d20af47..959cf776ad 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -1,10 +1,10 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-memo data-cy-mkw-memo"> +<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-memo class="mkw-memo"> <template #icon><i class="ti ti-note"></i></template> <template #header>{{ i18n.ts._widgets.memo }}</template> <div :class="$style.root"> - <textarea v-model="text" :class="$style.textarea" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea> + <textarea v-model="text" :style="`height: ${widgetProps.height}px;`" :class="$style.textarea" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea> <button :class="$style.save" :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button> </div> </MkContainer> @@ -25,6 +25,10 @@ const widgetPropsDef = { type: 'boolean' as const, default: true, }, + height: { + type: 'number' as const, + default: 100, + }, }; type WidgetProps = GetFormResultType<typeof widgetPropsDef>; diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index 63400b09d0..661f68b278 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications data-cy-mkw-notifications"> +<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" data-cy-mkw-notifications class="mkw-notifications"> <template #icon><i class="ti ti-bell"></i></template> <template #header>{{ i18n.ts.notifications }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 7949fc4a93..44e073545d 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -1,5 +1,5 @@ <template> -<div class="mkw-onlineUsers data-cy-mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }"> +<div data-cy-mkw-onlineUsers class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }"> <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text"> <template #n><b>{{ number(onlineUsersCount) }}</b></template> </I18n> diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index 8746ababbb..716bbb4274 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos data-cy-mkw-photos"> +<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" data-cy-mkw-photos class="mkw-photos"> <template #icon><i class="ti ti-camera"></i></template> <template #header>{{ i18n.ts._widgets.photos }}</template> @@ -67,7 +67,7 @@ const onDriveFileCreated = (file) => { const thumbnail = (image: any): string => { return defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(image.thumbnailUrl) + ? getStaticImageUrl(image.url) : image.thumbnailUrl; }; diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue index 9953bca65f..7a96b00217 100644 --- a/packages/frontend/src/widgets/WidgetPostForm.vue +++ b/packages/frontend/src/widgets/WidgetPostForm.vue @@ -1,5 +1,5 @@ <template> -<MkPostForm class="_panel mkw-post-form data-cy-mkw-postForm" :fixed="true" :autofocus="false"/> +<MkPostForm data-cy-mkw-postForm class="_panel mkw-post-form" :fixed="true" :autofocus="false"/> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 965bb89153..18fa2e2c22 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-rss data-cy-mkw-rss"> +<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-rss class="mkw-rss"> <template #icon><i class="ti ti-rss"></i></template> <template #header>RSS</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure"><i class="ti ti-settings"></i></button></template> diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index ffb77b281a..22a0024271 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -1,5 +1,5 @@ <template> -<div class="kvausudm _panel mkw-slideshow data-cy-mkw-slideshow" :style="{ height: widgetProps.height + 'px' }"> +<div data-cy-mkw-slideshow class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }"> <div @click="choose"> <p v-if="widgetProps.folderId == null"> {{ i18n.ts.folder }} diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index d6be6532a6..0f6f25b0a9 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" class="mkw-timeline data-cy-mkw-timeline"> +<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" data-cy-mkw-timeline class="mkw-timeline"> <template #icon> <i v-if="widgetProps.src === 'home'" class="ti ti-home"></i> <i v-else-if="widgetProps.src === 'local'" class="ti ti-planet"></i> @@ -15,7 +15,14 @@ </button> </template> - <div> + <div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> + <p :class="$style.disabledTitle"> + <i class="ti ti-minus"></i> + {{ i18n.ts._disabledTimeline.title }} + </p> + <p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> + </div> + <div v-else> <MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> </div> </MkContainer> @@ -29,8 +36,12 @@ import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { instance } from '@/instance'; const name = 'timeline'; +const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); +const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); const widgetPropsDef = { showHeader: { @@ -128,3 +139,17 @@ defineExpose<WidgetComponentExpose>({ id: props.widget ? props.widget.id : null, }); </script> + +<style lang="scss" module> +.disabled { + text-align: center; +} + +.disabledTitle { + margin: 16px; +} + +.disabledDescription { + font-size: 90%; +} +</style> diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 1423ae076c..fc8a310ece 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -1,5 +1,5 @@ <template> -<MkContainer :show-header="widgetProps.showHeader" class="mkw-trends data-cy-mkw-trends"> +<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-trends class="mkw-trends"> <template #icon><i class="ti ti-hash"></i></template> <template #header>{{ i18n.ts._widgets.trends }}</template> diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index 72c88d9a00..357d0ab78b 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -4,7 +4,7 @@ <template #header>{{ i18n.ts._widgets.serverMetric }}</template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template> - <div v-if="meta" class="mkw-serverMetric data-cy-mkw-serverMetric"> + <div v-if="meta" data-cy-mkw-serverMetric class="mkw-serverMetric"> <XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/> <XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/> <XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/> |