diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-05-16 20:00:48 -0400 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-05-16 20:00:48 -0400 |
| commit | 2fdec0ce29c5b61f61640123db16d89a72e97de2 (patch) | |
| tree | dbe2ecf2cfdf1a1dbe777a280bfe391b138039a8 /packages/frontend/src | |
| parent | merge: await `reverseConcat` (!1029) (diff) | |
| download | sharkey-2fdec0ce29c5b61f61640123db16d89a72e97de2.tar.gz sharkey-2fdec0ce29c5b61f61640123db16d89a72e97de2.tar.bz2 sharkey-2fdec0ce29c5b61f61640123db16d89a72e97de2.zip | |
add option to put Translate button in the note toolbar
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkNote.vue | 25 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNoteDetailed.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNoteSub.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSubNoteContent.vue | 7 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkNote.vue | 25 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkNoteDetailed.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkNoteSub.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/SkOldNoteWindow.vue | 9 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/preferences.vue | 9 | ||||
| -rw-r--r-- | packages/frontend/src/preferences/def.ts | 3 | ||||
| -rw-r--r-- | packages/frontend/src/utility/get-note-menu.ts | 32 |
11 files changed, 118 insertions, 42 deletions
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index b4977b73bc..f4c33f6ea8 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -86,12 +86,13 @@ SPDX-License-Identifier: AGPL-3.0-only :isBlock="true" class="_selectable" /> - <div v-if="translating || translation" :class="$style.translation"> + <div v-if="translating || translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> + <div v-else-if="translation && translation.text != null"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <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> @@ -163,6 +164,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="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" @mousedown.prevent="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> @@ -219,7 +223,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; @@ -229,7 +233,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 { isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -310,7 +314,7 @@ const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && const isDeleted = ref(false); const renoted = ref(false); const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); @@ -358,6 +362,11 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => { if (renoteCollapsed.value) return; galleryEl.value?.openGallery(); @@ -780,6 +789,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } +async function translate() { + if (props.mock) return; + + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (props.mock) { return; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 4a51cb3641..dcdbfaf53f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -104,12 +104,13 @@ SPDX-License-Identifier: AGPL-3.0-only class="_selectable" /> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> - <div v-if="translating || translation" :class="$style.translation"> + <div v-if="translating || translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> + <div v-else-if="translation && translation.text != null"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <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> @@ -175,6 +176,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="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" @mousedown.prevent="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> @@ -267,7 +271,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; @@ -281,7 +285,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 { isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -337,7 +341,7 @@ const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; @@ -386,6 +390,11 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { if (appearNote.value.cw != null) { @@ -764,6 +773,10 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (!isMyRenote) return; pleaseLogin({ openOnRemote: pleaseLoginContext.value }); diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 7661bebfe7..eb72939bf1 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -123,7 +123,7 @@ const props = withDefaults(defineProps<{ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id); const el = shallowRef<HTMLElement>(); -const translation = ref<any>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 8ac07c012a..e2a8ee4fe6 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -12,12 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> <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> - <div v-if="note.text && translating || note.text && translation" :class="$style.translation"> + <div v-if="note.text && translating || note.text && translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> + <div v-else-if="translation && translation.text != null"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> </div> @@ -55,7 +56,7 @@ import { prefer } from '@/preferences.js'; const props = defineProps<{ note: Misskey.entities.Note; translating?: boolean; - translation?: any; + translation?: Misskey.entities.NotesTranslateResponse | false | null; hideFiles?: boolean; expandAllCws?: boolean; }>(); diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index ab8a3ec4a6..4f8b3a0900 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -88,12 +88,13 @@ SPDX-License-Identifier: AGPL-3.0-only :isAnim="allowAnim" :isBlock="true" /> - <div v-if="translating || translation" :class="$style.translation"> + <div v-if="translating || translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> + <div v-else-if="translation && translation.text != null"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <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> @@ -164,6 +165,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="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" @mousedown.prevent="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> @@ -219,7 +223,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/utility/get-note-menu.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; import { deepClone } from '@/utility/clone.js'; @@ -229,7 +233,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 { isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; import { focusPrev, focusNext } from '@/utility/focus.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; @@ -310,7 +314,7 @@ const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && const isDeleted = ref(false); const renoted = ref(false); const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); @@ -358,6 +362,11 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => { if (renoteCollapsed.value) return; galleryEl.value?.openGallery(); @@ -780,6 +789,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } +async function translate() { + if (props.mock) return; + + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (props.mock) { return; diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index c499855a80..e2a9ba9a40 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -109,12 +109,13 @@ SPDX-License-Identifier: AGPL-3.0-only class="_selectable" /> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> - <div v-if="translating || translation" :class="$style.translation"> + <div v-if="translating || translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else-if="translation"> + <div v-else-if="translation && translation.text != null"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <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> @@ -180,6 +181,9 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="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" @mousedown.prevent="translate()"> + <i class="ti ti-language-hiragana"></i> + </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> @@ -272,7 +276,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js'; import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js'; import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js'; import { useNoteCapture } from '@/use/use-note-capture.js'; @@ -286,7 +290,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 { isEnabledUrlPreview } from '@/instance.js'; +import { instance, isEnabledUrlPreview } from '@/instance.js'; import { getAppearNote } from '@/utility/get-appear-note.js'; import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; @@ -343,7 +347,7 @@ const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; @@ -392,6 +396,11 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, + 't': () => { + if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) { + translate(); + } + }, 'o': () => galleryEl.value?.openGallery(), 'v|enter': () => { if (appearNote.value.cw != null) { @@ -770,6 +779,10 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } +async function translate() { + await translateNote(appearNote.value.id, translation, translating); +} + function showRenoteMenu(): void { if (!isMyRenote) return; pleaseLogin({ openOnRemote: pleaseLoginContext.value }); diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index 4c4fca2450..434af87ed9 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -137,7 +137,7 @@ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibili const hideLine = computed(() => props.detail); const el = shallowRef<HTMLElement>(); -const translation = ref<any>(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const isDeleted = ref(false); const renoted = ref(false); diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index bd5368351b..01339dbab8 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -42,12 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only <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"/> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> - <div v-if="translating || translation" :class="$style.translation"> + <div v-if="translating || translation != null" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation && translation.text != null"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> + <div v-else>{{ i18n.ts.translationFailed }}</div> </div> <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> @@ -151,7 +152,7 @@ const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const showContent = ref(false); -const translation = ref(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translating = ref(false); const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index ff366d699f..a537b6e837 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -271,6 +271,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['footer', 'action', 'translation', 'show']"> + <MkPreferenceContainer k="showTranslationButtonInNoteFooter"> + <MkSwitch v-model="showTranslationButtonInNoteFooter"> + <template #label><SearchLabel>{{ i18n.ts.showTranslationButtonInNoteFooter }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['reaction', 'count', 'show']"> <MkPreferenceContainer k="showReactionsCount"> <MkSwitch v-model="showReactionsCount"> @@ -964,6 +972,7 @@ const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior'); const hemisphere = prefer.model('hemisphere'); const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover'); const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter'); +const showTranslationButtonInNoteFooter = prefer.model('showTranslationButtonInNoteFooter'); const collapseRenotes = prefer.model('collapseRenotes'); const advancedMfm = prefer.model('advancedMfm'); const showReactionsCount = prefer.model('showReactionsCount'); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 277508d79d..a4d52c8acb 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -248,6 +248,9 @@ export const PREF_DEF = { showClipButtonInNoteFooter: { default: false, }, + showTranslationButtonInNoteFooter: { + default: false, + }, reactionsDisplaySize: { default: 'medium' as 'small' | 'medium' | 'large', }, diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index 0326ebdac8..f773149fac 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -176,7 +176,7 @@ function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuIt export function getNoteMenu(props: { note: Misskey.entities.Note; - translation: Ref<Misskey.entities.NotesTranslateResponse | null>; + translation: Ref<Misskey.entities.NotesTranslateResponse | false | null>; translating: Ref<boolean>; isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; @@ -290,17 +290,6 @@ export function getNoteMenu(props: { os.pageWindow(`/notes/${appearNote.id}`); } - async function translate(): Promise<void> { - if (props.translation.value != null) return; - props.translating.value = true; - props.translation.value = await misskeyApi('notes/translate', { - noteId: appearNote.id, - targetLang: miLocalStorage.getItem('lang') ?? navigator.language, - }).finally(() => { - props.translating.value = false; - }); - } - const menuItems: MenuItem[] = []; if ($i) { @@ -357,7 +346,7 @@ export function getNoteMenu(props: { menuItems.push({ icon: 'ti ti-language-hiragana', text: i18n.ts.translate, - action: translate, + action: () => translateNote(appearNote.id, props.translation, props.translating), }); } @@ -697,3 +686,20 @@ export function getRenoteMenu(props: { menu: renoteItems, }; } + +export async function translateNote(noteId: string, translation: Ref<Misskey.entities.NotesTranslateResponse | false | null>, translating: Ref<boolean>): Promise<void> { + if (translating.value || translation.value) return; + translating.value = true; + try { + const targetLang = miLocalStorage.getItem('lang') ?? navigator.language; + translation.value = await misskeyApi('notes/translate', { + noteId, + targetLang, + }); + } catch (err) { + console.error(`Translation failed for ${noteId}: `, err); + translation.value = false; + } finally { + translating.value = false; + } +} |