diff options
Diffstat (limited to 'packages/frontend')
28 files changed, 752 insertions, 255 deletions
diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts new file mode 100644 index 0000000000..c95da64bba --- /dev/null +++ b/packages/frontend/src/cache.ts @@ -0,0 +1,6 @@ +import * as misskey from 'misskey-js'; +import { Cache } from '@/scripts/cache'; + +export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity); +export const rolesCache = new Cache(Infinity); +export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity); 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/MkNote.vue b/packages/frontend/src/components/MkNote.vue index af81051a54..72c6e55df1 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -109,6 +109,9 @@ <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> + <i class="ti ti-paperclip"></i> + </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> <i class="ti ti-dots"></i> </button> @@ -151,7 +154,7 @@ import { reactionPicker } from '@/scripts/reaction-picker'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { $i } from '@/account'; import { i18n } from '@/i18n'; -import { getNoteMenu } from '@/scripts/get-note-menu'; +import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; @@ -192,6 +195,7 @@ const menuButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>(); +const clipButton = shallowRef<HTMLElement>(); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); @@ -392,6 +396,10 @@ function menu(viaKeyboard = false): void { }).then(focus); } +async function clip() { + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClipPage }), clipButton.value).then(focus); +} + function showRenoteMenu(viaKeyboard = false): void { if (!isMyRenote) return; os.popupMenu([{ diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index ea72e1b517..715fd3a9a8 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -114,6 +114,9 @@ <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()"> + <i class="ti ti-paperclip"></i> + </button> <button ref="menuButton" class="button _button" @mousedown="menu()"> <i class="ti ti-dots"></i> </button> @@ -156,7 +159,7 @@ import { reactionPicker } from '@/scripts/reaction-picker'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { $i } from '@/account'; import { i18n } from '@/i18n'; -import { getNoteMenu } from '@/scripts/get-note-menu'; +import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; @@ -196,6 +199,7 @@ const menuButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>(); +const clipButton = shallowRef<HTMLElement>(); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); @@ -384,6 +388,10 @@ function menu(viaKeyboard = false): void { }).then(focus); } +async function clip() { + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus); +} + function showRenoteMenu(viaKeyboard = false): void { if (!isMyRenote) return; os.popupMenu([{ diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 7fb830d537..814ab53d27 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,25 +1,26 @@ <template> -<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"> +<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" 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"> - <div :class="$style.earLeft"/> - <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]: 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"> - <div :class="$style.earLeft"/> - <div :class="$style.earRight"/> - </template> -</MkA> + <div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]"> + <div :class="$style.earLeft"> + <div v-if="useBlurEffect" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + </div> + </div> + <div :class="$style.earRight"> + <div v-if="useBlurEffect" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + </div> + </div> + </div> +</component> </template> <script lang="ts" setup> import { watch } from 'vue'; import * as misskey from 'misskey-js'; +import MkA from './MkA.vue'; import { getStaticImageUrl } from '@/scripts/media-proxy'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import { acct, userPage } from '@/filters/user'; @@ -27,6 +28,7 @@ import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store'; const squareAvatars = $ref(defaultStore.state.squareAvatars); +const useBlurEffect = $ref(defaultStore.state.useBlurEffect); const props = withDefaults(defineProps<{ user: misskey.entities.User; @@ -45,15 +47,20 @@ const emit = defineEmits<{ (ev: 'click', v: MouseEvent): void; }>(); +const bound = $computed(() => props.link + ? { to: userPage(props.user), target: props.target } + : {}); + const url = $computed(() => defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(props.user.avatarUrl) : props.user.avatarUrl); -function onClick(ev: MouseEvent) { +function onClick(ev: MouseEvent): void { + if (props.link) return; emit('click', ev); } -let color = $ref(); +let color = $ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { color = extractAvgColorFromBlurhash(props.user.avatarBlurhash); @@ -120,42 +127,113 @@ watch(() => props.user.avatarBlurhash, () => { } .cat { - > .earLeft, - > .earRight { + > .ears { contain: strict; - display: inline-block; - height: 50%; - width: 50%; - background: currentColor; + position: absolute; + top: -50%; + left: -50%; + width: 100%; + height: 100%; + padding: 50%; - &::before { - contain: strict; - content: ''; - display: block; - width: 60%; - height: 60%; - margin: 20%; - background: #df548f; + &.mask { + -webkit-mask: + url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') center / 50% 50%, + linear-gradient(#fff, #fff); + -webkit-mask-composite: destination-out, source-over; + mask: + url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%, + linear-gradient(#fff, #fff); // polyfill of `image(#fff)` } - } - > .earLeft { - border-radius: 0 75% 75%; - transform: rotate(37.5deg) skew(30deg); - } + > .earLeft, + > .earRight { + contain: strict; + display: inline-block; + height: 50%; + width: 50%; + background: currentColor; - > .earRight { - border-radius: 75% 0 75% 75%; - transform: rotate(-37.5deg) skew(-30deg); - } + &::after { + contain: strict; + content: ''; + display: block; + width: 60%; + height: 60%; + margin: 20%; + background: #df548f; + } + + > .layer { + contain: strict; + position: absolute; + top: 0; + width: 280%; + height: 280%; + + > .plot { + contain: strict; + width: 100%; + height: 100%; + clip-path: path('M0 0H1V1H0z'); + transform: scale(32767); + transform-origin: 0 0; + } + } + } - &:hover { > .earLeft { - animation: earwiggleleft 1s infinite; + transform: rotate(37.5deg) skew(30deg); + + &, &::after { + border-radius: 0 75% 75%; + } + + > .layer { + left: 0; + transform: + skew(-30deg) + rotate(-37.5deg) + translate(-2.82842712475%, /* -2 * sqrt(2) */ + -38.5857864376%); /* 40 - 2 * sqrt(2) */ + + > .plot { + background-position: 20% 10%; /* ~= 37.5deg */ + } + } } > .earRight { - animation: earwiggleright 1s infinite; + transform: rotate(-37.5deg) skew(-30deg); + + &, &::after { + border-radius: 75% 0 75% 75%; + } + + > .layer { + right: 0; + transform: + skew(30deg) + rotate(37.5deg) + translate(2.82842712475%, /* 2 * sqrt(2) */ + -38.5857864376%); /* 40 - 2 * sqrt(2) */ + + > .plot { + background-position: 80% 10%; /* ~= 37.5deg */ + } + } + } + } + + &:hover { + > .ears { + > .earLeft { + animation: earwiggleleft 1s infinite; + } + + > .earRight { + animation: earwiggleright 1s infinite; + } } } } diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 07729b8cf9..343d2c4c5c 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -10,6 +10,8 @@ <option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option> <option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option> <option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option> + <option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option> + <option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option> <option value="and">{{ i18n.ts._role._condition.and }}</option> <option value="or">{{ i18n.ts._role._condition.or }}</option> <option value="not">{{ i18n.ts._role._condition.not }}</option> @@ -42,7 +44,7 @@ <template #suffix>sec</template> </MkInput> - <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> + <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> </MkInput> </div> </template> @@ -91,6 +93,8 @@ const type = computed({ if (t === 'followersMoreThanOrEq') v.value.value = 10; if (t === 'followingLessThanOrEq') v.value.value = 10; if (t === 'followingMoreThanOrEq') v.value.value = 10; + if (t === 'notesLessThanOrEq') v.value.value = 10; + if (t === 'notesMoreThanOrEq') v.value.value = 10; v.value.type = t; }, }); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 7c2f04a9ab..ebe1a8ade0 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -46,7 +46,7 @@ let sensitiveWords: string = $ref(''); async function init() { const meta = await os.api('admin/meta'); - sensitiveWords = meta.pinnedUsers.join('\n'); + sensitiveWords = meta.sensitiveWords.join('\n'); } function save() { 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 e6896237f8..b1aa03f1f7 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -26,6 +26,7 @@ import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { useRouter } from '@/router'; import MkButton from '@/components/MkButton.vue'; +import { rolesCache } from '@/cache'; const router = useRouter(); @@ -61,6 +62,7 @@ if (props.id) { } async function save() { + rolesCache.delete(); if (role) { os.apiWithDialog('admin/roles/update', { roleId: role.id, diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 12f341c01d..65e64930d5 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -43,6 +43,14 @@ <MkSwitch v-model="emailRequiredForSignup"> <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> </MkSwitch> + + <MkSwitch v-model="enableChartsForRemoteUser"> + <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> + </MkSwitch> + + <MkSwitch v-model="enableChartsForFederatedInstances"> + <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template> + </MkSwitch> </div> </FormSection> @@ -175,6 +183,8 @@ let cacheRemoteFiles: boolean = $ref(false); let enableRegistration: boolean = $ref(false); let emailRequiredForSignup: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); +let enableChartsForRemoteUser: boolean = $ref(false); +let enableChartsForFederatedInstances: boolean = $ref(false); let swPublicKey: any = $ref(null); let swPrivateKey: any = $ref(null); let deeplAuthKey: string = $ref(''); @@ -198,6 +208,8 @@ async function init() { enableRegistration = !meta.disableRegistration; emailRequiredForSignup = meta.emailRequiredForSignup; enableServiceWorker = meta.enableServiceWorker; + enableChartsForRemoteUser = meta.enableChartsForRemoteUser; + enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; swPublicKey = meta.swPublickey; swPrivateKey = meta.swPrivateKey; deeplAuthKey = meta.deeplAuthKey; @@ -222,6 +234,8 @@ function save() { disableRegistration: !enableRegistration, emailRequiredForSignup, enableServiceWorker, + enableChartsForRemoteUser, + enableChartsForFederatedInstances, swPublicKey, swPrivateKey, deeplAuthKey, diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 7515a9122a..2b64de088a 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -30,6 +30,7 @@ import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { url } from '@/config'; import MkButton from '@/components/MkButton.vue'; +import { clipsCache } from '@/cache'; const props = defineProps<{ clipId: string, @@ -108,6 +109,8 @@ const headerActions = $computed(() => clip && isOwned ? [{ clipId: clip.id, ...result, }); + + clipsCache.delete(); }, }, ...(clip.isPublic ? [{ icon: 'ti ti-share', @@ -133,6 +136,8 @@ const headerActions = $computed(() => clip && isOwned ? [{ await os.apiWithDialog('clips/delete', { clipId: clip.id, }); + + clipsCache.delete(); }, }] : null); diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 4c23985f3b..aad914d6bb 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -28,6 +28,7 @@ import MkClipPreview from '@/components/MkClipPreview.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { clipsCache } from '@/cache'; const pagination = { endpoint: 'clips/list' as const, @@ -65,6 +66,8 @@ async function create() { os.apiWithDialog('clips/create', result); + clipsCache.delete(); + pagingComponent.reload(); } diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index 8a96b54881..11a2aca8c5 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -24,6 +24,7 @@ import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { userListsCache } from '@/cache'; const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); @@ -38,6 +39,7 @@ async function create() { }); if (canceled) return; await os.apiWithDialog('users/lists/create', { name: name }); + userListsCache.delete(); pagingComponent.reload(); } diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 205434971d..768a48746c 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -37,6 +37,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { userPage } from '@/filters/user'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userListsCache } from '@/cache'; const props = defineProps<{ listId: string; @@ -97,6 +98,8 @@ async function renameList() { name: name, }); + userListsCache.delete(); + list.name = name; } @@ -107,10 +110,10 @@ async function deleteList() { }); if (canceled) return; - await os.api('users/lists/delete', { + await os.apiWithDialog('users/lists/delete', { listId: list.id, }); - os.success(); + userListsCache.delete(); mainRouter.push('/my/lists'); } 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/general.vue b/packages/frontend/src/pages/settings/general.vue index 2e2c456c07..dd62a32530 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -47,6 +47,7 @@ <div class="_gaps_m"> <div class="_gaps_s"> <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> + <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> @@ -143,6 +144,7 @@ async function reloadAsk() { const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); +const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); 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/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index a01e3f8cee..3c782973ae 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -1,7 +1,7 @@ <template> <div class="_gaps_m"> <MkInput v-model="name"> - <template #label>Name</template> + <template #label>{{ i18n.ts._webhookSettings.name }}</template> </MkInput> <MkInput v-model="url" type="url"> @@ -10,24 +10,24 @@ <MkInput v-model="secret"> <template #prefix><i class="ti ti-lock"></i></template> - <template #label>Secret</template> + <template #label>{{ i18n.ts._webhookSettings.secret }}</template> </MkInput> <FormSection> - <template #label>Events</template> + <template #label>{{ i18n.ts._webhookSettings.events }}</template> <div class="_gaps_s"> - <MkSwitch v-model="event_follow">Follow</MkSwitch> - <MkSwitch v-model="event_followed">Followed</MkSwitch> - <MkSwitch v-model="event_note">Note</MkSwitch> - <MkSwitch v-model="event_reply">Reply</MkSwitch> - <MkSwitch v-model="event_renote">Renote</MkSwitch> - <MkSwitch v-model="event_reaction">Reaction</MkSwitch> - <MkSwitch v-model="event_mention">Mention</MkSwitch> + <MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch> + <MkSwitch v-model="event_followed">{{ i18n.ts._webhookSettings._events.followed }}</MkSwitch> + <MkSwitch v-model="event_note">{{ i18n.ts._webhookSettings._events.note }}</MkSwitch> + <MkSwitch v-model="event_reply">{{ i18n.ts._webhookSettings._events.reply }}</MkSwitch> + <MkSwitch v-model="event_renote">{{ i18n.ts._webhookSettings._events.renote }}</MkSwitch> + <MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> + <MkSwitch v-model="event_mention">{{ i18n.ts._webhookSettings._events.mention }}</MkSwitch> </div> </FormSection> - <MkSwitch v-model="active">Active</MkSwitch> + <MkSwitch v-model="active">{{ i18n.ts._webhookSettings.active }}</MkSwitch> <div class="_buttons"> <MkButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 45ab5722c3..6eb8a654f5 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -1,7 +1,7 @@ <template> <div class="_gaps_m"> <MkInput v-model="name"> - <template #label>Name</template> + <template #label>{{ i18n.ts._webhookSettings.name }}</template> </MkInput> <MkInput v-model="url" type="url"> @@ -10,20 +10,20 @@ <MkInput v-model="secret"> <template #prefix><i class="ti ti-lock"></i></template> - <template #label>Secret</template> + <template #label>{{ i18n.ts._webhookSettings.secret }}</template> </MkInput> <FormSection> - <template #label>Events</template> + <template #label>{{ i18n.ts._webhookSettings.events }}</template> <div class="_gaps_s"> - <MkSwitch v-model="event_follow">Follow</MkSwitch> - <MkSwitch v-model="event_followed">Followed</MkSwitch> - <MkSwitch v-model="event_note">Note</MkSwitch> - <MkSwitch v-model="event_reply">Reply</MkSwitch> - <MkSwitch v-model="event_renote">Renote</MkSwitch> - <MkSwitch v-model="event_reaction">Reaction</MkSwitch> - <MkSwitch v-model="event_mention">Mention</MkSwitch> + <MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch> + <MkSwitch v-model="event_followed">{{ i18n.ts._webhookSettings._events.followed }}</MkSwitch> + <MkSwitch v-model="event_note">{{ i18n.ts._webhookSettings._events.note }}</MkSwitch> + <MkSwitch v-model="event_reply">{{ i18n.ts._webhookSettings._events.reply }}</MkSwitch> + <MkSwitch v-model="event_renote">{{ i18n.ts._webhookSettings._events.renote }}</MkSwitch> + <MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> + <MkSwitch v-model="event_mention">{{ i18n.ts._webhookSettings._events.mention }}</MkSwitch> </div> </FormSection> diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue index e10f65b0af..bc729ab871 100644 --- a/packages/frontend/src/pages/settings/webhook.vue +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -1,7 +1,7 @@ <template> <div class="_gaps_m"> <FormLink :to="`/settings/webhook/new`"> - Create webhook + {{ i18n.ts._webhookSettings.createWebhook }} </FormLink> <FormSection> @@ -31,6 +31,7 @@ import MkPagination from '@/components/MkPagination.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; const pagination = { endpoint: 'i/webhooks/list' as const, diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 590c5765fd..c8077edd28 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -66,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')), diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 6b8041d78e..2ca1b164ae 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -471,7 +471,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R components.push(component); const instance = values.OBJ(new Map([ ['id', values.STR(_id)], - ['update', values.FN_NATIVE(async ([def], opts) => { + ['update', values.FN_NATIVE(([def], opts) => { utils.assertObject(def); const updates = getOptions(def, call); for (const update of def.value.keys()) { @@ -491,13 +491,13 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R return { 'Ui:root': rootInstance, - 'Ui:patch': values.FN_NATIVE(async ([id, val], opts) => { + 'Ui:patch': values.FN_NATIVE(([id, val], opts) => { utils.assertString(id); utils.assertArray(val); patch(id.value, val.value, opts.call); }), - 'Ui:get': values.FN_NATIVE(async ([id], opts) => { + 'Ui:get': values.FN_NATIVE(([id], opts) => { utils.assertString(id); const instance = instances[id.value]; if (instance) { @@ -508,7 +508,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R }), // Ui:root.update({ children: [...] }) の糖衣構文 - 'Ui:render': values.FN_NATIVE(async ([children], opts) => { + 'Ui:render': values.FN_NATIVE(([children], opts) => { utils.assertArray(children); rootComponent.value.children = children.value.map(v => { @@ -517,51 +517,51 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R }); }), - 'Ui:C:container': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:container': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('container', def, id, getContainerOptions, opts.call); }), - 'Ui:C:text': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:text': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('text', def, id, getTextOptions, opts.call); }), - 'Ui:C:mfm': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:mfm': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('mfm', def, id, getMfmOptions, opts.call); }), - 'Ui:C:textarea': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:textarea': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('textarea', def, id, getTextareaOptions, opts.call); }), - 'Ui:C:textInput': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:textInput': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('textInput', def, id, getTextInputOptions, opts.call); }), - 'Ui:C:numberInput': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:numberInput': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.call); }), - 'Ui:C:button': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:button': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('button', def, id, getButtonOptions, opts.call); }), - 'Ui:C:buttons': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:buttons': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('buttons', def, id, getButtonsOptions, opts.call); }), - 'Ui:C:switch': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:switch': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('switch', def, id, getSwitchOptions, opts.call); }), - 'Ui:C:select': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:select': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('select', def, id, getSelectOptions, opts.call); }), - 'Ui:C:folder': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:folder': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('folder', def, id, getFolderOptions, opts.call); }), - 'Ui:C:postFormButton': values.FN_NATIVE(async ([def, id], opts) => { + 'Ui:C:postFormButton': values.FN_NATIVE(([def, id], opts) => { return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.call); }), }; diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/scripts/cache.ts new file mode 100644 index 0000000000..858e5f03bf --- /dev/null +++ b/packages/frontend/src/scripts/cache.ts @@ -0,0 +1,80 @@ + +export class Cache<T> { + private cachedAt: number | null = null; + private value: T | undefined; + private lifetime: number; + + constructor(lifetime: Cache<never>['lifetime']) { + this.lifetime = lifetime; + } + + public set(value: T): void { + this.cachedAt = Date.now(); + this.value = value; + } + + public get(): T | undefined { + if (this.cachedAt == null) return undefined; + if ((Date.now() - this.cachedAt) > this.lifetime) { + this.value = undefined; + this.cachedAt = null; + return undefined; + } + return this.value; + } + + public delete() { + this.value = undefined; + this.cachedAt = null; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + public async fetch(fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + this.set(value); + return value; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + public async fetchMaybe(fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + if (value !== undefined) { + this.set(value); + } + return value; + } +} 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..52e610e437 --- /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(file), + }, { + text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off', + action: () => toggleSensitive(file), + }, { + text: i18n.ts.describeFile, + icon: 'ti ti-text-caption', + action: () => describe(file), + }, null, { + text: i18n.ts.copyUrl, + icon: 'ti ti-link', + action: () => copyUrl(file), + }, { + 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(file), + }]; +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 9c0ff3d1b2..00f2523bf9 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -10,6 +10,81 @@ import { url } from '@/config'; import { noteActions } from '@/store'; import { miLocalStorage } from '@/local-storage'; import { getUserMenu } from '@/scripts/get-user-menu'; +import { clipsCache } from '@/cache'; + +export async function getNoteClipMenu(props: { + note: misskey.entities.Note; + isDeleted: Ref<boolean>; + currentClipPage?: Ref<misskey.entities.Clip>; +}) { + const isRenote = ( + props.note.renote != null && + props.note.text == null && + props.note.fileIds.length === 0 && + props.note.poll == null + ); + + const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note; + + const clips = await clipsCache.fetch(() => os.api('clips/list')); + return [...clips.map(clip => ({ + text: clip.name, + action: () => { + claimAchievement('noteClipped1'); + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + })), null, { + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + clipsCache.delete(); + + claimAchievement('noteClipped1'); + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }]; +} export function getNoteMenu(props: { note: misskey.entities.Note; @@ -208,64 +283,7 @@ export function getNoteMenu(props: { type: 'parent', icon: 'ti ti-paperclip', text: i18n.ts.clip, - children: async () => { - const clips = await os.api('clips/list'); - return [{ - icon: 'ti ti-plus', - text: i18n.ts.createNew, - action: async () => { - const { canceled, result } = await os.form(i18n.ts.createNewClip, { - name: { - type: 'string', - label: i18n.ts.name, - }, - description: { - type: 'string', - required: false, - multiline: true, - label: i18n.ts.description, - }, - isPublic: { - type: 'boolean', - label: i18n.ts.public, - default: false, - }, - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - claimAchievement('noteClipped1'); - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - }, - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - claimAchievement('noteClipped1'); - os.promiseDialog( - os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), - null, - async (err) => { - if (err.id === '734806c4-542c-463a-9311-15c512803965') { - const confirm = await os.confirm({ - type: 'warning', - text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), - }); - if (!confirm.canceled) { - os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); - if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; - } - } else { - os.alert({ - type: 'error', - text: err.message + '\n' + err.id, - }); - } - }, - ); - }, - }))]; - }, + children: () => getNoteClipMenu(props), }, statePromise.then(state => state.isMutedThread ? { icon: 'ti ti-message-off', diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index d7eb331183..fe941c77b2 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -8,6 +8,7 @@ import { userActions } from '@/store'; import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; +import { rolesCache, userListsCache } from '@/cache'; export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; @@ -126,7 +127,7 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router icon: 'ti ti-list', text: i18n.ts.addToList, children: async () => { - const lists = await os.api('users/lists/list'); + const lists = await userListsCache.fetch(() => os.api('users/lists/list')); return lists.map(list => ({ text: list.name, @@ -147,7 +148,7 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router icon: 'ti ti-badges', text: i18n.ts.roles, children: async () => { - const roles = await os.api('admin/roles/list'); + const roles = await rolesCache.fetch(() => os.api('admin/roles/list')); return roles.filter(r => r.target === 'manual').map(r => ({ text: r.name, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 3d87234f41..c3cf48afc4 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -290,6 +290,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + showClipButtonInNoteFooter: { + where: 'device', + default: false, + }, aiChanMode: { where: 'device', default: false, |