diff options
Diffstat (limited to 'packages/frontend/src')
20 files changed, 369 insertions, 130 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/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/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/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/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/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-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, |