diff options
Diffstat (limited to 'packages/frontend')
93 files changed, 2114 insertions, 908 deletions
diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json index f325114522..18baf516ba 100644 --- a/packages/frontend/.storybook/tsconfig.json +++ b/packages/frontend/.storybook/tsconfig.json @@ -18,6 +18,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "incremental": true, "jsx": "react", "jsxFactory": "h" }, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f5c7bcf1b4..5d028ce142 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,19 +20,11 @@ "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@misskey-dev/browser-image-resizer": "2024.1.0", - "@phosphor-icons/web": "^2.0.3", - "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "6.0.2", - "@rollup/pluginutils": "5.1.4", + "@phosphor-icons/web": "2.1.2", "@ruffle-rs/ruffle": "0.1.0-nightly.2024.10.15", "@sentry/vue": "9.14.0", "@syuilo/aiscript": "0.19.0", - "@transfem-org/sfm-js": "0.24.6", - "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.2.3", - "@vue/compiler-sfc": "3.5.14", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", - "astring": "1.9.0", "broadcast-channel": "7.1.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", @@ -45,37 +37,30 @@ "compare-versions": "6.1.1", "cropperjs": "2.0.0", "date-fns": "4.1.0", - "estree-walker": "3.0.3", "eventemitter3": "5.0.1", "frontend-shared": "workspace:*", "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", - "katex": "0.16.10", - "magic-string": "0.30.17", + "katex": "0.16.22", "matter-js": "0.20.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "moment": "^2.30.1", + "moment": "2.30.1", "photoswipe": "5.4.4", + "promise-limit": "2.7.0", "punycode.js": "2.3.1", - "rollup": "4.40.0", "sanitize-html": "2.16.0", - "sass": "1.87.0", "shiki": "3.3.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.176.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.15", - "tsconfig-paths": "4.2.0", "typescript": "5.8.3", "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.3.3", "vue": "3.5.14", "vuedraggable": "next", "wanakana": "5.3.1" @@ -84,7 +69,10 @@ "cypress": "14.3.2" }, "devDependencies": { - "@misskey-dev/summaly": "5.2.1", + "@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", "@storybook/addon-actions": "8.6.12", "@storybook/addon-essentials": "8.6.12", "@storybook/addon-interactions": "8.6.12", @@ -104,9 +92,10 @@ "@storybook/vue3": "8.6.12", "@storybook/vue3-vite": "8.6.12", "@testing-library/vue": "8.1.0", + "@twemoji/parser": "15.1.1", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.7", - "@types/katex": "^0.16.7", + "@types/katex": "0.16.7", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", "@types/node": "22.15.2", @@ -118,16 +107,22 @@ "@types/ws": "8.18.1", "@typescript-eslint/eslint-plugin": "8.31.0", "@typescript-eslint/parser": "8.31.0", + "@vitejs/plugin-vue": "5.2.3", "@vitest/coverage-v8": "3.1.2", "@vue/compiler-core": "3.5.14", + "@vue/compiler-sfc": "3.5.14", "@vue/runtime-core": "3.5.14", + "mfm-js": "npm:@transfem-org/sfm-js@0.24.6", "acorn": "8.14.1", + "astring": "1.9.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "10.0.0", + "estree-walker": "3.0.3", "fast-glob": "3.3.3", "happy-dom": "17.4.4", "intersection-observer": "0.12.2", + "magic-string": "0.30.17", "micromatch": "4.0.8", "minimatch": "10.0.1", "msw": "2.7.5", @@ -136,10 +131,16 @@ "prettier": "3.5.3", "react": "19.1.0", "react-dom": "19.1.0", + "rollup": "4.40.0", + "sass": "1.87.0", "seedrandom": "3.0.5", "start-server-and-test": "2.0.11", "storybook": "8.6.12", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "three": "0.176.0", + "tsc-alias": "1.8.15", + "tsconfig-paths": "4.2.0", + "vite": "6.3.3", "vite-plugin-turbosnap": "1.0.3", "vitest": "3.1.2", "vitest-fetch-mock": "0.4.5", diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index c52fdb898e..6025bc44f0 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder :withSpacer="false"> <template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template> <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> - <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.targetUserId }}</template> - <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> - <RouterView :router="targetRouter"/> + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <admin-user :userId="report.targetUserId" :userHint="report.targetUser"></admin-user> + </div> + </MkFolder> + + <MkFolder v-if="report.targetInstance" :withSpacer="false"> + <template #icon> + <img + v-if="targetInstanceIcon" + :src="targetInstanceIcon" + :alt="i18n.tsx.instanceIconAlt({ name: report.targetInstance.name || report.targetInstance.host })" + :class="$style.instanceIcon" + class="icon" + /> + </template> + <template #label>{{ i18n.ts.instance }}: {{ report.targetInstance.name || report.targetInstance.host }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.targetInstance.id }}</template> + + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <instance-info :host="report.targetInstance.host" :instanceHint="report.targetInstance" :metaHint="metaHint"></instance-info> </div> </MkFolder> @@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-message-2"></i></template> <template #label>{{ i18n.ts.details }}</template> <div class="_gaps_s"> - <Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/> + <Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/> + <SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/> </div> </MkFolder> <MkFolder :withSpacer="false"> <template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template> <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> - <template #suffix>#{{ report.reporterId.toUpperCase() }}</template> + <template #suffix>{{ i18n.ts.id }}# {{ report.reporterId }}</template> - <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> - <RouterView :router="reporterRouter"/> + <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;"> + <admin-user :userId="report.reporterId" :userHint="report.reporter"></admin-user> </div> </MkFolder> <MkFolder :defaultOpen="false"> <template #icon><i class="ti ti-message-2"></i></template> - <template #label>{{ i18n.ts.moderationNote }}</template> + <template #label>{{ i18n.ts.staffNotes }}</template> <template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template> <div class="_gaps_s"> <MkTextarea v-model="moderationNote" manualSave> @@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { provide, ref, watch } from 'vue'; +import { computed, provide, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import * as mfm from 'mfm-js'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -91,19 +111,38 @@ import RouterView from '@/components/global/RouterView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { createRouter } from '@/router.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy'; +import InstanceInfo from '@/pages/instance-info.vue'; +import { iAmAdmin } from '@/i'; +import { misskeyApi } from '@/utility/misskey-api'; +import AdminUser from '@/pages/admin-user.vue'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ report: Misskey.entities.AdminAbuseUserReportsResponse[number]; -}>(); + metaHint?: Misskey.entities.AdminMetaResponse | undefined; +}>(), { + metaHint: undefined, +}); const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); +/* const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); targetRouter.init(); const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); reporterRouter.init(); +*/ + +const parsedComment = computed(() => mfm.parse(props.report.comment)); + +const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl + ? getProxiedImageUrlNullable(props.report.targetInstance.faviconUrl, 'preview') + : props.report.targetInstance?.iconUrl + ? getProxiedImageUrlNullable(props.report.targetInstance.iconUrl, 'preview') + : null); const moderationNote = ref(props.report.moderationNote ?? ''); @@ -150,4 +189,8 @@ function showMenu(ev: MouseEvent) { </script> <style lang="scss" module> +.instanceIcon { + width: 18px; + height: 18px; +} </style> diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 21f604aa43..e19c6435ef 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -142,7 +142,7 @@ function reset() { function remove() { if (captcha.value.remove && captchaWidgetId.value) { try { - if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value); + if (_DEV_) console.debug('remove', props.provider, captchaWidgetId.value); captcha.value.remove(captchaWidgetId.value); } catch (error: unknown) { // ignore diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 40f41f5d0f..36c08a8c64 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise<void> { return bundle.id === language || bundle.aliases?.includes(language); }); if (bundles.length > 0) { - if (_DEV_) console.log(`Loading language: ${language}`); + if (_DEV_) console.debug(`Loading language: ${language}`); await highlighter.loadLanguage(bundles[0].import); codeLang.value = language; } else { diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 63e6b74154..8cf4e5fa2d 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -16,6 +16,7 @@ import { instance } from '@/instance.js'; import { prefer } from '@/preferences.js'; import { getDateText } from '@/utility/timeline-date-separate.js'; import { $i } from '@/i.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; export default defineComponent({ props: { @@ -146,14 +147,12 @@ export default defineComponent({ [$style['direction-up']]: props.direction === 'up', }; - return () => prefer.s.animation ? h(TransitionGroup, { + return () => h(SkTransitionGroup, { class: classes, name: 'list', tag: 'div', onBeforeLeave, onLeaveCancelled, - }, { default: renderChildren }) : h('div', { - class: classes, }, { default: renderChildren }); }, }); diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 43d2002204..dfdfc0a871 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -53,6 +53,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { miLocalStorage } from '@/local-storage.js'; import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'closed'): void; @@ -66,6 +67,7 @@ function close() { } function neverShow() { + prefer.commit('neverShowDonationInfo', 'true'); miLocalStorage.setItem('neverShowDonationInfo', 'true'); close(); } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 2e5d0a3dea..4537bc9d82 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened"> - <MkStickyContainer> + <MkStickyContainer :sticky="sticky"> <template #header> <button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> <div :class="$style.headerIcon"><slot name="icon"></slot></div> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkStickyContainer> + <MkStickyContainer :sticky="sticky"> <template #header> <div v-if="$slots.header" :class="$style.inBodyHeader"> <slot name="header"></slot> @@ -73,12 +73,14 @@ const props = withDefaults(defineProps<{ withSpacer?: boolean; spacerMin?: number; spacerMax?: number; + sticky?: boolean; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, spacerMin: 14, spacerMax: 22, + sticky: true, }); const rootEl = useTemplateRef('rootEl'); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 366321565d..56bfa5de94 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -72,20 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm - v-if="appearNote.text" - :parsedNodes="parsed" - :text="appearNote.text" - :author="appearNote.user" - :nyaize="'respect'" - :emojiUrls="appearNote.emojis" - :enableEmojiMenu="true" - :enableEmojiMenuReaction="true" - :isAnim="allowAnim" - :isBlock="true" - class="_selectable" - /> + <div> + <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> + <Mfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + :isAnim="allowAnim" + class="_selectable" + /> + </div> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> @@ -95,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> + <SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> </div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -113,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> </MkReactionsViewer> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p> @@ -157,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> @@ -180,7 +181,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; @@ -226,7 +227,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -237,6 +238,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -304,9 +306,9 @@ const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); -const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); +const isLong = shouldCollapsed(appearNote.value, urls.value); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); @@ -360,7 +362,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, @@ -913,11 +915,11 @@ function emitUpdReaction(emoji: string, delta: number) { .footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; position: relative; z-index: 1; margin-top: 0.4em; - max-width: 400px; + overflow-x: auto; } &:hover > .article > .main > .footer > .footerButton { @@ -1203,10 +1205,6 @@ function emitUpdReaction(emoji: string, delta: number) { padding: 8px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } @@ -1290,25 +1288,7 @@ function emitUpdReaction(emoji: string, delta: number) { } } -@container (max-width: 400px) { - .root:not(.showActionsOnlyHover) { - .footerButton { - &:not(:last-child) { - margin-right: 0.2em; - } - } - } -} - @container (max-width: 350px) { - .root:not(.showActionsOnlyHover) { - .footerButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } - } - .colorBar { top: 6px; left: 6px; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 52cc836926..7f38b9ec02 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -89,21 +89,21 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <div v-show="mergedCW == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm - v-if="appearNote.text" - :parsedNodes="parsed" - :text="appearNote.text" - :author="appearNote.user" - :nyaize="'respect'" - :emojiUrls="appearNote.emojis" - :enableEmojiMenu="true" - :enableEmojiMenuReaction="true" - :isAnim="allowAnim" - :isBlock="true" - class="_selectable" - /> - <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> + <div> + <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> + <Mfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + :isAnim="allowAnim" + class="_selectable" + /> + </div> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> @@ -112,13 +112,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> + <SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/> </div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <div :class="$style.noteFooterInfo"> <div v-if="appearNote.updatedAt"> {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> @@ -169,7 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> @@ -233,7 +233,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import * as config from '@@/js/config.js'; @@ -278,7 +278,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -286,7 +286,7 @@ import { DI } from '@/di.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; -import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -339,8 +339,7 @@ const isDeleted = ref(false); const renoted = ref(false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm); @@ -388,7 +387,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, @@ -415,6 +414,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); +// Auto-select the first page of reactions +watch(appearNote, n => { + reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null; +}, { immediate: true }); + const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, @@ -886,12 +890,10 @@ function animatedMFM() { } .footer { - position: relative; - z-index: 1; - margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + position: relative; + z-index: 1; + margin-top: 0.4em; + overflow-x: auto; } .replyTo { @@ -1083,10 +1085,6 @@ function animatedMFM() { padding: 8px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } @@ -1169,14 +1167,6 @@ function animatedMFM() { } } -@container (max-width: 350px) { - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } -} - @container (max-width: 300px) { .root { font-size: 0.825em; @@ -1186,12 +1176,6 @@ function animatedMFM() { width: 50px; height: 50px; } - - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } } .muted { diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 282854c6a8..58de5bd5a7 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <MkReactionsViewer ref="reactionsViewer" :note="note"/> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> @@ -113,7 +113,8 @@ import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; import { prefer } from '@/preferences.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; -import { instance } from '@/instance'; +import { instance, policies } from '@/instance'; +import { getAppearNote } from '@/utility/get-appear-note'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -128,7 +129,9 @@ const props = withDefaults(defineProps<{ onDeleteCallback: undefined, }); -const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); +const appearNote = computed(() => getAppearNote(props.note)); + +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); const el = shallowRef<HTMLElement>(); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); @@ -144,19 +147,11 @@ const likeButton = shallowRef<HTMLElement>(); const renoteTooltip = computeRenoteTooltip(renoted); -const appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const replies = ref<Misskey.entities.Note[]>([]); const mergedCW = computed(() => computeMergedCw(appearNote.value)); -const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds && props.note.fileIds.length === 0 && - props.note.poll == null -); - const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, @@ -206,8 +201,8 @@ async function reply(viaKeyboard = false): Promise<void> { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); await os.post({ - reply: props.note, - channel: props.note.channel ?? undefined, + reply: appearNote.value, + channel: appearNote.value.channel ?? undefined, animation: !viaKeyboard, }); focus(); @@ -217,9 +212,9 @@ function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); sound.playMisskeySfx('reaction'); - if (props.note.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { misskeyApi('notes/like', { - noteId: props.note.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -233,12 +228,12 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => { misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -252,7 +247,7 @@ function like(): void { showMovedDialog(); sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { - noteId: props.note.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -361,7 +356,7 @@ function quote() { }).then((cancelled) => { if (cancelled) return; misskeyApi('notes/renotes', { - noteId: props.note.id, + noteId: appearNote.value.id, userId: $i?.id, limit: 1, quote: true, @@ -383,12 +378,12 @@ function quote() { } function menu(): void { - const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { - os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } async function translate() { @@ -397,7 +392,7 @@ async function translate() { if (props.detail) { misskeyApi('notes/children', { - noteId: props.note.id, + noteId: appearNote.value.id, limit: prefer.s.numberOfReplies, showQuotes: false, }).then(res => { @@ -419,12 +414,10 @@ if (props.detail) { } .footer { - position: relative; - z-index: 1; - margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + position: relative; + z-index: 1; + margin-top: 0.4em; + overflow-x: auto; } .main { @@ -469,23 +462,11 @@ if (props.detail) { padding-top: 10px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } } -@container (max-width: 400px) { - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.7em; - } - } -} - .noteFooterButtonCount { display: inline; margin: 0 0 0 8px; diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index efb481d01d..04dff2eb36 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -15,13 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> - <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/> </template> </div> </template> @@ -62,7 +57,7 @@ defineExpose({ &.noGap { background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent); - .note { + .note:not(:empty) { border-bottom: solid 0.5px var(--MI_THEME-divider); } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 54edf771ed..46e98462dc 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notifications }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" + <SkTransitionGroup + :class="[$style.notifications]" :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/> <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/> </div> - </component> + </SkTransitionGroup> </template> </MkPagination> </MkPullToRefresh> @@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index a9e4704b24..44112775dc 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -106,7 +106,7 @@ windowRouter.addListener('replace', ctx => { }); windowRouter.addListener('change', ctx => { - if (_DEV_) console.log('windowRouter: change', ctx.fullPath); + if (_DEV_) console.debug('windowRouter: change', ctx.fullPath); searchMarkerId.value = getSearchMarker(ctx.fullPath); }); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 79a268e8f6..b850c17be1 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -386,7 +386,7 @@ function prepend(item: MisskeyEntity): void { return; } - if (_DEV_) console.log(isHead(), isPausingUpdate); + if (_DEV_) console.debug(isHead(), isPausingUpdate); if (isHead() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 000ccf50bf..a650365a28 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -107,7 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, toRaw } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; @@ -373,7 +373,9 @@ if (props.specified) { // keep cw when reply if (prefer.s.keepCw && props.reply && props.reply.cw) { useCw.value = true; - cw.value = props.reply.cw; + cw.value = (prefer.s.keepCw === 'prepend-re' && !props.reply.cw.toLowerCase().startsWith('re:')) + ? `RE: ${props.reply.cw}` + : props.reply.cw; } // apply default CW @@ -557,6 +559,7 @@ async function toggleLocalOnly() { if (confirm.result === 'no') return; if (confirm.result === 'neverShow') { + prefer.commit('neverShowLocalOnlyInfo', 'true'); miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true'); } } @@ -1356,7 +1359,7 @@ defineExpose({ } &.danger { - color: #ff2a2a; + color: var(--MI_THEME-warn); } } diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 945640ab41..88ac8c87c1 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<component - :is="prefer.s.animation ? TransitionGroup : 'div'" +<SkTransitionGroup :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" @@ -14,8 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> - <slot v-if="hasMoreReactions" :key="'$more'" name="more"/> -</component> + <div v-if="hasMoreReactions" :key="'$more'" :class="$style.moreReactions"> + <slot name="more"/> + </div> +</SkTransitionGroup> </template> <script lang="ts" setup> @@ -25,6 +26,7 @@ import { TransitionGroup } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -102,7 +104,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe position: absolute; } -.root { +.root, .moreReactions { display: flex; flex-wrap: wrap; align-items: center; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index cf4e4eda74..511a45c165 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -39,32 +39,34 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; -import { useInterval } from '@@/js/use-interval.js'; -import type { VNode, VNodeChild } from 'vue'; -import type { MenuItem } from '@/types/menu.js'; -import * as os from '@/os.js'; - -type ItemOption = { +<script lang="ts"> +type ItemOption<T extends string | number | null | boolean = string | number | null> = { type?: 'option'; - value: string | number | null; + value: T; label: string; }; -type ItemGroup = { +type ItemGroup<T extends string | number | null | boolean = string | number | null> = { type: 'group'; label: string; - items: ItemOption[]; + items: ItemOption<T>[]; }; -export type MkSelectItem = ItemOption | ItemGroup; +export type MkSelectItem<T extends string | number | null | boolean = string | number | null> = ItemOption<T> | ItemGroup<T>; +</script> + +<script lang="ts" setup generic="T extends string | number | null | boolean = string | number | null"> +import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; +import { useInterval } from '@@/js/use-interval.js'; +import type { VNode, VNodeChild } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; // TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) // see: https://github.com/misskey-dev/misskey/issues/15558 const props = defineProps<{ - modelValue: string | number | null; + modelValue: T; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -73,11 +75,11 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; - items?: MkSelectItem[]; + items?: MkSelectItem<T>[]; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: string | number | null): void; + (ev: 'update:modelValue', value: T): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 365b23f4ce..003c68309d 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -307,7 +307,7 @@ async function onSubmit(): Promise<void> { emit('approvalPending'); } else { const resJson = (await res.json()) as Misskey.entities.SignupResponse; - if (_DEV_) console.log(resJson); + if (_DEV_) console.debug(resJson); emit('signup', resJson); diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 0780f6c910..60d303f937 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -8,8 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="{ [$style.clickToOpen]: prefer.s.clickToOpen }" @click.stop="prefer.s.clickToOpen ? noteclick(note.id) : undefined"> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> - <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> + <div> + <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> + </div> <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> @@ -35,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 48e8c7f377..61b34b561d 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -14,8 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notes }"> - <component - :is="prefer.s.animation ? TransitionGroup : 'div'" + <SkTransitionGroup :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]" :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" @@ -24,16 +23,11 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass=" $style.transition_x_move" tag="div" > - <div v-for="(note, i) in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> - <DynamicNote :class="$style.note" :note="note" :withHardMute="true"/> - <div :class="$style.ad"> - <MkAd :preferForms="['horizontal', 'horizontal-big']"/> - </div> - </div> - <DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <div v-for="(note, i) in notes" :key="note.id" :class="{ '_gaps': !noGap }"> + <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> + <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/> </div> - </component> + </SkTransitionGroup> </template> </MkPagination> </MkPullToRefresh> @@ -54,6 +48,7 @@ import DynamicNote from '@/components/DynamicNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -358,7 +353,7 @@ defineExpose({ &.noGap { background: var(--MI_THEME-panel); - .note { + .note:not(:empty) { border-bottom: solid 0.5px var(--MI_THEME-divider); } diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index a14c2ecef9..5d0e6e3df7 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only </footer> </article> </component> + + <I18n v-if="attributionUser" :src="i18n.ts.writtenBy" :class="$style.linkAttribution" tag="p"> + <template #user> + <MkA v-user-preview="attributionUser.id" :to="userPage(attributionUser)"> + <MkAvatar :class="$style.linkAttributionIcon" :user="attributionUser"/> + <MkUserName :user="attributionUser" style="color: var(--MI_THEME-accent)"/> + </MkA> + </template> + </I18n> + <p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p> + <template v-if="showActions"> <div v-if="tweetId" :class="$style.action"> <MkButton :small="true" inline @click="tweetExpanded = true"> @@ -88,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> +<script lang="ts"> +// eslint-disable-next-line import/order +import type { summaly } from '@misskey-dev/summaly'; + +export type SummalyResult = Awaited<ReturnType<typeof summaly>> & { + haveNoteLocally?: boolean, + linkAttribution?: { + userId: string, + } +}; +</script> + <script lang="ts" setup> import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { url as local } from '@@/js/config.js'; import { versatileLang } from '@@/js/intl-const.js'; import * as Misskey from 'misskey-js'; import { maybeMakeRelative } from '@@/js/url.js'; -import type { summaly } from '@misskey-dev/summaly'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { deviceKind } from '@/utility/device-kind.js'; @@ -106,8 +128,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; import { $i } from '@/i'; - -type SummalyResult = Awaited<ReturnType<typeof summaly>>; +import { userPage } from '@/filters/user.js'; const props = withDefaults(defineProps<{ url: string; @@ -116,12 +137,18 @@ const props = withDefaults(defineProps<{ showAsQuote?: boolean; showActions?: boolean; skipNoteIds?: (string | undefined)[]; + previewHint?: SummalyResult; + noteHint?: Misskey.entities.Note | null; + attributionHint?: Misskey.entities.User | null; }>(), { detail: false, compact: false, showAsQuote: false, showActions: true, skipNoteIds: undefined, + previewHint: undefined, + noteHint: undefined, + attributionHint: undefined, }); const MOBILE_THRESHOLD = 500; @@ -146,6 +173,10 @@ const player = ref<SummalyResult['player']>({ height: null, allow: [], }); +const linkAttribution = ref<{ + userId: string, +} | null>(null); +const attributionUser = ref<Misskey.entities.User | null>(null); const playerEnabled = ref(false); const tweetId = ref<string | null>(null); const tweetExpanded = ref(props.detail); @@ -154,12 +185,35 @@ const tweetHeight = ref(150); const unknownUrl = ref(false); const theNote = ref<Misskey.entities.Note | null>(null); const fetchingTheNote = ref(false); +const fetchingAttribution = ref<Promise<void> | null>(null); onDeactivated(() => { playerEnabled.value = false; }); -async function fetchNote() { +async function fetchAttribution(initial: boolean): Promise<void> { + if (!linkAttribution.value) return; + if (attributionUser.value) return; + if (fetchingAttribution.value) return fetchingAttribution.value; + + return fetchingAttribution.value ??= (async (userId: string): Promise<void> => { + try { + if (initial && props.attributionHint !== undefined) { + attributionUser.value = props.attributionHint; + } else { + attributionUser.value = await misskeyApi('users/show', { userId }); + } + } catch { + // makes the loading ellipsis vanish. + linkAttribution.value = null; + } finally { + // Reset promise to mark as done + fetchingAttribution.value = null; + } + })(linkAttribution.value.userId); +} + +async function fetchNote(initial: boolean) { if (!props.showAsQuote) return; if (!activityPub.value) return; if (theNote.value) return; @@ -167,8 +221,15 @@ async function fetchNote() { fetchingTheNote.value = true; try { - const response = await misskeyApi('ap/show', { uri: activityPub.value }); + const response = (initial && props.noteHint !== undefined) + ? { type: 'Note', object: props.noteHint } + : await misskeyApi('ap/show', { uri: activityPub.value }); if (response.type !== 'Note') return; + if (!response.object) { + activityPub.value = null; + theNote.value = null; + return; + } const theNoteId = response['object'].id; if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { hidePreview.value = true; @@ -194,13 +255,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi if (m) tweetId.value = m[1]; } +// This is now handled on the backend +/* if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { requestUrl.hostname = 'www.youtube.com'; } requestUrl.hash = ''; +*/ -function refresh(withFetch = false) { +function refresh(withFetch = false, initial = false) { const params = new URLSearchParams({ url: requestUrl.href, lang: versatileLang, @@ -210,18 +274,21 @@ function refresh(withFetch = false) { } const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; - return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) - .then(res => { - if (!res.ok) { - if (_DEV_) { - console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint) + ? Promise.resolve(props.previewHint) + : window.fetch(`/url?${params.toString()}`, { headers }) + .then(res => { + if (!res.ok) { + if (_DEV_) { + console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + } + return null; } - return null; - } - return res.json(); - }) - .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { + return res.json(); + }); + return fetching.value ??= fetchPromise + .then(async (info: SummalyResult | null) => { unknownUrl.value = info == null; title.value = info?.title ?? null; description.value = info?.description ?? null; @@ -236,11 +303,16 @@ function refresh(withFetch = false) { }; sensitive.value = info?.sensitive ?? false; activityPub.value = info?.activityPub ?? null; + linkAttribution.value = info?.linkAttribution ?? null; + // These will be populated by the fetch* functions + attributionUser.value = null; theNote.value = null; - if (info?.haveNoteLocally) { - await fetchNote(); - } + + await Promise.all([ + fetchAttribution(initial), + fetchNote(initial), + ]); }) .finally(() => { fetching.value = null; @@ -273,7 +345,7 @@ onUnmounted(() => { }); // Load initial data -refresh(); +refresh(false, true); </script> <style lang="scss" module> @@ -357,7 +429,7 @@ refresh(); .body { position: relative; box-sizing: border-box; - padding: 16px; + padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple } .header { @@ -395,6 +467,28 @@ refresh(); vertical-align: top; } +.linkAttributionIcon { + display: inline-block; + width: 16px; + height: 16px; + margin-left: 0.25em; + margin-right: 0.25em; + vertical-align: middle; + border-radius: 50%; + * { + border-radius: 4px; + } +} + +.linkAttribution { + width: 100%; + font-size: 0.8em; + display: inline-block; + margin: auto; + padding-top: 0.5em; + text-align: right; +} + .action { display: flex; gap: 6px; diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index 310d044387..3f5bd345f2 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ email }}</div> </div> <div> - <div :class="$style.label">Reason</div> + <div :class="$style.label">{{ i18n.ts.signupReason }}</div> <div>{{ reason }}</div> </div> </div> diff --git a/packages/frontend/src/components/SkBadgeStrip.vue b/packages/frontend/src/components/SkBadgeStrip.vue new file mode 100644 index 0000000000..6611d35b07 --- /dev/null +++ b/packages/frontend/src/components/SkBadgeStrip.vue @@ -0,0 +1,84 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.badges"> + <div + v-for="badge of badges" + :key="badge.key" + :class="[$style.badge, semanticClass(badge)]" + > + {{ badge.label }} + </div> +</div> +</template> + +<script lang="ts"> +export interface Badge { + /** + * ID/key of this badge, must be unique within the strip. + */ + key: string; + + /** + * Label text to display. + * Should already be translated. + */ + label: string; + + /** + * Semantic style of the badge. + * Defaults to "neutral" if unset. + */ + style?: 'success' | 'neutral' | 'warning' | 'error'; +} +</script> + +<script setup lang="ts"> +import { useCssModule } from 'vue'; + +const $style = useCssModule(); + +defineProps<{ + badges: Badge[], +}>(); + +function semanticClass(badge: Badge): string { + const style = badge.style ?? 'neutral'; + return $style[`semantic_${style}`]; +} +</script> + +<style module lang="scss"> +.badges { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--MI-margin); +} + +.badge { + display: inline-block; + border: solid 1px; + border-radius: var(--MI-radius-sm); + padding: 2px 6px; + font-size: 85%; +} + +.semantic_error { + color: var(--MI_THEME-error); + border-color: var(--MI_THEME-error); +} + +.semantic_warning { + color: var(--MI_THEME-warn); + border-color: var(--MI_THEME-warn); +} + +.semantic_success { + color: var(--MI_THEME-success); + border-color: var(--MI_THEME-success); +} +</style> diff --git a/packages/frontend/src/components/SkDateSeparatedList.vue b/packages/frontend/src/components/SkDateSeparatedList.vue new file mode 100644 index 0000000000..239d0c1939 --- /dev/null +++ b/packages/frontend/src/components/SkDateSeparatedList.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <template v-for="(item, index) in timeline" :key="item.id"> + <slot v-if="item.type === 'item'" :id="item.id" :index="index" :item="item.data"></slot> + <slot v-else-if="item.type === 'date'" :id="item.id" :index="index" :prev="item.prev" :prevText="item.prevText" :next="item.next" :nextText="item.nextText" name="date"> + <div :class="$style.dateDivider"> + <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> + <span :class="$style.dateSeparator"></span> + <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> + </div> + </slot> + </template> +</div> +</template> + +<script setup lang="ts" generic="T extends { id: string; createdAt: string; }"> +import { computed } from 'vue'; +import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate'; + +const props = defineProps<{ + items: T[], +}>(); + +const itemsRef = computed(() => props.items); +const timeline = makeDateSeparatedTimelineComputedRef(itemsRef); +</script> + +<style module lang="scss"> +// From room.vue +.dateDivider { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 0.5em; + opacity: 0.75; + border: solid 0.5px var(--MI_THEME-divider); + border-radius: 999px; + width: fit-content; + padding: 0.5em 1em; + margin: 0 auto; +} + +// From room.vue +.dateSeparator { + height: 1em; + width: 1px; + background: var(--MI_THEME-divider); +} +</style> diff --git a/packages/frontend/src/components/SkFollowingRecentNotes.vue b/packages/frontend/src/components/SkFollowingRecentNotes.vue index 3eb2ac8572..37f2f8833a 100644 --- a/packages/frontend/src/components/SkFollowingRecentNotes.vue +++ b/packages/frontend/src/components/SkFollowingRecentNotes.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items: notes }"> + <!-- TODO replace with SkDateSeparatedList when merged --> <MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true"> <SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/> </MkDateSeparatedList> diff --git a/packages/frontend/src/components/SkMfmWindow.vue b/packages/frontend/src/components/SkMfmWindow.vue index 14d309b7ba..c544bc528c 100644 --- a/packages/frontend/src/components/SkMfmWindow.vue +++ b/packages/frontend/src/components/SkMfmWindow.vue @@ -100,6 +100,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.unixtime }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.unixtimeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_unixtime"/> + <MkTextarea v-model="preview_unixtime"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> <div class="title">{{ i18n.ts._mfm.inlineCode }}</div> <div class="content"> <p>{{ i18n.ts._mfm.inlineCodeDescription }}</p> @@ -429,6 +439,9 @@ const preview_small = ref( const preview_center = ref( `<center>${i18n.ts._mfm.dummy}</center>`, ); +const preview_unixtime = ref( + `$[unixtime ${Math.floor(Date.now() / 1000)}]`, +); const preview_inlineCode = ref('`<: "Hello, world!"`'); const preview_blockCode = ref( '```ai\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```', diff --git a/packages/frontend/src/components/SkMutedNote.vue b/packages/frontend/src/components/SkMutedNote.vue index 3c072fab3f..c9b3d768de 100644 --- a/packages/frontend/src/components/SkMutedNote.vue +++ b/packages/frontend/src/components/SkMutedNote.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserName :user="note.user"/> </template> </I18n> -<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small"> +<I18n v-else-if="!prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkUserName :user="note.user"/> </template> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 5184cbd801..4d6d080ddf 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> + <SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/> </div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> </MkReactionsViewer> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p> @@ -158,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()"> @@ -181,7 +181,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; @@ -226,7 +226,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -237,6 +237,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -304,9 +305,9 @@ const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); -const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); +const isLong = shouldCollapsed(appearNote.value, urls.value); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); @@ -360,7 +361,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, @@ -921,11 +922,11 @@ function emitUpdReaction(emoji: string, delta: number) { .footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; position: relative; z-index: 1; margin-top: 0.4em; - max-width: 400px; + overflow-x: auto; } &:hover > .article > .main > .footer > .footerButton { @@ -947,10 +948,6 @@ function emitUpdReaction(emoji: string, delta: number) { .footerButton { font-size: 90%; - - &:not(:last-child) { - margin-right: 0; - } } } @@ -1238,10 +1235,6 @@ function emitUpdReaction(emoji: string, delta: number) { padding: 8px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } @@ -1358,25 +1351,7 @@ function emitUpdReaction(emoji: string, delta: number) { } } -@container (max-width: 400px) { - .root:not(.showActionsOnlyHover) { - .footerButton { - &:not(:last-child) { - margin-right: 0.2em; - } - } - } -} - @container (max-width: 350px) { - .root:not(.showActionsOnlyHover) { - .footerButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } - } - .colorBar { top: 6px; left: 6px; @@ -1385,16 +1360,6 @@ function emitUpdReaction(emoji: string, delta: number) { } } -@container (max-width: 300px) { - .root:not(.showActionsOnlyHover) { - .footerButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } - } -} - @container (max-width: 250px) { .quoteNote { padding: 12px; diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index b165b95d40..f761029cfb 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -108,7 +108,6 @@ SPDX-License-Identifier: AGPL-3.0-only :isBlock="true" class="_selectable" /> - <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> @@ -117,7 +116,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <div v-if="isEnabledUrlPreview"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> + <SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/> </div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> @@ -132,7 +131,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </div> <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> @@ -174,7 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()"> @@ -238,7 +237,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useTemplateRef, watch } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import * as config from '@@/js/config.js'; @@ -283,7 +282,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; -import { instance, isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview, policies } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -291,7 +290,7 @@ import { DI } from '@/di.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids.js'; -import { extractPreviewUrls } from '@/utility/extract-preview-urls'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -345,8 +344,7 @@ const isDeleted = ref(false); const renoted = ref(false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); -const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false); @@ -394,7 +392,7 @@ const keymap = { clip(); }, 't': () => { - if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) { translate(); } }, @@ -421,6 +419,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); +// Auto-select the first page of reactions +watch(appearNote, n => { + reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null; +}, { immediate: true }); + const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, @@ -918,13 +921,13 @@ onUnmounted(() => { } .footer { - display: flex; - align-items: center; - justify-content: space-between; - position: relative; - z-index: 1; - margin-top: 0.4em; - max-width: 400px; + display: flex; + align-items: center; + justify-content: flex-start; + position: relative; + z-index: 1; + margin-top: 0.4em; + overflow-x: auto; } .replyTo { @@ -1141,10 +1144,6 @@ onUnmounted(() => { padding: 8px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } @@ -1234,14 +1233,6 @@ onUnmounted(() => { } } -@container (max-width: 350px) { - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } -} - @container (max-width: 300px) { .root { font-size: 0.825em; @@ -1251,12 +1242,6 @@ onUnmounted(() => { width: 50px; height: 50px; } - - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } } .avatar { diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index 775436cb0f..4e8a3147ad 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <MkReactionsViewer ref="reactionsViewer" :note="note"/> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p> @@ -70,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()"> <i class="ti ti-paperclip"></i> </button> - <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> + <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()"> <i class="ti ti-language-hiragana"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()"> @@ -121,7 +121,8 @@ import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js'; import { prefer } from '@/preferences.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import SkMutedNote from '@/components/SkMutedNote.vue'; -import { instance } from '@/instance'; +import { instance, policies } from '@/instance'; +import { getAppearNote } from '@/utility/get-appear-note'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -141,7 +142,9 @@ const props = withDefaults(defineProps<{ onDeleteCallback: undefined, }); -const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); +const appearNote = computed(() => getAppearNote(props.note)); + +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); const hideLine = computed(() => props.detail); const el = shallowRef<HTMLElement>(); @@ -158,19 +161,11 @@ const likeButton = shallowRef<HTMLElement>(); const renoteTooltip = computeRenoteTooltip(renoted); -let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null); const replies = ref<Misskey.entities.Note[]>([]); const mergedCW = computed(() => computeMergedCw(appearNote.value)); -const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds && props.note.fileIds.length === 0 && - props.note.poll == null -); - const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ type: 'lookup', url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, @@ -220,8 +215,8 @@ async function reply(viaKeyboard = false): Promise<void> { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); await os.post({ - reply: props.note, - channel: props.note.channel ?? undefined, + reply: appearNote.value, + channel: appearNote.value.channel ?? undefined, animation: !viaKeyboard, }); focus(); @@ -231,9 +226,9 @@ function react(): void { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); sound.playMisskeySfx('reaction'); - if (props.note.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { misskeyApi('notes/like', { - noteId: props.note.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -247,12 +242,12 @@ function react(): void { } } else { blur(); - reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => { misskeyApi('notes/reactions/create', { - noteId: props.note.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -266,7 +261,7 @@ function like(): void { showMovedDialog(); sound.playMisskeySfx('reaction'); misskeyApi('notes/like', { - noteId: props.note.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -375,7 +370,7 @@ function quote() { }).then((cancelled) => { if (cancelled) return; misskeyApi('notes/renotes', { - noteId: props.note.id, + noteId: appearNote.value.id, userId: $i?.id, limit: 1, quote: true, @@ -397,12 +392,12 @@ function quote() { } function menu(): void { - const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function clip(): Promise<void> { - os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } async function translate() { @@ -411,7 +406,7 @@ async function translate() { if (props.detail) { misskeyApi('notes/children', { - noteId: props.note.id, + noteId: appearNote.value.id, limit: prefer.s.numberOfReplies, showQuotes: false, }).then(res => { @@ -449,11 +444,11 @@ if (props.detail) { .footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; position: relative; z-index: 1; margin-top: 0.4em; - max-width: 400px; + overflow-x: auto; } .main { @@ -559,14 +554,6 @@ if (props.detail) { } } -@container (max-width: 400px) { - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.7em; - } - } -} - .noteFooterButtonCount { display: inline; margin: 0 0 0 8px; diff --git a/packages/frontend/src/components/SkNoteTranslation.vue b/packages/frontend/src/components/SkNoteTranslation.vue index 170eea80cf..406242f1d1 100644 --- a/packages/frontend/src/components/SkNoteTranslation.vue +++ b/packages/frontend/src/components/SkNoteTranslation.vue @@ -33,7 +33,6 @@ if (_DEV_) { watch( [() => props.translation, () => props.translating], ([translation, translating]) => console.debug('Translation status changed: ', { translation, translating }), - { immediate: true }, ); } </script> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index b6dbec81c5..aa1da2d6e3 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -40,19 +40,19 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + <Mfm v-if="appearNote.text" :text="appearNote.text" :parsedNodes="parsed" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/> + <SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <footer :class="$style.footer"> + <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel"> <div :class="$style.noteFooterInfo"> <MkTime :time="appearNote.createdAt" mode="detail"/> </div> @@ -76,14 +76,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject, onMounted, ref, shallowRef, computed } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkWindow from '@/components/MkWindow.vue'; import MkPoll from '@/components/MkPoll.vue'; -import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; @@ -93,7 +92,7 @@ import { prefer } from '@/preferences'; import { getPluginHandlers } from '@/plugin.js'; import SkNoteTranslation from '@/components/SkNoteTranslation.vue'; import { getSelfNoteIds } from '@/utility/get-self-note-ids'; -import { extractPreviewUrls } from '@/utility/extract-preview-urls.js'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const props = defineProps<{ note: Misskey.entities.Note; @@ -143,12 +142,11 @@ const isRenote = ( const el = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []); const showContent = ref(false); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); -const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null); const selfNoteIds = computed(() => getSelfNoteIds(props.note)); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); @@ -163,11 +161,12 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT } .footer { - position: relative; - z-index: 1; - margin-top: 0.4em; - width: max-content; - min-width: max-content; + position: relative; + z-index: 1; + margin-top: 0.4em; + width: max-content; + min-width: max-content; + overflow-x: auto; } .note { @@ -280,23 +279,11 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT padding: 8px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--MI_THEME-fgHighlighted); } } -@container (max-width: 350px) { - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } -} - @container (max-width: 500px) { .root { font-size: 0.9em; @@ -323,11 +310,5 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT width: 50px; height: 50px; } - - .noteFooterButton { - &:not(:last-child) { - margin-right: 0.1em; - } - } } </style> diff --git a/packages/frontend/src/components/SkTransitionGroup.vue b/packages/frontend/src/components/SkTransitionGroup.vue new file mode 100644 index 0000000000..1c07186501 --- /dev/null +++ b/packages/frontend/src/components/SkTransitionGroup.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup v-if="animate ?? prefer.s.animation" v-bind="props" :class="props.class"> + <slot></slot> +</TransitionGroup> +<component :is="tag" v-else :class="props.class"> + <slot></slot> +</component> +</template> + +<script setup lang="ts"> +import type { TransitionGroupProps } from 'vue'; +import { prefer } from '@/preferences'; + +// This is a "best guess" type. +// If any valid :class binding produces a type error here, then please change this to match. +type ClassBinding = string | Record<string, boolean | undefined>; + +// This can be an inline type, but pulling it out makes TS errors clearer. +interface SkTransitionGroupProps extends TransitionGroupProps { + /** + * Override CSS styles for the TransitionGroup or native element. + */ + class?: undefined | ClassBinding | ClassBinding[]; + + /** + * If true, will render a TransitionGroup. + * If false, will render a native element. + * If null or undefined (default), will respect the value of prefer.s.animation. + */ + animate?: boolean | undefined | null; +} + +const props = withDefaults(defineProps<SkTransitionGroupProps>(), { + tag: 'div', + class: undefined, + animate: undefined, +}); +</script> diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue new file mode 100644 index 0000000000..dbd930248a --- /dev/null +++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue @@ -0,0 +1,348 @@ +<!-- +SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="isRefreshing"> + <MkLoading :class="$style.loading"></MkLoading> +</div> +<template v-else> + <MkUrlPreview + v-for="preview of urlPreviews" + :key="preview.url" + :url="preview.url" + :previewHint="preview" + :noteHint="preview.note" + :attributionHint="preview.attributionUser" + :detail="detail" + :compact="compact" + :showAsQuote="showAsQuote" + :showActions="showActions" + :skipNoteIds="skipNoteIds" + ></MkUrlPreview> +</template> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import * as mfm from 'mfm-js'; +import { computed, ref, watch } from 'vue'; +import { versatileLang } from '@@/js/intl-const'; +import promiseLimit from 'promise-limit'; +import type { SummalyResult } from '@/components/MkUrlPreview.vue'; +import { extractPreviewUrls } from '@/utility/extract-preview-urls'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm'; +import { $i } from '@/i'; +import { misskeyApi } from '@/utility/misskey-api'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { getNoteUrls } from '@/utility/getNoteUrls'; + +type Summary = SummalyResult & { + note?: Misskey.entities.Note | null; + attributionUser?: Misskey.entities.User | null; +}; + +type Limiter<T> = ReturnType<typeof promiseLimit<T>>; + +const props = withDefaults(defineProps<{ + sourceUrls?: string[]; + sourceNodes?: mfm.MfmNode[]; + sourceText?: string; + sourceNote?: Misskey.entities.Note; + + detail?: boolean; + compact?: boolean; + showAsQuote?: boolean; + showActions?: boolean; + skipNoteIds?: string[]; +}>(), { + sourceUrls: undefined, + sourceText: undefined, + sourceNodes: undefined, + sourceNote: undefined, + + detail: undefined, + compact: undefined, + showAsQuote: undefined, + showActions: undefined, + skipNoteIds: () => [], +}); + +const urlPreviews = ref<Summary[]>([]); + +const urls = computed<string[]>(() => { + if (props.sourceUrls) { + return props.sourceUrls; + } + + // sourceNodes > sourceText > sourceNote + const source = + props.sourceNodes ?? + (props.sourceText ? mfm.parse(props.sourceText) : null) ?? + (props.sourceNote?.text ? mfm.parse(props.sourceNote.text) : null); + + if (source) { + if (props.sourceNote) { + return extractPreviewUrls(props.sourceNote, source); + } else { + return extractUrlFromMfm(source); + } + } + + return []; +}); + +// todo un-ref these +const isRefreshing = ref<Promise<void> | false>(false); +const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>()); +const cachedPreviews = ref(new Map<string, Summary | null>()); +const cachedUsers = new Map<string, Misskey.entities.User | null>(); + +/** + * Refreshes the group. + * Calls are automatically de-duplicated. + */ +function refresh(): Promise<void> { + if (isRefreshing.value) return isRefreshing.value; + + const promise = doRefresh(); + promise.finally(() => isRefreshing.value = false); + isRefreshing.value = promise; + return promise; +} + +/** + * Refreshes the group. + * Don't call this directly - use refresh() instead! + */ +async function doRefresh(): Promise<void> { + let previews = await fetchPreviews(); + + // Remove duplicates + previews = deduplicatePreviews(previews); + + // Remove any with hidden notes + previews = previews.filter(preview => !preview.note || !props.skipNoteIds.includes(preview.note.id)); + + urlPreviews.value = previews; +} + +async function fetchPreviews(): Promise<Summary[]> { + const userLimiter = promiseLimit<Misskey.entities.User | null>(4); + const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2); + const summaryLimiter = promiseLimit<Summary | null>(5); + + const summaries = await Promise.all(urls.value.map(url => + summaryLimiter(async () => { + return await fetchPreview(url); + }).then(async (summary) => { + if (summary) { + await Promise.all([ + attachNote(summary, noteLimiter), + attachAttribution(summary, userLimiter), + ]); + } + + return summary; + }))); + + return summaries.filter((preview): preview is Summary => preview != null); +} + +async function fetchPreview(url: string): Promise<Summary | null> { + const cached = cachedPreviews.value.get(url); + if (cached) { + return cached; + } + + const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; + const params = new URLSearchParams({ url, lang: versatileLang }); + const res = await window.fetch(`/url?${params.toString()}`, { headers }).catch(() => null); + + if (res?.ok) { + // Success - got the summary + const summary: Summary = await res.json(); + cachedPreviews.value.set(url, summary); + if (summary.url !== url) { + cachedPreviews.value.set(summary.url, summary); + } + return summary; + } + + // Failed, blocked, or not found + cachedPreviews.value.set(url, null); + return null; +} + +async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entities.Note | null>): Promise<void> { + if (props.showAsQuote && summary.activityPub && summary.haveNoteLocally) { + // Have to pull this out to make TS happy + const noteUri = summary.activityPub; + + summary.note = await noteLimiter(async () => { + return await fetchNote(noteUri); + }); + } +} + +async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> { + const cached = cachedNotes.value.get(noteUri); + if (cached) { + return cached; + } + + const response = await misskeyApi('ap/show', { uri: noteUri }).catch(() => null); + if (response && response.type === 'Note') { + const note = response['object']; + + // Success - got the note + cachedNotes.value.set(noteUri, note); + if (note.uri && note.uri !== noteUri) { + cachedNotes.value.set(note.uri, note); + } + return note; + } + + // Failed, blocked, or not found + cachedNotes.value.set(noteUri, null); + return null; +} + +async function attachAttribution(summary: Summary, userLimiter: Limiter<Misskey.entities.User | null>): Promise<void> { + if (summary.linkAttribution) { + // Have to pull this out to make TS happy + const userId = summary.linkAttribution.userId; + + summary.attributionUser = await userLimiter(async () => { + return await fetchUser(userId); + }); + } +} + +async function fetchUser(userId: string): Promise<Misskey.entities.User | null> { + const cached = cachedUsers.get(userId); + if (cached) { + return cached; + } + + const user = await misskeyApi('users/show', { userId }).catch(() => null); + + cachedUsers.set(userId, user); + return user; +} + +function deduplicatePreviews(previews: Summary[]): Summary[] { + // eslint-disable-next-line no-param-reassign + previews = previews + // Remove any previews with duplicate URL + .filter((preview, index) => !previews.some((p, i) => { + // Skip the current preview (don't count self as duplicate). + if (p === preview) return false; + + // Skip differing URLs (not duplicate). + if (p.url !== preview.url) return false; + + // Skip if we have AP and the other doesn't + if (preview.activityPub && !p.activityPub) return false; + + // Skip if we have a note and the other doesn't + if (preview.note && !p.note) return false; + + // Skip later previews (keep the earliest instance)... + // ...but only if we have AP or the later one doesn't... + // ...and only if we have note or the later one doesn't. + if (i > index && (preview.activityPub || !p.activityPub) && (preview.note || !p.note)) return false; + + // If we get here, then "preview" is a duplicate of "p" and should be skipped. + return true; + })); + + // eslint-disable-next-line no-param-reassign + previews = previews + // Remove any previews with duplicate AP + .filter((preview, index) => !previews.some((p, i) => { + // Skip the current preview (don't count self as duplicate). + if (p === preview) return false; + + // Skip if we don't have AP + if (!preview.activityPub) return false; + + // Skip if other does not have AP + if (!p.activityPub) return false; + + // Skip differing URLs (not duplicate). + if (p.activityPub !== preview.activityPub) return false; + + // Skip later previews (keep the earliest instance) + if (i > index) return false; + + // If we get here, then "preview" is a duplicate of "p" and should be skipped. + return true; + })); + + // eslint-disable-next-line no-param-reassign + previews = previews + // Remove any previews with duplicate note + .filter((preview, index) => !previews.some((p, i) => { + // Skip the current preview (don't count self as duplicate). + if (p === preview) return false; + + // Skip if we don't have a note + if (!preview.note) return false; + + // Skip if other does not have a note + if (!p.note) return false; + + // Skip differing notes (not duplicate). + if (p.note.id !== preview.note.id) return false; + + // Skip later previews (keep the earliest instance) + if (i > index) return false; + + // If we get here, then "preview" is a duplicate of "p" and should be skipped. + return true; + })); + + // eslint-disable-next-line no-param-reassign + previews = previews + // Remove any previews where the note duplicates url + .filter((preview, index) => !previews.some((p, i) => { + // Skip the current preview (don't count self as duplicate). + if (p === preview) return false; + + // Skip if we have a note + if (preview.note) return false; + + // Skip if other does not have a note + if (!p.note) return false; + + // Skip later previews (keep the earliest instance) + if (i > index) return false; + + const noteUrls = getNoteUrls(p.note); + + // Remove if other duplicates our AP URL + if (preview.activityPub && noteUrls.includes(preview.activityPub)) return true; + + // Remove if other duplicates our main URL + return noteUrls.includes(preview.url); + })); + + return previews; +} + +// Kick everything off, and watch for changes. +watch( + [urls, () => props.showAsQuote, () => props.skipNoteIds], + () => refresh(), + { immediate: true }, +); +</script> + +<style module lang="scss"> +.loading { + box-shadow: 0 0 0 1px var(--MI_THEME-divider); + border-radius: var(--MI-radius-sm); +} +</style> diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index dea486e66d..9f92c43b68 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -4,7 +4,7 @@ */ import { h, defineAsyncComponent } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import CkFollowMouse from '../CkFollowMouse.vue'; diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 05245716c2..73ce393113 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl"> - <div ref="headerEl" :class="$style.header"> + <div ref="headerEl" :class="{ [$style.header]: sticky }"> <slot name="header"></slot> </div> <div - :class="$style.body" + :class="{ [$style.body]: sticky }" :data-sticky-container-header-height="headerHeight" :data-sticky-container-footer-height="footerHeight" > <slot></slot> </div> - <div ref="footerEl" :class="$style.footer"> + <div ref="footerEl" :class="{ [$style.footer]: sticky }"> <slot name="footer"></slot> </div> </div> @@ -25,6 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue'; import { DI } from '@/di.js'; +withDefaults(defineProps<{ + sticky?: boolean, +}>(), { + sticky: true, +}); + const rootEl = useTemplateRef('rootEl'); const headerEl = useTemplateRef('headerEl'); const footerEl = useTemplateRef('footerEl'); diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d2e59bf4ad..485ea687de 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> - <div :class="$style.body"> + <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template> + <div :class="[ $style.body, { _spacer: spacer } ]"> <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page"> <slot></slot> </MkSwiper> @@ -30,13 +30,16 @@ const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; swipable?: boolean; page?: string; + spacer?: boolean; }>(), { reversed: false, swipable: true, + page: undefined, + spacer: false, }); const pageHeaderProps = computed(() => { - const { reversed, ...rest } = props; + const { reversed, spacer, ...rest } = props; return rest; }); diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index c95c74aef3..38a5c1ba23 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -4,12 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_x_move : ''" +<SkTransitionGroup + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" :duration="200" tag="div" :class="$style.tabs" > @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> -</TransitionGroup> +</SkTransitionGroup> </template> <script lang="ts" setup> @@ -47,6 +47,7 @@ import { prefer } from '@/preferences.js'; import MkLoadingPage from '@/pages/_loading_.vue'; import { DI } from '@/di.js'; import { deepEqual } from '@/utility/deep-equal.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const props = defineProps<{ router?: Router; diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index df26874c17..543e9afdaf 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -11,8 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { retryOnThrottled } from '@@/js/retry-on-throttled.js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -20,16 +21,25 @@ import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ block: Misskey.entities.PageBlock, page: Misskey.entities.Page, + index: number; }>(); const note = ref<Misskey.entities.Note | null>(null); +// eslint-disable-next-line id-denylist +let timeoutId: ReturnType<typeof window.setTimeout> | null = null; + onMounted(() => { if (props.block.note == null) return; - misskeyApi('notes/show', { noteId: props.block.note }) - .then(result => { - note.value = result; - }); + timeoutId = window.setTimeout(async () => { + note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: props.block.note })); + }, 500 * props.index); // rate limit is 2 reqs per sec +}); + +onUnmounted(() => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } }); </script> diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index ef3524fe7a..3891380dd0 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -7,29 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps" :class="$style.textRoot"> <Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/> <div v-if="isEnabledUrlPreview" class="_gaps_s"> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!page.user.rejectQuotes"/> + <SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes" @click.stop/> </div> </div> </template> <script lang="ts" setup> -import { defineAsyncComponent, computed } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { isEnabledUrlPreview } from '@/instance.js'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; -const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); - -const props = defineProps<{ +defineProps<{ block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); - -const urls = computed(() => { - if (!props.block.text) return []; - return extractUrlFromMfm(mfm.parse(props.block.text)); -}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index a31c5eff28..9f9feeed49 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps"> - <XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/> + <XBlock v-for="(child, index) in page.content" :key="child.id" :index="index" :page="page" :block="child" :h="2"/> </div> </template> diff --git a/packages/frontend/src/i.ts b/packages/frontend/src/i.ts index a71ed1671f..0021298d63 100644 --- a/packages/frontend/src/i.ts +++ b/packages/frontend/src/i.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { reactive } from 'vue'; +import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { miLocalStorage } from '@/local-storage.js'; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index e75e3dfd34..953af6333a 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, reactive } from 'vue'; +import { computed, nextTick, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; +import { $i } from '@/i'; // TODO: 他のタブと永続化されたstateを同期 @@ -38,6 +39,8 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); +export const policies = computed<Misskey.entities.RolePolicies>(() => $i?.policies ?? instance.policies); + export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> { if (!force) { const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; @@ -60,3 +63,8 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met return instance; } + +// instance export can be empty sometimes, which causes problems. +await fetchInstance().catch(err => { + console.warn('Initial meta fetch failed:', err); +}); diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts index a166df9eb0..792dde7dd1 100644 --- a/packages/frontend/src/lib/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -274,7 +274,7 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { } else { redirectPath = current.route.redirect + (current._parsedRoute.queryString ? '?' + current._parsedRoute.queryString : '') + (current._parsedRoute.hash ? '#' + current._parsedRoute.hash : ''); } - if (_DEV_) console.log('Redirecting to: ', redirectPath); + if (_DEV_) console.debug('Redirecting to: ', redirectPath); if (_redirected && this.redirectCount++ > 10) { throw new Error('redirect loop detected'); } diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index a232ced75e..4c55b1ffa5 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -97,7 +97,7 @@ export class Pizzax<T extends StateDef> { if (this.isPureObject(value) && this.isPureObject(def)) { const merged = deepMerge(value, def); - if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); + if (_DEV_) console.debug('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); return merged as X; } diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 5c795b1f9d..f1d660a45b 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -48,23 +48,23 @@ export type Keys = ( //const safeSessionStorage = new Map<Keys, string>(); export const miLocalStorage = { - getItem: (key: Keys): string | null => { - return window.localStorage.getItem(key); + getItem: <T extends string = string>(key: Keys): T | null => { + return window.localStorage.getItem(key) as T | null; }, - setItem: (key: Keys, value: string): void => { + setItem: <T extends string = string>(key: Keys, value: T): void => { window.localStorage.setItem(key, value); }, removeItem: (key: Keys): void => { window.localStorage.removeItem(key); }, - getItemAsJson: (key: Keys): any | undefined => { + getItemAsJson: <T = any>(key: Keys): T | undefined => { const item = miLocalStorage.getItem(key); if (item === null) { return undefined; } return JSON.parse(item); }, - setItemAsJson: (key: Keys, value: any): void => { + setItemAsJson: <T = any>(key: Keys, value: T): void => { miLocalStorage.setItem(key, JSON.stringify(value)); }, }; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 5a12e3ae6d..24eb81beca 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -101,7 +101,7 @@ export const apiWithDialog = (< }); export function promiseDialog<T extends Promise<any>>( - promise: T, + promise: T | (() => T), onSuccess?: ((res: Awaited<T>) => void) | null, onFailure?: ((err: Misskey.api.APIError) => void) | null, text?: string, @@ -109,6 +109,10 @@ export function promiseDialog<T extends Promise<any>>( const showing = ref(true); const success = ref(false); + if (typeof(promise) === 'function') { + promise = promise(); + } + promise.then(res => { if (onSuccess) { showing.value = false; diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index d3c0de3040..dc29ae2f80 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -4,11 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> + <FormSuspense v-if="init" :p="init"> + <div v-if="user && info"> <div v-if="tab === 'overview'" class="_gaps"> - <div v-if="user" class="aeakzknw"> + <div class="aeakzknw"> <MkAvatar class="avatar" :user="user" indicator link preview/> <div class="body"> <span class="name"><MkUserName class="name" :user="user"/></span> @@ -20,19 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only <span class="_monospace">{{ user.id }}</span> <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button> </span> - <span class="state"> - <span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span> - <span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span> - <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span> - <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span> - <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span> - </span> </div> </div> + <SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip> + <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> - <MkFolder v-if="!isSystem"> + <MkFolder v-if="!isSystem" :sticky="false"> <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.details }}</template> <div style="display: flex; flex-direction: column; gap: 1em;"> @@ -89,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="info"> + <MkFolder v-if="info" :sticky="false"> <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._role.policies }}</template> <div class="_gaps"> @@ -99,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmAdmin && ips && ips.length > 0"> + <MkFolder v-if="iAmAdmin && ips && ips.length > 0" :sticky="false"> <template #icon><i class="ph-network ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.ip }}</template> <MkInfo>{{ i18n.ts.ipTip }}</MkInfo> @@ -109,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0"> + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0" :sticky="false"> <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.moderationNote }}</template> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> @@ -135,6 +130,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSection> + <FormSection v-else-if="info.signupReason"> + <template #label>{{ i18n.ts.signupReason }}</template> + {{ info.signupReason }} + </FormSection> + <FormSection v-if="!isSystem && user && iAmModerator"> <div class="_gaps"> <MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> @@ -233,14 +233,46 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'raw'" class="_gaps_m"> - <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> - </MkObjectView> + <MkFolder :sticky="false" :defaultOpen="true"> + <template #icon><i class="ph-user-circle ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.user }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawUserDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(user, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + + <MkObjectView tall :value="user"/> + </MkFolder> - <MkObjectView tall :value="user"> - </MkObjectView> + <MkFolder :sticky="false"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawInfoDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(info, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + + <MkObjectView tall :value="info"/> + </MkFolder> + + <MkFolder v-if="ap" :sticky="false"> + <template #icon><i class="ph-globe ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.activityPub }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawApDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(ap, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + <MkObjectView tall :value="ap"/> + </MkFolder> </div> - </FormSuspense> - </div> + </div> + </FormSuspense> </PageWithHeader> </template> @@ -248,6 +280,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import type { Badge } from '@/components/SkBadgeStrip.vue'; +import type { ChartSrc } from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -272,16 +306,25 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInput from '@/components/MkInput.vue'; import MkNumber from '@/components/MkNumber.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; const props = withDefaults(defineProps<{ userId: string; initialTab?: string; + userHint?: Misskey.entities.UserDetailed; + infoHint?: Misskey.entities.AdminShowUserResponse; + ipsHint?: Misskey.entities.AdminGetUserIpsResponse; + apHint?: Misskey.entities.ApGetResponse; }>(), { initialTab: 'overview', + userHint: undefined, + infoHint: undefined, + ipsHint: undefined, + apHint: undefined, }); const tab = ref(props.initialTab); -const chartSrc = ref('per-user-notes'); +const chartSrc = ref<ChartSrc>('per-user-notes'); const user = ref<null | Misskey.entities.UserDetailed>(); const init = ref<ReturnType<typeof createFetcher>>(); const info = ref<Misskey.entities.AdminShowUserResponse | null>(null); @@ -304,6 +347,98 @@ const filesPagination = { })), }; +const badges = computed(() => { + const arr: Badge[] = []; + if (info.value && user.value) { + if (info.value.isSuspended) { + arr.push({ + key: 'suspended', + label: i18n.ts.suspended, + style: 'error', + }); + } + + if (info.value.isSilenced) { + arr.push({ + key: 'silenced', + label: i18n.ts.silenced, + style: 'warning', + }); + } + + if (info.value.alwaysMarkNsfw) { + arr.push({ + key: 'nsfw', + label: i18n.ts.nsfw, + style: 'warning', + }); + } + + if (user.value.mandatoryCW) { + arr.push({ + key: 'cw', + label: i18n.ts.cw, + style: 'warning', + }); + } + + if (info.value.isHibernated) { + arr.push({ + key: 'hibernated', + label: i18n.ts.hibernated, + style: 'neutral', + }); + } + + if (info.value.isAdministrator) { + arr.push({ + key: 'admin', + label: i18n.ts.administrator, + style: 'success', + }); + } else if (info.value.isModerator) { + arr.push({ + key: 'mod', + label: i18n.ts.moderator, + style: 'success', + }); + } + + if (user.value.host == null) { + if (info.value.email) { + if (info.value.emailVerified) { + arr.push({ + key: 'verified', + label: i18n.ts.verified, + style: 'success', + }); + } else { + arr.push({ + key: 'not_verified', + label: i18n.ts.notVerified, + style: 'success', + }); + } + } + + if (info.value.approved) { + arr.push({ + key: 'approved', + label: i18n.ts.approved, + style: 'success', + }); + } else { + arr.push({ + key: 'not_approved', + label: i18n.ts.notApproved, + style: 'warning', + }); + } + } + } + return arr; +}); + const announcementsStatus = ref<'active' | 'archived'>('active'); const announcementsPagination = { @@ -314,47 +449,65 @@ const announcementsPagination = { status: announcementsStatus.value, })), }; -const expandedRoles = ref([]); +const expandedRoles = ref<string[]>([]); -function createFetcher() { - return () => Promise.all([misskeyApi('users/show', { - userId: props.userId, - }), misskeyApi('admin/show-user', { - userId: props.userId, - }), iAmAdmin ? misskeyApi('admin/get-user-ips', { - userId: props.userId, - }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { +function createFetcher(withHint = true) { + return () => Promise.all([ + (withHint && props.userHint) ? props.userHint : misskeyApi('users/show', { + userId: props.userId, + }), + (withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', { + userId: props.userId, + }), + iAmAdmin + ? (withHint && props.ipsHint) ? props.ipsHint : misskeyApi('admin/get-user-ips', { + userId: props.userId, + }) + : null, + iAmAdmin + ? (withHint && props.apHint) ? props.apHint : misskeyApi('ap/get', { + userId: props.userId, + }).catch(() => null) : null], + ).then(async ([_user, _info, _ips, _ap]) => { user.value = _user; info.value = _info; ips.value = _ips; - moderator.value = info.value.isModerator; - silenced.value = info.value.isSilenced; - approved.value = info.value.approved; - markedAsNSFW.value = info.value.alwaysMarkNsfw; - suspended.value = info.value.isSuspended; - rejectQuotes.value = user.value.rejectQuotes ?? false; - moderationNote.value = info.value.moderationNote; - mandatoryCW.value = user.value.mandatoryCW; + ap.value = _ap; + moderator.value = _info.isModerator; + silenced.value = _info.isSilenced; + approved.value = _info.approved; + markedAsNSFW.value = _info.alwaysMarkNsfw; + suspended.value = _info.isSuspended; + rejectQuotes.value = _user.rejectQuotes ?? false; + moderationNote.value = _info.moderationNote; + mandatoryCW.value = _user.mandatoryCW; }); } -function refreshUser() { - init.value = createFetcher(); +async function refreshUser() { + // Not a typo - createFetcher() returns a function() + await createFetcher(false)(); } -async function onMandatoryCWChanged(value: string) { - await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value }); - refreshUser(); +async function onMandatoryCWChanged(value: string | number) { + await os.promiseDialog(async () => { + await misskeyApi('admin/cw-user', { userId: props.userId, cw: String(value) }); + await refreshUser(); + }); } async function onModerationNoteChanged(value: string) { - await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); + await refreshUser(); + }); } async function updateRemoteUser() { - await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('federation/update-remote-user', { userId: props.userId }); + await refreshUser(); + }); } async function resetPassword() { @@ -366,9 +519,9 @@ async function resetPassword() { return; } else { const { password } = await misskeyApi('admin/reset-password', { - userId: user.value.id, + userId: props.userId, }); - os.alert({ + await os.alert({ type: 'success', text: i18n.tsx.newPasswordIs({ password }), }); @@ -383,7 +536,7 @@ async function toggleNSFW(v) { if (confirm.canceled) { markedAsNSFW.value = !v; } else { - await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: props.userId }); await refreshUser(); } } @@ -396,8 +549,10 @@ async function toggleSilence(v) { if (confirm.canceled) { silenced.value = !v; } else { - await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -409,8 +564,10 @@ async function toggleSuspend(v) { if (confirm.canceled) { suspended.value = !v; } else { - await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -422,11 +579,13 @@ async function toggleRejectQuotes(v: boolean): Promise<void> { if (confirm.canceled) { rejectQuotes.value = !v; } else { - await misskeyApi('admin/reject-quotes', { - userId: props.userId, - rejectQuotes: v, + await os.promiseDialog(async () => { + await misskeyApi('admin/reject-quotes', { + userId: props.userId, + rejectQuotes: v, + }); + await refreshUser(); }); - await refreshUser(); } } @@ -436,17 +595,10 @@ async function unsetUserAvatar() { text: i18n.ts.unsetUserAvatarConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-avatar', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-avatar', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function unsetUserBanner() { @@ -455,17 +607,10 @@ async function unsetUserBanner() { text: i18n.ts.unsetUserBannerConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-banner', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-banner', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function deleteAllFiles() { @@ -474,17 +619,10 @@ async function deleteAllFiles() { text: i18n.ts.deleteAllFilesConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/delete-all-files-of-a-user', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/delete-all-files-of-a-user', { userId: props.userId }); + await refreshUser(); }); - await refreshUser(); } async function deleteAccount() { @@ -493,18 +631,19 @@ async function deleteAccount() { text: i18n.ts.deleteThisAccountConfirm, }); if (confirm.canceled) return; + if (!user.value) return; const typed = await os.inputText({ - text: i18n.tsx.typeToConfirm({ x: user.value?.username }), + text: i18n.tsx.typeToConfirm({ x: user.value.username }), }); if (typed.canceled) return; - if (typed.result === user.value?.username) { + if (typed.result === user.value.username) { await os.apiWithDialog('admin/delete-account', { - userId: user.value.id, + userId: props.userId, }); } else { - os.alert({ + await os.alert({ type: 'error', text: 'input not match', }); @@ -544,23 +683,27 @@ async function assignRole() { : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) : null; - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/assign', { roleId, userId: props.userId, expiresAt }); + await refreshUser(); + }); } async function unassignRole(role, ev) { - os.popupMenu([{ + await os.popupMenu([{ text: i18n.ts.unassign, icon: 'ti ti-x', danger: true, action: async () => { - await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/unassign', { roleId: role.id, userId: props.userId }); + await refreshUser(); + }); }, }], ev.currentTarget ?? ev.target); } -function toggleRoleItem(role) { +function toggleRoleItem(role: Misskey.entities.Role) { if (expandedRoles.value.includes(role.id)) { expandedRoles.value = expandedRoles.value.filter(x => x !== role.id); } else { @@ -569,6 +712,7 @@ function toggleRoleItem(role) { } function createAnnouncement() { + if (!user.value) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, }, { @@ -577,6 +721,7 @@ function createAnnouncement() { } function editAnnouncement(announcement) { + if (!user.value) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, announcement, @@ -591,14 +736,6 @@ watch(() => props.userId, () => { immediate: true, }); -watch(user, () => { - misskeyApi('ap/get', { - uri: user.value.uri ?? `${url}/users/${user.value.id}`, - }).then(res => { - ap.value = res; - }); -}); - const headerActions = computed(() => []); const headerTabs = computed(() => isSystem.value ? [{ @@ -782,6 +919,7 @@ definePage(() => ({ cursor: pointer; } +// Sync with instance-info.vue .buttonStrip { margin: calc(var(--MI-margin) / 2 * -1); @@ -789,4 +927,13 @@ definePage(() => ({ margin: calc(var(--MI-margin) / 2); } } + +.rawFolderHeader { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: var(--MI-marginHalf); + gap: var(--MI-marginHalf); +} </style> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 4ec4372492..a2343d7e76 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -48,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50"> - <div class="_gaps"> - <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> - </div> + <SkDateSeparatedList v-slot="{ item: report }" :items="items"> + <XAbuseReport :report="report" :metaHint="metaHint" @resolved="resolved"/> + </SkDateSeparatedList> </MkPagination> </div> </div> @@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, useTemplateRef, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; @@ -67,6 +68,9 @@ import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import { store } from '@/store.js'; +import SkDateSeparatedList from '@/components/SkDateSeparatedList.vue'; +import { iAmAdmin } from '@/i'; +import { misskeyApi } from '@/utility/misskey-api'; const reports = useTemplateRef('reports'); @@ -76,6 +80,14 @@ const targetUserOrigin = ref('combined'); const searchUsername = ref(''); const searchHost = ref(''); +const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined); + +if (iAmAdmin) { + misskeyApi('admin/meta') + .then(meta => metaHint.value = meta) + .catch(err => console.error('[MkAbuseReport] Error fetching meta:', err)); +} + const pagination = { endpoint: 'admin/abuse-user-reports' as const, limit: 10, diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 1a80f6fef1..b72583214b 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="text" class="_selectable" :text="message.text" + :parsedNotes="parsed" :i="$i" :nyaize="'respect'" :enableEmojiMenu="true" @@ -21,19 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only /> <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> </MkFukidashi> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/> + <SkUrlPreviewGroup :sourceNodes="parsed" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/> <div :class="$style.footer"> <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> <MkTime :class="$style.time" :time="message.createdAt"/> <MkA v-if="isSearchResult && 'toRoom' in message && message.toRoom != null" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> <MkA v-if="isSearchResult && 'toUser' in message && message.toUser != null && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> </div> - <TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + <SkTransitionGroup + :enterActiveClass="$style.transition_reaction_enterActive" + :leaveActiveClass="$style.transition_reaction_leaveActive" + :enterFromClass="$style.transition_reaction_enterFrom" + :leaveToClass="$style.transition_reaction_leaveTo" + :moveClass="$style.transition_reaction_move" tag="div" :class="$style.reactions" > <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="[$style.reaction, record.user.id === $i.id ? $style.reactionMy : null]" @click="onReactionClick(record)"> @@ -45,21 +46,19 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.reactionIcon" /> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </div> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, provide } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import { isLink } from '@@/js/is-link.js'; import type { MenuItem } from '@/types/menu.js'; import type { NormalizedChatMessage } from './room.vue'; -import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; -import MkUrlPreview from '@/components/MkUrlPreview.vue'; import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -73,6 +72,8 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; +import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue'; const $i = ensureSignin(); @@ -82,7 +83,7 @@ const props = defineProps<{ }>(); const isMe = computed(() => props.message.fromUserId === $i.id); -const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); +const parsed = computed(() => props.message.text ? mfm.parse(props.message.text) : []); provide(DI.mfmEmojiReactCallback, (reaction) => { if ($i.policies.chatAvailability !== 'available') return; diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index 5afda5682f..6505e172dd 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -31,12 +31,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> </div> - <TransitionGroup - :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" - :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + <SkTransitionGroup + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" tag="div" class="_gaps" > <div v-for="item in timeline.toReversed()" :key="item.id"> @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> </div> </div> - </TransitionGroup> + </SkTransitionGroup> </div> <div v-if="user && (!user.canChat || user.host !== null)"> @@ -111,6 +111,7 @@ import { useRouter } from '@/router.js'; import { useMutationObserver } from '@/use/use-mutation-observer.js'; import MkInfo from '@/components/MkInfo.vue'; import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const $i = ensureSignin(); const router = useRouter(); diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index a47e3efbc8..82badd40b3 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -10,27 +10,77 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="polls">{{ i18n.ts.poll }}</option> </MkTab> <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> - <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> + <div v-else-if="tab === 'polls'"> + <template v-if="ltlAvailable || gtlAvailable"> + <MkFoldableSection v-if="ltlAvailable" class="_margin"> + <template #header><i class="ph-house ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.tsx.pollsOnLocal({ name: instance.name ?? host }) }}</template> + <MkNotes :pagination="paginationForPollsLocal" :disableAutoLoad="true"/> + </MkFoldableSection> + + <MkFoldableSection v-if="gtlAvailable" class="_margin"> + <template #header><i class="ph-globe ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsOnRemote }}</template> + <MkNotes :pagination="paginationForPollsRemote" :disableAutoLoad="true"/> + </MkFoldableSection> + + <MkFoldableSection v-if="gtlAvailable" class="_margin"> + <template #header><i class="ph-timer ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsExpired }}</template> + <MkNotes :pagination="paginationForPollsExpired" :disableAutoLoad="true"/> + </MkFoldableSection> + </template> + <template v-else> + <div v-if="$i"><i class="ti ti-alert-triangle"></i>{{ i18n.ts.trendingPollsDisabled }}</div> + <div v-else><i class="ti ti-alert-triangle"></i>{{ i18n.ts.trendingPollsDisabledLogIn }}</div> + </template> + </div> </div> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref } from 'vue'; +import { host } from '@@/js/config.js'; import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import { i18n } from '@/i18n.js'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import { instance } from '@/instance.js'; +import { $i } from '@/i'; + +const ltlAvailable = computed(() => $i?.policies.ltlAvailable ?? instance.policies.ltlAvailable); +const gtlAvailable = computed(() => $i?.policies.gtlAvailable ?? instance.policies.gtlAvailable); const paginationForNotes = { endpoint: 'notes/featured' as const, limit: 10, }; -const paginationForPolls = { +const paginationForPollsLocal = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, + params: { + excludeChannels: true, + local: true, + }, +}; + +const paginationForPollsRemote = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, + params: { + excludeChannels: true, + local: false, + }, +}; + +const paginationForPollsExpired = { endpoint: 'notes/polls/recommendation' as const, limit: 10, offsetMode: true, params: { excludeChannels: true, + local: null, + expired: true, }, }; diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index aa18f44e88..4f74467871 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{ items }"> + <!-- TODO replace with SkDateSeparatedList when merged --> <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false"> <DynamicNote :key="item.id" :note="item.note" :class="$style.note"/> </MkDateSeparatedList> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 479774faef..28fd593893 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -4,99 +4,131 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <MkSwiper v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'overview'" class="_gaps_m"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> + <div v-if="instance"> + <!-- This empty div is preserved to avoid merge conflicts --> + <div> + <div v-if="tab === 'overview'" class="_gaps"> <div class="fnfelxur"> - <img :src="faviconUrl" alt="" class="icon"/> - <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> - </div> - <div style="display: flex; flex-direction: column; gap: 1em;"> - <MkKeyValue :copy="host" oneline> - <template #key>Host</template> - <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.software }}</template> - <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> - </MkKeyValue> + <!-- TODO copy the alt text stuff from reports UI PR --> + <img v-if="faviconUrl" :src="faviconUrl" alt="" class="icon"/> + <div :class="$style.headerData"> + <span class="name">{{ instance.name || instance.host }}</span> + <span> + <span class="_monospace">{{ instance.host }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(instance.host)"><i class="ti ti-copy"></i></button> + </span> + <span> + <span class="_monospace">{{ instance.id }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(instance.id)"><i class="ti ti-copy"></i></button> + </span> + </div> </div> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ instance.description }}</template> - </MkKeyValue> - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <div class="_gaps_s"> - <MkKeyValue> - <template #key> - {{ i18n.ts._delivery.status }} - </template> - <template #value> - {{ i18n.ts._delivery._type[suspensionState] }} - </template> + <SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip> + + <MkFolder :sticky="false"> + <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <MkKeyValue :copy="instance.id" oneline> + <template #key>{{ i18n.ts.id }}</template> + <template #value><span class="_monospace">{{ instance.id }}</span></template> </MkKeyValue> - <div class="_buttons"> - <MkButton inline :disabled="!instance" danger @click="deleteAllFiles">{{ i18n.ts.deleteAllFiles }}</MkButton> - <MkButton inline :disabled="!instance" danger @click="severAllFollowRelations">{{ i18n.ts.severAllFollowRelations }}</MkButton> - </div> + <MkKeyValue :copy="instance.name" oneline> + <template #key>{{ i18n.ts.name }}</template> + <template #value><span class="_monospace">{{ instance.name || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="host" oneline> + <template #key>{{ i18n.ts.host }}</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.firstRetrievedAt" oneline> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.firstRetrievedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.infoUpdatedAt" oneline> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.infoUpdatedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.latestRequestReceivedAt" oneline> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="instance.latestRequestReceivedAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.softwareName" oneline> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.maintainerName" oneline> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value><span class="_monospace">{{ instance.maintainerName || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="instance.maintainerEmail" oneline> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.followingPub }}</template> + <template #value><span class="_monospace"><MkNumber :value="instance.followingCount"/></span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.followersSub }}</template> + <template #value><span class="_monospace"><MkNumber :value="instance.followersCount"/></span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts._delivery.status }}</template> + <template #value><span class="_monospace">{{ i18n.ts._delivery._type[suspensionState] }}</span></template> + </MkKeyValue> + </div> + </MkFolder> + + <MkFolder :sticky="false"> + <template #label>{{ i18n.ts.wellKnownResources }}</template> + <template #icon><i class="ph-network ph-bold ph-lg"></i></template> + <ul :class="$style.linksList" class="_gaps_s"> + <!-- TODO more links here --> + <li><MkLink :url="`https://${host}/.well-known/host-meta`" class="_monospace">/.well-known/host-meta</MkLink></li> + <li><MkLink :url="`https://${host}/.well-known/host-meta.json`" class="_monospace">/.well-known/host-meta.json</MkLink></li> + <li><MkLink :url="`https://${host}/.well-known/nodeinfo`" class="_monospace">/.well-known/nodeinfo</MkLink></li> + <li><MkLink :url="`https://${host}/robots.txt`" class="_monospace">/robots.txt</MkLink></li> + <li><MkLink :url="`https://${host}/manifest.json`" class="_monospace">/manifest.json</MkLink></li> + </ul> + </MkFolder> + + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0" :sticky="false"> + <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.moderationNote }}</template> + <MkTextarea v-model="moderationNote" manualSave @update:modelValue="saveModerationNote"> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + </MkFolder> + + <FormSection v-if="instance.description"> + <template #label>{{ i18n.ts.description }}</template> + {{ instance.description }} + </FormSection> + + <FormSection v-if="iAmModerator"> + <template #label>{{ i18n.ts.moderation }}</template> + <div class="_gaps"> + <MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo> + <MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isSuspended" :disabled="!instance" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch> <MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> - <MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo> - <MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> - <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch> <MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch> + <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch> <MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch> <MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo> <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> - <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> - <MkTextarea v-model="moderationNote" manualSave> - <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> - </div> - </FormSection> - - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.registeredAt }}</template> - <template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.updatedAt }}</template> - <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> - <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> - </MkKeyValue> - </FormSection> - - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Following (Pub)</template> - <template #value>{{ number(instance.followingCount) }}</template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Followers (Sub)</template> - <template #value>{{ number(instance.followersCount) }}</template> - </MkKeyValue> - </FormSection> - <FormSection> - <template #label>Well-known resources</template> - <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> - <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> - <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> - <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> - <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + <div :class="$style.buttonStrip"> + <MkButton inline :disabled="!instance" @click="refreshMetadata"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton> + <MkButton inline :disabled="!instance" danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton> + <MkButton inline :disabled="!instance" danger @click="severAllFollowRelations"><i class="ph-link-break ph-bold ph-lg"></i> {{ i18n.ts.severAllFollowRelations }}</MkButton> + </div> + </div> </FormSection> </div> <div v-else-if="tab === 'chart'" class="_gaps_m"> @@ -126,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'users'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> - <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(user.updatedAt) })" class="user" :to="`/admin/user/${user.id}`"> <MkUserCardMini :user="user"/> </MkA> </MkPagination> @@ -135,11 +167,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :pagination="followingPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.followee.updatedAt)}`" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.followee.updatedAt) })" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> <MkUserCardMini :user="followRelationship.followee" :withChart="false"/> </MkA> <span class="arrow">→</span> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.follower.updatedAt)}`" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.follower.updatedAt) })" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> <MkUserCardMini :user="followRelationship.follower" :withChart="false"/> </MkA> </div> @@ -150,11 +182,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :pagination="followersPagination"> <div class="follow-relations-list"> <div v-for="followRelationship in items" :key="followRelationship.id" class="follow-relation"> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.followee.updatedAt)}`" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.followee.updatedAt) })" :to="`/admin/user/${followRelationship.followee.id}`" class="user"> <MkUserCardMini :user="followRelationship.followee" :withChart="false"/> </MkA> <span class="arrow">←</span> - <MkA v-tooltip.mfm="`Last posted: ${dateString(followRelationship.follower.updatedAt)}`" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> + <MkA v-tooltip.mfm="i18n.tsx.lastPosted({ at: dateString(followRelationship.follower.updatedAt) })" :to="`/admin/user/${followRelationship.follower.id}`" class="user"> <MkUserCardMini :user="followRelationship.follower" :withChart="false"/> </MkA> </div> @@ -165,16 +197,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkObjectView tall :value="instance"> </MkObjectView> </div> - </MkSwiper> + </div> </div> </PageWithHeader> </template> <script lang="ts" setup> -import { ref, computed, watch } from 'vue'; +import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { ChartSrc } from '@/components/MkChart.vue'; import type { Paging } from '@/components/MkPagination.vue'; +import type { Badge } from '@/components/SkBadgeStrip.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; @@ -197,10 +230,19 @@ import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import { $i } from '@/i.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import MkFolder from '@/components/MkFolder.vue'; +import MkNumber from '@/components/MkNumber.vue'; +import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ host: string; -}>(); + metaHint?: Misskey.entities.AdminMetaResponse; + instanceHint?: Misskey.entities.FederationInstance; +}>(), { + metaHint: undefined, + instanceHint: undefined, +}); const tab = ref('overview'); @@ -233,6 +275,55 @@ const isBaseBlocked = computed(() => meta.value && baseDomains.value.some(d => m const isBaseSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.silencedHosts.includes(d))); const isBaseMediaSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.mediaSilencedHosts.includes(d))); +const badges = computed(() => { + const arr: Badge[] = []; + if (instance.value) { + if (instance.value.isBlocked) { + arr.push({ + key: 'blocked', + label: i18n.ts.blocked, + style: 'error', + }); + } + if (instance.value.isSuspended) { + arr.push({ + key: 'suspended', + label: i18n.ts.suspended, + style: 'error', + }); + } + if (instance.value.isSilenced) { + arr.push({ + key: 'silenced', + label: i18n.ts.silenced, + style: 'warning', + }); + } + if (instance.value.isMediaSilenced) { + arr.push({ + key: 'media_silenced', + label: i18n.ts.mediaSilenced, + style: 'warning', + }); + } + if (instance.value.isNSFW) { + arr.push({ + key: 'nsfw', + label: i18n.ts.nsfw, + style: 'warning', + }); + } + if (instance.value.isBubbled) { + arr.push({ + key: 'bubbled', + label: i18n.ts.bubble, + style: 'success', + }); + } + } + return arr; +}); + const usersPagination = { endpoint: iAmModerator ? 'admin/show-users' : 'users', limit: 10, @@ -264,20 +355,30 @@ const followersPagination = { offsetMode: false, }; -if (iAmModerator) { - watch(moderationNote, async () => { - if (instance.value == null) return; - await misskeyApi('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value }); - }); +async function saveModerationNote() { + if (iAmModerator) { + await os.promiseDialog(async () => { + if (instance.value == null) return; + await os.apiWithDialog('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value }); + await fetch(); + }); + } } -async function fetch(): Promise<void> { - if (iAmAdmin) { - meta.value = await misskeyApi('admin/meta'); - } - instance.value = await misskeyApi('federation/show-instance', { - host: props.host, - }); +async function fetch(withHint = false): Promise<void> { + const [m, i] = await Promise.all([ + (withHint && props.metaHint) + ? props.metaHint + : iAmAdmin ? misskeyApi('admin/meta') : null, + (withHint && props.instanceHint) + ? props.instanceHint + : misskeyApi('federation/show-instance', { + host: props.host, + }), + ]); + meta.value = m; + instance.value = i; + suspensionState.value = instance.value?.suspensionState ?? 'none'; isSuspended.value = suspensionState.value !== 'none'; isBlocked.value = instance.value?.isBlocked ?? false; @@ -292,80 +393,106 @@ async function fetch(): Promise<void> { async function toggleBlock(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - await misskeyApi('admin/update-meta', { - blockedHosts: isBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + await os.apiWithDialog('admin/update-meta', { + blockedHosts: isBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), + }); + await fetch(); }); } async function toggleSilenced(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - const silencedHosts = meta.value.silencedHosts ?? []; - await misskeyApi('admin/update-meta', { - silencedHosts: isSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + const silencedHosts = meta.value.silencedHosts ?? []; + await os.promiseDialog(async () => { + await misskeyApi('admin/update-meta', { + silencedHosts: isSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), + }); + await fetch(); + }); }); } async function toggleMediaSilenced(): Promise<void> { if (!iAmAdmin) return; - if (!meta.value) throw new Error('No meta?'); - if (!instance.value) throw new Error('No instance?'); - const { host } = instance.value; - const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; - await misskeyApi('admin/update-meta', { - mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + await os.promiseDialog(async () => { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; + await misskeyApi('admin/update-meta', { + mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + }); + await fetch(); }); } async function toggleSuspended(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - suspensionState.value = isSuspended.value ? 'manuallySuspended' : 'none'; - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - isSuspended: isSuspended.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + suspensionState.value = isSuspended.value ? 'manuallySuspended' : 'none'; + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isSuspended: isSuspended.value, + }); + await fetch(); }); } async function toggleNSFW(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - isNSFW: isNSFW.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isNSFW: isNSFW.value, + }); + await fetch(); }); } async function toggleRejectReports(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - rejectReports: rejectReports.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + rejectReports: rejectReports.value, + }); + await fetch(); }); } async function toggleRejectQuotes(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - await misskeyApi('admin/federation/update-instance', { - host: instance.value.host, - rejectQuotes: rejectQuotes.value, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + rejectQuotes: rejectQuotes.value, + }); + await fetch(); }); } -function refreshMetadata(): void { +async function refreshMetadata(): Promise<void> { if (!iAmModerator) return; - if (!instance.value) throw new Error('No instance?'); - misskeyApi('admin/federation/refresh-remote-instance-metadata', { - host: instance.value.host, + await os.promiseDialog(async () => { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/refresh-remote-instance-metadata', { + host: instance.value.host, + }); + await fetch(); }); - os.alert({ + await os.alert({ text: 'Refresh requested', }); } @@ -380,14 +507,12 @@ async function deleteAllFiles(): Promise<void> { }); if (confirm.canceled) return; - await Promise.all([ - misskeyApi('admin/federation/delete-all-files', { - host: instance.value.host, - }), - os.alert({ - text: i18n.ts.deleteAllFilesQueued, - }), - ]); + await os.apiWithDialog('admin/federation/delete-all-files', { + host: instance.value.host, + }); + await os.alert({ + text: i18n.ts.deleteAllFilesQueued, + }); } async function severAllFollowRelations(): Promise<void> { @@ -404,17 +529,15 @@ async function severAllFollowRelations(): Promise<void> { }); if (confirm.canceled) return; - await Promise.all([ - misskeyApi('admin/federation/remove-all-following', { - host: instance.value.host, - }), - os.alert({ - text: i18n.tsx.severAllFollowRelationsQueued({ host: instance.value.host }), - }), - ]); + await os.apiWithDialog('admin/federation/remove-all-following', { + host: instance.value.host, + }); + await os.alert({ + text: i18n.tsx.severAllFollowRelationsQueued({ host: instance.value.host }), + }); } -fetch(); +fetch(true); const headerActions = computed(() => [{ text: `https://${props.host}`, @@ -429,16 +552,16 @@ const headerTabs = computed(() => [{ title: i18n.ts.overview, icon: 'ti ti-info-circle', }, { - key: 'chart', - title: i18n.ts.charts, - icon: 'ti ti-chart-line', -}, { key: 'users', title: i18n.ts.users, icon: 'ti ti-users', }, ...getFollowingTabs(), { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { key: 'raw', - title: 'Raw', + title: i18n.ts.raw, icon: 'ti ti-code', }]); @@ -522,3 +645,38 @@ definePage(() => ({ } } </style> + +<style lang="scss" module> +.headerData { + display: flex; + flex-direction: column; + + > * { + overflow: hidden; + text-overflow: ellipsis; + font-size: 85%; + opacity: 0.7; + } + + > :first-child { + text-overflow: initial; + word-break: break-all; + font-size: 100%; + opacity: 1.0; + } +} + +.linksList { + margin: 0; + padding-left: 1.5em; +} + +// Sync with admin-user.vue +.buttonStrip { + margin: calc(var(--MI-margin) / 2 * -1); + + >* { + margin: calc(var(--MI-margin) / 2); + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index f275ec9517..7d56743967 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable vue/no-mutating-props */ import { watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { retryOnThrottled } from '@@/js/retry-on-throttled.js'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -35,6 +36,7 @@ import { i18n } from '@/i18n.js'; const props = defineProps<{ modelValue: Misskey.entities.PageBlock & { type: 'note' }; + index: number; }>(); const emit = defineEmits<{ @@ -58,7 +60,13 @@ watch(id, async () => { ...props.modelValue, note: id.value, }); - note.value = await misskeyApi('notes/show', { noteId: id.value }); + const timeoutId = window.setTimeout(async () => { + note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: id.value })); + }, 500 * props.index); // rate limit is 2 reqs per sec + + return () => { + window.clearTimeout(timeoutId); + }; }, { immediate: true, }); diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue index f191320180..8d7ba1a3ab 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -5,10 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{element}"> + <template #item="{element, index}"> <div :class="$style.item"> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/> + <component + :is="getComponent(element.type)" + :modelValue="element" + :index="index" + @update:modelValue="updateItem" + @remove="() => removeItem(element)" + /> </div> </template> </Sortable> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 59b1a5a137..f4d0f25734 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -347,7 +347,7 @@ definePage(() => ({ text-align: center; border-radius: 99rem; - & :global(.ti) { + & :global(.ti), & :global(.ph-lg) { line-height: 2.5rem; } diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index c0c90cb993..1c1adaf687 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -243,13 +243,13 @@ if (game.value.isStarted && !game.value.isEnded) { useInterval(() => { if (game.value.isEnded) return; const crc32 = engine.value.calcCrc32(); - if (_DEV_) console.log('crc32', crc32); + if (_DEV_) console.debug('crc32', crc32); misskeyApi('reversi/verify', { gameId: game.value.id, crc32: crc32.toString(), }).then((res) => { if (res.desynced) { - if (_DEV_) console.log('resynced'); + if (_DEV_) console.debug('resynced'); restoreGame(res.game!); } }); diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index 9b0e04860e..5b9b0d897a 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -22,24 +22,9 @@ import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; -const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? ''); - -async function apply() { - miLocalStorage.setItem('customCss', localCustomCss.value); - - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - -watch(localCustomCss, async () => { - await apply(); -}); +const localCustomCss = prefer.model('customCss'); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index a0a40e4c72..164179d21c 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -15,36 +15,50 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch } from 'vue'; +import { ref, watch, computed } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; const $i = ensureSignin(); const instanceMutes = ref($i.mutedInstances.join('\n')); +const domainArray = computed(() => { + return instanceMutes.value + .trim().split('\n') + .map(el => el.trim().toLowerCase()) + .filter(el => el); +}); const changed = ref(false); async function save() { - let mutes = instanceMutes.value - .trim().split('\n') - .map(el => el.trim()) - .filter(el => el); + // checks for a full line without whitespace. + if (!domainArray.value.every(d => /^\S+$/.test(d))) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + }); + return; + } await misskeyApi('i/update', { - mutedInstances: mutes, + mutedInstances: domainArray.value, }); - changed.value = false; - // Refresh filtered list to signal to the user how they've been saved - instanceMutes.value = mutes.join('\n'); + instanceMutes.value = domainArray.value.join('\n'); + + changed.value = false; } -watch(instanceMutes, () => { - changed.value = true; +watch(domainArray, (newArray, oldArray) => { + // compare arrays + if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) { + changed.value = true; + } }); </script> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 8cc3945df8..e19d7eff85 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -12,10 +12,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <SearchMarker + v-slot="slotProps" :label="i18n.ts.wordMute" :keywords="['note', 'word', 'soft', 'mute', 'hide']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.wordMute }}</template> @@ -37,10 +38,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.hardWordMute" :keywords="['note', 'word', 'hard', 'mute', 'hide']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.hardWordMute }}</template> @@ -55,10 +57,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.instanceMute" :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" > - <MkFolder v-if="instance.federation !== 'none'"> + <MkFolder v-if="instance.federation !== 'none'" :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-planet-off"></i></template> <template #label>{{ i18n.ts.instanceMute }}</template> @@ -67,9 +70,10 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :keywords="['renote', 'mute', 'hide', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-repeat-off"></i></template> <template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template> @@ -102,10 +106,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.mutedUsers" :keywords="['note', 'mute', 'hide', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-eye-off"></i></template> <template #label>{{ i18n.ts.mutedUsers }}</template> @@ -140,10 +145,11 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker + v-slot="slotProps" :label="i18n.ts.blockedUsers" :keywords="['block', 'user']" > - <MkFolder> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> <template #icon><i class="ti ti-ban"></i></template> <template #label>{{ i18n.ts.blockedUsers }}</template> @@ -223,12 +229,6 @@ const expandedBlockItems = ref([]); const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); -watch([ - showSoftWordMutedWord, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - async function unrenoteMute(user, ev) { os.popupMenu([{ text: i18n.ts.renoteUnmute, diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index b85f45884d..84c625b502 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> </div> - <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> + <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji', 'tossface']"> <MkPreferenceContainer k="emojiStyle"> <div> <MkRadios v-model="emojiStyle"> @@ -107,6 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="native">{{ i18n.ts.native }}</option> <option value="fluentEmoji">Fluent Emoji</option> <option value="twemoji">Twemoji</option> + <option value="tossface">Tossface</option> </MkRadios> <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> </div> @@ -237,6 +238,13 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- If one of the other options is selected show this as a blank other --> <option v-if="!useCustomSearchEngine" value="">{{ i18n.ts.searchEngineOther }}</option> </MkSelect> + + <div v-if="useCustomSearchEngine"> + <MkInput v-model="searchEngine" :max="300" :manualSave="true"> + <template #label>{{ i18n.ts.searchEngineCusomURI }}</template> + <template #caption>{{ i18n.ts.searchEngineCustomURIDescription }}</template> + </MkInput> + </div> </MkPreferenceContainer> </SearchMarker> @@ -395,9 +403,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <SearchMarker :keywords="['remember', 'keep', 'note', 'cw']"> <MkPreferenceContainer k="keepCw"> - <MkSwitch v-model="keepCw"> + <MkSelect v-model="keepCw"> <template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template> - </MkSwitch> + <template #caption><SearchKeyword>{{ i18n.ts.keepCwDescription }}</SearchKeyword></template> + <option :value="false">{{ i18n.ts.keepCwDisabled }}</option>> + <option :value="true">{{ i18n.ts.keepCwEnabled }}</option>> + <option value="prepend-re">{{ i18n.ts.keepCwPrependRe }}</option> + </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -675,7 +687,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['font', 'size']"> <MkRadios v-model="fontSize"> <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> - <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="0"><span style="font-size: 14px;">Aa</span></option> <option value="1"><span style="font-size: 15px;">Aa</span></option> <option value="2"><span style="font-size: 16px;">Aa</span></option> <option value="3"><span style="font-size: 17px;">Aa</span></option> @@ -787,7 +799,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['corner', 'radius']"> <MkRadios v-model="cornerRadius"> <template #label><SearchLabel>{{ i18n.ts.cornerRadius }}</SearchLabel></template> - <option :value="null"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> + <option value="sharkey"><i class="sk-icons sk-shark sk-icons-lg" style="top: 2px;position: relative;"></i> Sharkey</option> <option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option> </MkRadios> </SearchMarker> @@ -966,7 +978,6 @@ import { worksOnInstance } from '@/utility/favicon-dot.js'; const $i = ensureSignin(); -const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); const overridedDeviceKind = prefer.model('overridedDeviceKind'); @@ -1026,9 +1037,6 @@ const contextMenu = prefer.model('contextMenu'); const menuStyle = prefer.model('menuStyle'); const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable'); -const fontSize = ref(miLocalStorage.getItem('fontSize')); -const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); - // Sharkey options const collapseNotesRepliedTo = prefer.model('collapseNotesRepliedTo'); const showTickerOnReplies = prefer.model('showTickerOnReplies'); @@ -1044,7 +1052,6 @@ const notificationClickable = prefer.model('notificationClickable'); const warnExternalUrl = prefer.model('warnExternalUrl'); const showVisibilitySelectorOnBoost = prefer.model('showVisibilitySelectorOnBoost'); const visibilityOnBoost = prefer.model('visibilityOnBoost'); -const cornerRadius = ref(miLocalStorage.getItem('cornerRadius')); const oneko = prefer.model('oneko'); const numberOfReplies = prefer.model('numberOfReplies'); const autoloadConversation = prefer.model('autoloadConversation'); @@ -1052,40 +1059,13 @@ const clickToOpen = prefer.model('clickToOpen'); const useCustomSearchEngine = computed(() => !Object.keys(searchEngineMap).includes(searchEngine.value)); const defaultCW = ref($i.defaultCW); const defaultCWPriority = ref($i.defaultCWPriority); - -watch(lang, () => { - miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); -}); - -watch(fontSize, () => { - if (fontSize.value == null) { - miLocalStorage.removeItem('fontSize'); - } else { - miLocalStorage.setItem('fontSize', fontSize.value); - } -}); - -watch(useSystemFont, () => { - if (useSystemFont.value) { - miLocalStorage.setItem('useSystemFont', 't'); - } else { - miLocalStorage.removeItem('useSystemFont'); - } -}); - -watch(cornerRadius, () => { - if (cornerRadius.value == null) { - miLocalStorage.removeItem('cornerRadius'); - } else { - miLocalStorage.setItem('cornerRadius', cornerRadius.value); - } -}); +const lang = prefer.model('lang'); +const fontSize = prefer.model('fontSize'); +const useSystemFont = prefer.model('useSystemFont'); +const cornerRadius = prefer.model('cornerRadius'); watch([ hemisphere, - lang, enableInfiniteScroll, showNoteActionsOnlyHover, overridedDeviceKind, @@ -1107,8 +1087,6 @@ watch([ useStickyIcons, keepScreenOn, contextMenu, - fontSize, - useSystemFont, makeEveryTextElementsSelectable, noteDesign, ], async () => { diff --git a/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue new file mode 100644 index 0000000000..c77870f9d3 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue @@ -0,0 +1,67 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkTextarea v-model="attributionDomains"> + <template #label><SearchLabel>{{ i18n.ts.attributionDomains }}</SearchLabel></template> + <template #caption> + {{ i18n.ts.attributionDomainsDescription }} + <br/> + <Mfm :text="tutorialTag"/> + </template> +</MkTextarea> +<MkButton primary :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</template> + +<script lang="ts" setup> +import { ref, watch, computed } from 'vue'; +import { host as hostRaw } from '@@/js/config.js'; +import { toUnicode } from 'punycode.js'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const attributionDomains = ref($i.attributionDomains.join('\n')); +const domainArray = computed(() => { + return attributionDomains.value + .trim().split('\n') + .map(el => el.trim().toLowerCase()) + .filter(el => el); +}); +const changed = ref(false); +const tutorialTag = '`<meta name="fediverse:creator" content="' + $i.username + '@' + toUnicode(hostRaw) + '" />`'; + +async function save() { + // checks for a full line without whitespace. + if (!domainArray.value.every(d => /^\S+$/.test(d))) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + }); + return; + } + + await misskeyApi('i/update', { + attributionDomains: domainArray.value, + }); + + // Refresh filtered list to signal to the user how they've been saved + attributionDomains.value = domainArray.value.join('\n'); + + changed.value = false; +} + +watch(domainArray, (newArray, oldArray) => { + // compare arrays + if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) { + changed.value = true; + } +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ee4dd1b65a..21bc74326a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -163,6 +163,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.flagAsBotDescription }}</template> </MkSwitch> </SearchMarker> + + <SearchMarker + :label="i18n.ts.attributionDomains" + :keywords="['attribution', 'domains', 'preview', 'url']" + > + <AttributionDomainsSettings/> + </SearchMarker> </div> </MkFolder> </SearchMarker> @@ -172,6 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import AttributionDomainsSettings from './profile.attribution-domains-setting.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue index 5ca487a70b..b38946d64c 100644 --- a/packages/frontend/src/pages/welcome.timeline.note.vue +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -10,14 +10,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div><Mfm :text="note.cw" :author="note.user"/></div> <MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/> <div v-if="showContent"> - <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> + <div> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user"/> + </div> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> </div> <div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]"> - <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> + <div> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user"/> + </div> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <div v-if="note.files && note.files.length > 0" :class="$style.richcontent"> diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index a7bb82a5f0..ccd4472ca2 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -13,15 +13,18 @@ import { deckStore } from '@/ui/deck/deck-store.js'; import { unisonReload } from '@/utility/unison-reload.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage'; // TODO: そのうち消す export function migrateOldSettings() { os.waiting(i18n.ts.settingsMigrating); store.loaded.then(async () => { - misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: Theme[]) => { - if (themes.length > 0) { - prefer.commit('themes', themes); + prefer.suppressReload(); + + await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then(themes => { + if (Array.isArray(themes) && themes.length > 0) { + prefer.commit('themes', themes as Theme[]); } }); @@ -33,7 +36,7 @@ export function migrateOldSettings() { }))); prefer.commit('deck.profile', deckStore.s.profile); - misskeyApi('i/registry/keys', { + await misskeyApi('i/registry/keys', { scope: ['client', 'deck', 'profiles'], }).then(async keys => { const profiles: DeckProfile[] = []; @@ -41,16 +44,18 @@ export function migrateOldSettings() { const deck = await misskeyApi('i/registry/get', { scope: ['client', 'deck', 'profiles'], key: key, - }); - profiles.push({ - id: uuid(), - name: key, - columns: deck.columns, - layout: deck.layout, - }); + }).catch(() => null); + if (deck) { + profiles.push({ + id: uuid(), + name: key, + columns: (deck as DeckProfile).columns, + layout: (deck as DeckProfile).layout, + }); + } } prefer.commit('deck.profiles', profiles); - }); + }).catch(() => null); prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme')); prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme')); @@ -164,8 +169,17 @@ export function migrateOldSettings() { prefer.commit('warnMissingAltText', store.s.warnMissingAltText); //#endregion - window.setTimeout(() => { - unisonReload(); - }, 10000); + //#region Hybrid migrations + prefer.commit('fontSize', miLocalStorage.getItem('fontSize') ?? '0'); + prefer.commit('useSystemFont', miLocalStorage.getItem('useSystemFont') != null); + prefer.commit('cornerRadius', miLocalStorage.getItem('cornerRadius') ?? 'sharkey'); + prefer.commit('lang', miLocalStorage.getItem('lang') ?? 'en-US'); + prefer.commit('customCss', miLocalStorage.getItem('customCss') ?? ''); + prefer.commit('neverShowDonationInfo', miLocalStorage.getItem('neverShowDonationInfo') != null); + prefer.commit('neverShowLocalOnlyInfo', miLocalStorage.getItem('neverShowLocalOnlyInfo') != null); + //#endregion + + prefer.allowReload(); + unisonReload(); }); } diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts index 8d3cbae797..f8a6edcc98 100644 --- a/packages/frontend/src/preferences.ts +++ b/packages/frontend/src/preferences.ts @@ -130,7 +130,7 @@ function syncBetweenTabs() { latestSyncedAt = Date.now(); - if (_DEV_) console.log('prefer:synced'); + if (_DEV_) console.debug('prefer:synced'); } window.setInterval(syncBetweenTabs, 5000); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index a4d52c8acb..f430c4573c 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -10,11 +10,12 @@ import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; import type { DeviceKind } from '@/utility/device-kind.js'; import type { DeckProfile } from '@/deck.js'; -import type { PreferencesDefinition } from './manager.js'; +import type { Pref, PreferencesDefinition } from './manager.js'; import type { FollowingFeedState } from '@/types/following-feed.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; import { searchEngineMap } from '@/utility/search-engine-map.js'; import { defaultFollowingFeedState } from '@/types/following-feed.js'; +import { miLocalStorage } from '@/local-storage'; /** サウンド設定 */ export type SoundStore = { @@ -120,7 +121,7 @@ export const PREF_DEF = { default: false, }, keepCw: { - default: true, + default: true as boolean | 'prepend-re', }, rememberNoteVisibility: { default: false, @@ -477,4 +478,84 @@ export const PREF_DEF = { default: true, }, //#endregion + + //#region hybrid options + // These exist in preferences, but may have a legacy value in local storage. + // Some parts of the system may still reference the legacy storage so both need to stay in sync! + // Null means "fall back to existing value from localStorage" + // For all of these preferences, "null" means fall back to existing value in localStorage. + fontSize: { + default: '0', + needsReload: true, + onSet: fontSize => { + if (fontSize !== '0') { + miLocalStorage.setItem('fontSize', fontSize); + } else { + miLocalStorage.removeItem('fontSize'); + } + }, + } as Pref<'0' | '1' | '2' | '3'>, + useSystemFont: { + default: false, + needsReload: true, + onSet: useSystemFont => { + if (useSystemFont) { + miLocalStorage.setItem('useSystemFont', 't'); + } else { + miLocalStorage.removeItem('useSystemFont'); + } + }, + } as Pref<boolean>, + cornerRadius: { + default: 'sharkey', + needsReload: true, + onSet: cornerRadius => { + if (cornerRadius === 'sharkey') { + miLocalStorage.removeItem('cornerRadius'); + } else { + miLocalStorage.setItem('cornerRadius', cornerRadius); + } + }, + } as Pref<'misskey' | 'sharkey'>, + lang: { + default: 'en-US', + needsReload: true, + onSet: lang => { + miLocalStorage.setItem('lang', lang); + miLocalStorage.removeItem('locale'); + miLocalStorage.removeItem('localeVersion'); + }, + } as Pref<string>, + customCss: { + default: '', + needsReload: true, + onSet: customCss => { + if (customCss) { + miLocalStorage.setItem('customCss', customCss); + } else { + miLocalStorage.removeItem('customCss'); + } + }, + } as Pref<string>, + neverShowDonationInfo: { + default: false, + onSet: neverShowDonationInfo => { + if (neverShowDonationInfo) { + miLocalStorage.setItem('neverShowDonationInfo', 'true'); + } else { + miLocalStorage.removeItem('neverShowDonationInfo'); + } + }, + } as Pref<boolean>, + neverShowLocalOnlyInfo: { + default: false, + onSet: neverShowLocalOnlyInfo => { + if (neverShowLocalOnlyInfo) { + miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true'); + } else { + miLocalStorage.removeItem('neverShowLocalOnlyInfo'); + } + }, + } as Pref<boolean>, + //#endregion } satisfies PreferencesDefinition; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index f96aa2f368..349040d98e 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, onUnmounted, ref, watch } from 'vue'; +import { computed, nextTick, onUnmounted, ref, watch } from 'vue'; import { v4 as uuid } from 'uuid'; import { host, version } from '@@/js/config.js'; import { PREF_DEF } from './def.js'; @@ -14,6 +14,7 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { deepEqual } from '@/utility/deep-equal.js'; +import { reloadAsk } from '@/utility/reload-ask'; // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない @@ -84,16 +85,29 @@ export type StorageProvider = { cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>; }; -export type PreferencesDefinition = Record<string, { - default: any; +export type Pref<T> = { + default: T; accountDependent?: boolean; serverDependent?: boolean; -}>; + needsReload?: boolean; + onSet?: (value: T) => void; +}; + +export type PreferencesDefinition = Record<string, Pref<any> | undefined>; export class PreferencesManager { private storageProvider: StorageProvider; public profile: PreferencesProfile; public cloudReady: Promise<void>; + private enableReload = true; + + public suppressReload() { + this.enableReload = false; + } + + public allowReload() { + this.enableReload = true; + } /** * static / state の略 (static が予約語のため) @@ -126,11 +140,11 @@ export class PreferencesManager { } private isAccountDependentKey<K extends keyof PREF>(key: K): boolean { - return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true; + return (PREF_DEF as PreferencesDefinition)[key]?.accountDependent === true; } private isServerDependentKey<K extends keyof PREF>(key: K): boolean { - return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true; + return (PREF_DEF as PreferencesDefinition)[key]?.serverDependent === true; } private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) { @@ -142,14 +156,28 @@ export class PreferencesManager { const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 if (deepEqual(this.s[key], v)) { - if (_DEV_) console.log('(skip) prefer:commit', key, v); + if (_DEV_) console.debug('(skip) prefer:commit', key, v); return; } - if (_DEV_) console.log('prefer:commit', key, v); + if (_DEV_) console.debug('prefer:commit', key, v); this.rewriteRawState(key, v); + const pref = (PREF_DEF as PreferencesDefinition)[key]; + if (pref) { + // Call custom setter + if (pref.onSet) { + pref.onSet(v); + } + + // Prompt to reload the frontend + if (pref.needsReload && this.enableReload) { + // noinspection JSIgnoredPromiseFromCall + nextTick(() => reloadAsk({ unison: true })); + } + } + const record = this.getMatchedRecordOf(key); if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) { @@ -250,13 +278,13 @@ export class PreferencesManager { if (!deepEqual(cloudValue, record[1])) { this.rewriteRawState(key, cloudValue); record[1] = cloudValue; - if (_DEV_) console.log('cloud fetched', key, cloudValue); + if (_DEV_) console.debug('cloud fetched', key, cloudValue); } } } this.save(); - if (_DEV_) console.log('cloud fetch completed'); + if (_DEV_) console.debug('cloud fetch completed'); } public static newProfile(): PreferencesProfile { diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index adba908c3c..d8986ceb52 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -153,7 +153,7 @@ export async function restoreFromCloudBackup() { scope: ['client', 'preferences', 'backups'], }); - if (_DEV_) console.log(keys); + if (_DEV_) console.debug(keys); if (keys.length === 0) { os.alert({ @@ -179,7 +179,7 @@ export async function restoreFromCloudBackup() { key: select.result, }); - if (_DEV_) console.log(profile); + if (_DEV_) console.debug(profile); miLocalStorage.setItem('preferences', JSON.stringify(profile)); miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 7b2638903b..1333b227f3 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -211,7 +211,9 @@ rt { max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-max, 24px) * 2))); margin: var(--MI_SPACER-max, 24px) auto; container-type: inline-size; +} +._spacer > * { /* 子に継承させない */ --MI_SPACER-w: initial; --MI_SPACER-min: initial; @@ -428,6 +430,14 @@ rt { gap: var(--MI-margin); } +/** + * Use with _gaps, _gaps_m, or _gaps_s. + * Place the other class *first*! + */ +._h_gaps { + flex-direction: row; +} + ._buttons { display: flex; gap: 8px; diff --git a/packages/frontend/src/tab-id.ts b/packages/frontend/src/tab-id.ts index 49b69f72d2..0178fc36be 100644 --- a/packages/frontend/src/tab-id.ts +++ b/packages/frontend/src/tab-id.ts @@ -8,4 +8,4 @@ import { v4 as uuid } from 'uuid'; // HMR有効時にバグか知らんけど複数回実行されるのでその対策 export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? uuid(); window.sessionStorage.setItem('TAB_ID', TAB_ID); -if (_DEV_) console.log('TAB_ID', TAB_ID); +if (_DEV_) console.debug('TAB_ID', TAB_ID); diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 7cfbd9df0a..d080f48696 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -67,8 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XUpload v-if="uploads.length > 0"/> -<component - :is="prefer.s.animation ? TransitionGroup : 'div'" +<SkTransitionGroup tag="div" :class="[$style.notifications, { [$style.notificationsPosition_leftTop]: prefer.s.notificationPosition === 'leftTop', @@ -87,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-for="notification in notifications" :key="notification.id" :class="$style.notification" :style="{ pointerEvents: getPointerEvents() }"> <XNotification :notification="notification"/> </div> -</component> +</SkTransitionGroup> <XStreamIndicator/> @@ -115,6 +114,7 @@ import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 6bf0dfc17c..f0c62aa8e2 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="[$style.root, { [$style.iconOnly]: iconOnly }]"> <div :class="$style.body"> <div :class="$style.top"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> <img :src="instance.sidebarLogoUrl && !iconOnly ? instance.sidebarLogoUrl : instance.iconUrl || '/favicon.ico'" alt="" :class="instance.sidebarLogoUrl && !iconOnly ? $style.wideInstanceIcon : $style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> @@ -299,6 +300,18 @@ function menuEdit() { backdrop-filter: var(--MI-blur, blur(8px)); } + .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + .instance { position: relative; display: block; diff --git a/packages/frontend/src/utility/check-animated-mfm.ts b/packages/frontend/src/utility/check-animated-mfm.ts index 2614dfb4f1..371a631af7 100644 --- a/packages/frontend/src/utility/check-animated-mfm.ts +++ b/packages/frontend/src/utility/check-animated-mfm.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { const animatedNodes = mfm.extract(nodes, (node) => { diff --git a/packages/frontend/src/utility/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts index 89a5ce1df8..d518562053 100644 --- a/packages/frontend/src/utility/extract-mentions.ts +++ b/packages/frontend/src/utility/extract-mentions.ts @@ -5,7 +5,7 @@ // test is located in test/extract-mentions -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 diff --git a/packages/frontend/src/utility/extract-preview-urls.ts b/packages/frontend/src/utility/extract-preview-urls.ts index 5fc9c87a32..264359f179 100644 --- a/packages/frontend/src/utility/extract-preview-urls.ts +++ b/packages/frontend/src/utility/extract-preview-urls.ts @@ -3,35 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as config from '@@/js/config.js'; import type * as Misskey from 'misskey-js'; -import type * as mfm from '@transfem-org/sfm-js'; +import type * as mfm from 'mfm-js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { getNoteUrls } from '@/utility/getNoteUrls'; /** * Extracts all previewable URLs from a note. */ export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] { const links = extractUrlFromMfm(contents); - return links.filter(url => - // Remote note - url !== note.url && - url !== note.uri && - // Local note - url !== `${config.url}/notes/${note.id}` && - // Remote reply - url !== note.reply?.url && - url !== note.reply?.uri && - // Local reply - url !== `${config.url}/notes/${note.reply?.id}` && - // Remote renote or quote - url !== note.renote?.url && - url !== note.renote?.uri && - // Local renote or quote - url !== `${config.url}/notes/${note.renote?.id}` && - // Remote renote *of* a quote - url !== note.renote?.renote?.url && - url !== note.renote?.renote?.uri && - // Local renote *of* a quote - url !== `${config.url}/notes/${note.renote?.renote?.id}`); + if (links.length < 0) return []; + + const self = getNoteUrls(note); + return links.filter(url => !self.includes(url)); } diff --git a/packages/frontend/src/utility/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts index 260dba030e..99d80f3624 100644 --- a/packages/frontend/src/utility/extract-url-from-mfm.ts +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; // unique without hash // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index f773149fac..056480a7ef 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -11,7 +11,7 @@ import type { Ref, ShallowRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; +import { instance, policies } from '@/instance.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -342,7 +342,7 @@ export function getNoteMenu(props: { }); } - if ($i.policies.canUseTranslator && instance.translatorAvailable) { + if (policies.value.canUseTranslator && instance.translatorAvailable) { menuItems.push({ icon: 'ti ti-language-hiragana', text: i18n.ts.translate, @@ -497,6 +497,14 @@ export function getNoteMenu(props: { } else { menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.embed)); } + + if (policies.value.canUseTranslator && instance.translatorAvailable) { + menuItems.push({ + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: () => translateNote(appearNote.id, props.translation, props.translating), + }); + } } const noteActions = getPluginHandlers('note_action'); @@ -523,7 +531,7 @@ export function getNoteMenu(props: { } const cleanup = () => { - if (_DEV_) console.log('note menu cleanup', cleanups); + if (_DEV_) console.debug('note menu cleanup', cleanups); for (const cl of cleanups) { cl(); } diff --git a/packages/frontend/src/utility/get-note-versions-menu.ts b/packages/frontend/src/utility/get-note-versions-menu.ts index aac0375640..ec830f3d3f 100644 --- a/packages/frontend/src/utility/get-note-versions-menu.ts +++ b/packages/frontend/src/utility/get-note-versions-menu.ts @@ -54,7 +54,7 @@ export async function getNoteVersionsMenu(props: { note: Misskey.entities.Note } }); const cleanup = () => { - if (_DEV_) console.log('note menu cleanup', cleanups); + if (_DEV_) console.debug('note menu cleanup', cleanups); for (const cl of cleanups) { cl(); } diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 9b7320586a..fde390cece 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -443,7 +443,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router return { menu: menuItems, cleanup: () => { - if (_DEV_) console.log('user menu cleanup', cleanups); + if (_DEV_) console.debug('user menu cleanup', cleanups); for (const cl of cleanups) { cl(); } diff --git a/packages/frontend/src/utility/getNoteUrls.ts b/packages/frontend/src/utility/getNoteUrls.ts new file mode 100644 index 0000000000..efd014cbf0 --- /dev/null +++ b/packages/frontend/src/utility/getNoteUrls.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as config from '@@/js/config.js'; +import type * as Misskey from 'misskey-js'; + +export function getNoteUrls(note: Misskey.entities.Note): string[] { + const urls: string[] = [ + // Any note + `${config.url}/notes/${note.id}`, + ]; + + // Remote note + if (note.url) urls.push(note.url); + if (note.uri) urls.push(note.uri); + + if (note.reply) { + // Any Reply + urls.push(`${config.url}/notes/${note.reply.id}`); + // Remote Reply + if (note.reply.url) urls.push(note.reply.url); + if (note.reply.uri) urls.push(note.reply.uri); + } + + if (note.renote) { + // Any Renote + urls.push(`${config.url}/notes/${note.renote.id}`); + // Remote Renote + if (note.renote.url) urls.push(note.renote.url); + if (note.renote.uri) urls.push(note.renote.uri); + } + + if (note.renote?.renote) { + // Any Quote + urls.push(`${config.url}/notes/${note.renote.renote.id}`); + // Remote Quote + if (note.renote.renote.url) urls.push(note.renote.renote.url); + if (note.renote.renote.uri) urls.push(note.renote.renote.uri); + } + + return urls; +} diff --git a/packages/frontend/src/utility/intl-const.ts b/packages/frontend/src/utility/intl-const.ts index 385f59ec39..cb2bf7c70d 100644 --- a/packages/frontend/src/utility/intl-const.ts +++ b/packages/frontend/src/utility/intl-const.ts @@ -19,7 +19,7 @@ try { }); } catch (err) { console.warn(err); - if (_DEV_) console.log('[Intl] Fallback to en-US'); + if (_DEV_) console.debug('[Intl] Fallback to en-US'); // Fallback to en-US _dateTimeFormat = new Intl.DateTimeFormat('en-US', { @@ -42,7 +42,7 @@ try { _numberFormat = new Intl.NumberFormat(versatileLang); } catch (err) { console.warn(err); - if (_DEV_) console.log('[Intl] Fallback to en-US'); + if (_DEV_) console.debug('[Intl] Fallback to en-US'); // Fallback to en-US _numberFormat = new Intl.NumberFormat('en-US'); diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts index 7c7ea113d4..f49de80231 100644 --- a/packages/frontend/src/utility/reload-ask.ts +++ b/packages/frontend/src/utility/reload-ask.ts @@ -12,6 +12,10 @@ let isReloadConfirming = false; export async function reloadAsk(opts: { unison?: boolean; reason?: string; + type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string; + okText?: string; + cancelText?: string; }) { if (isReloadConfirming) { return; @@ -19,13 +23,12 @@ export async function reloadAsk(opts: { isReloadConfirming = true; - const { canceled } = await os.confirm(opts.reason == null ? { - type: 'info', - text: i18n.ts.reloadConfirm, - } : { - type: 'info', - title: i18n.ts.reloadConfirm, - text: opts.reason, + const { canceled } = await os.confirm({ + type: opts.type ?? 'question', + title: opts.title ?? i18n.ts.reloadConfirm, + text: opts.reason ?? undefined, + okText: opts.okText ?? i18n.ts.yes, + cancelText: opts.cancelText ?? i18n.ts.no, }).finally(() => { isReloadConfirming = false; }); diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index d3f82a37f2..3eba4d3e20 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -134,7 +134,7 @@ export function playMisskeySfx(operationType: OperationType) { if (!succeed && sound.type === '_driveFile_') { // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; - if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); + if (_DEV_) console.debug(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); playMisskeySfxFileInternal({ type: soundName, volume: sound.volume, diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index e1bc9790b9..ef946b11d6 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -4,7 +4,7 @@ */ import { computed } from 'vue'; -import type { Ref } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; export function getDateText(dateInstance: Date) { const date = dateInstance.getDate(); @@ -25,7 +25,7 @@ export type DateSeparetedTimelineItem<T> = { nextText: string; }; -export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ComputedRef<T[]>) { return computed<DateSeparetedTimelineItem<T>[]>(() => { const tl: DateSeparetedTimelineItem<T>[] = []; for (let i = 0; i < items.value.length; i++) { diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index d983376566..799225662b 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwalb"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="instances"> + <SkTransitionGroup v-else tag="div" name="chart" class="instances"> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img :src="getInstanceIcon(instance)" alt=""/> <div class="body"> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkMiniChart class="chart" :src="charts[i].requests.received"/> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </MkContainer> </template> @@ -37,6 +37,7 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const name = 'federation'; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index db09031c33..d5e2c930de 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwala"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="tags"> + <SkTransitionGroup v-else tag="div" name="chart" class="tags"> <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkMiniChart class="chart" :src="stat.chart"/> </div> - </TransitionGroup> + </SkTransitionGroup> </div> </MkContainer> </template> @@ -35,6 +35,7 @@ import MkMiniChart from '@/components/MkMiniChart.vue'; import { misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import SkTransitionGroup from '@/components/SkTransitionGroup.vue'; const name = 'hashtags'; diff --git a/packages/frontend/src/workers/tsconfig.json b/packages/frontend/src/workers/tsconfig.json index 8ee8930465..39ba45ddbb 100644 --- a/packages/frontend/src/workers/tsconfig.json +++ b/packages/frontend/src/workers/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { "lib": ["esnext", "webworker"], + "incremental": true } } diff --git a/packages/frontend/test/tsconfig.json b/packages/frontend/test/tsconfig.json index 98ac45211b..1490a66d20 100644 --- a/packages/frontend/test/tsconfig.json +++ b/packages/frontend/test/tsconfig.json @@ -22,6 +22,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "baseUrl": "./", "paths": { "@/*": ["../src/*"] diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 3c7e5e1da3..0616eee5be 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -23,6 +23,7 @@ "useDefineForClassFields": true, "verbatimModuleSyntax": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/packages/frontend/vite.replaceIcons.ts b/packages/frontend/vite.replaceIcons.ts index ce05fc7e7b..36e6666925 100644 --- a/packages/frontend/vite.replaceIcons.ts +++ b/packages/frontend/vite.replaceIcons.ts @@ -224,6 +224,7 @@ export function pluginReplaceIcons() { 'ti ti-dice-5': 'ph ph-dice-five ph-bold ph-lg', 'ti ti-dots': 'ph-dots-three ph-bold ph-lg', 'ti ti-download': 'ph-download ph-bold ph-lg', + 'ti-download': 'ph-download ph-bold ph-lg', // in custom-emoji-manager.remote.list 'ti ti-edit': 'ph-pencil-simple-line ph-bold ph-lg', 'ti ti-equal-double': 'ph-equals ph-bold ph-lg', 'ti ti-equal-not': 'ph-prohibit ph-bold ph-lg', @@ -258,6 +259,7 @@ export function pluginReplaceIcons() { 'ti ti-home': 'ph-house ph-bold ph-lg', 'ti ti-hourglass-empty': 'ph-hourglass ph-bold ph-lg', 'ti ti-icons': 'ph-squares-four ph-bold ph-lg', + 'ti-icons': 'ph-squares-four ph-bold ph-lg', // in custom-emoji-manager.local.list 'ti ti-id': 'ph-identification-card ph-bold ph-lg', 'ti ti-info-circle': 'ph-info ph-bold ph-lg', 'ti ti-json': 'ph-brackets-curly ph-bold ph-lg', @@ -275,6 +277,7 @@ export function pluginReplaceIcons() { 'ti ti-lock-star': 'ph-shield-star ph-bold ph-lg', 'ti ti-login-2': 'ph-sign-in ph-bold ph-lg', 'ti ti-mail': 'ph-envelope ph-bold ph-lg', + 'ti-mail': 'ph-envelope ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti-map-pin': 'ph-map-pin ph-bold ph-lg', 'ti ti-maximize': 'ph-frame-corners ph-bold ph-lg', 'ti ti-medal': 'ph-trophy ph-bold ph-lg', @@ -359,6 +362,7 @@ export function pluginReplaceIcons() { 'ti ti-text-caption': 'ph-text-indent ph-bold ph-lg', 'ti ti-tool': 'ph-wrench ph-bold ph-lg', 'ti ti-trash': 'ph-trash ph-bold ph-lg', + 'ti-trash': 'ph-trash ph-bold ph-lg', // in custom-emoji-manager.local.list 'ti ti-trophy': 'ph-trophy ph-bold ph-lg', 'ti ti-universe': 'ph-rocket-launch ph-bold ph-lg', 'ti ti-upload': 'ph-upload ph-bold ph-lg', @@ -379,6 +383,7 @@ export function pluginReplaceIcons() { 'ti ti-volume': 'ph-speaker-high ph-bold ph-lg', 'ti ti-volume-3': 'ph-speaker-x ph-bold ph-lg', 'ti ti-webhook': 'ph-webhooks-logo ph-bold ph-lg', + 'ti-webhook': 'ph-webhooks-logo ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti-whirl': 'ph-globe-hemisphere-west ph-bold ph-lg', 'ti ti-window-maximize': 'ph-frame-corners ph-bold ph-lg', 'ti ti-world': 'ph-globe-hemisphere-west ph-bold ph-lg', @@ -389,6 +394,7 @@ export function pluginReplaceIcons() { 'ti ti-world-x': 'ph-planet ph-bold ph-lg', 'ti ti-x': 'ph-x ph-bold ph-lg', 'ti ti-help': 'ph-question ph-bold ph-lg', + 'ti-help': 'ph-question ph-bold ph-lg', // in notification-recipient.item.vue 'ti ti ti-caret-down': 'ph-caret-down ph-bold ph-lg', 'ti ti-chevron-down': 'ph-caret-down ph-bold ph-lg', 'ti ti-accessible': 'ph-person-simple-circle ph-bold ph-lg', |