From e19a8131bfb142a2fbf4ebd21e507033793b3322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 11 Dec 2025 22:36:04 +0100 Subject: [PATCH] pl-fe: Support local post translations using the Translator API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../pl-fe/src/components/status-content.tsx | 18 +-- .../pl-fe/src/components/translate-button.tsx | 105 +++++++++++++++--- packages/pl-fe/src/locales/en.json | 4 + .../statuses/use-local-status-translation.ts | 72 ++++++++++++ .../src/stores/language-model-availability.ts | 34 ++++++ packages/pl-fe/src/stores/status-meta.ts | 13 +++ 6 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 packages/pl-fe/src/queries/statuses/use-local-status-translation.ts create mode 100644 packages/pl-fe/src/stores/language-model-availability.ts diff --git a/packages/pl-fe/src/components/status-content.tsx b/packages/pl-fe/src/components/status-content.tsx index 3f03a3a12..0efa21f99 100644 --- a/packages/pl-fe/src/components/status-content.tsx +++ b/packages/pl-fe/src/components/status-content.tsx @@ -9,6 +9,7 @@ import Text from 'pl-fe/components/ui/text'; import Emojify from 'pl-fe/features/emoji/emojify'; import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container'; import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config'; +import { useLocalStatusTranslation } from 'pl-fe/queries/statuses/use-local-status-translation'; import { useStatusTranslation } from 'pl-fe/queries/statuses/use-status-translation'; import { useSettings } from 'pl-fe/stores/settings'; import { useStatusMeta, useStatusMetaActions } from 'pl-fe/stores/status-meta'; @@ -96,6 +97,7 @@ const StatusContent: React.FC = React.memo(({ const { collapseStatuses, expandStatuses } = useStatusMetaActions(); const statusMeta = useStatusMeta(status.id); const { data: translation } = useStatusTranslation(status.id, statusMeta.targetLanguage); + const { data: localTranslation } = useLocalStatusTranslation(status.id, statusMeta.localTargetLanguage); const withSpoiler = status.spoiler_text?.length > 0; const expanded = !withSpoiler || statusMeta.expanded || false; @@ -134,16 +136,18 @@ const StatusContent: React.FC = React.memo(({ }, [expanded]); const content = useMemo( - (): string => translation - ? translation.content - : (status.content_map && statusMeta.currentLanguage) - ? (status.content_map[statusMeta.currentLanguage] || status.content) - : status.content, - [status.content, translation, statusMeta.currentLanguage], + (): string => localTranslation + ? localTranslation.content + : translation + ? translation.content + : (status.content_map && statusMeta.currentLanguage) + ? (status.content_map[statusMeta.currentLanguage] || status.content) + : status.content, + [status.content, localTranslation, translation, statusMeta.currentLanguage], ); const { content: parsedContent, hashtags } = useMemo(() => { - if (renderMfm && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) { + if (renderMfm && !localTranslation && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) { return { content: , hashtags: [], diff --git a/packages/pl-fe/src/components/translate-button.tsx b/packages/pl-fe/src/components/translate-button.tsx index 68a20c949..0d896c8bf 100644 --- a/packages/pl-fe/src/components/translate-button.tsx +++ b/packages/pl-fe/src/components/translate-button.tsx @@ -8,7 +8,9 @@ import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useFeatures } from 'pl-fe/hooks/use-features'; import { useInstance } from 'pl-fe/hooks/use-instance'; import { useTranslationLanguages } from 'pl-fe/queries/instance/use-translation-languages'; +import { useLocalStatusTranslation } from 'pl-fe/queries/statuses/use-local-status-translation'; import { useStatusTranslation } from 'pl-fe/queries/statuses/use-status-translation'; +import { useLanguageModelAvailability, useLanguageModelAvailabilityActions } from 'pl-fe/stores/language-model-availability'; import { useSettings } from 'pl-fe/stores/settings'; import { useStatusMeta, useStatusMetaActions } from 'pl-fe/stores/status-meta'; @@ -37,6 +39,22 @@ const canRemoteTranslate = (status: ITranslateButton['status'], instance: Instan return true; }; +type Availability = Awaited>; + +const localTranslationAvailability = async (status: ITranslateButton['status'], locale: string): Promise => { + if (!('Translator' in window)) return 'unavailable'; + + if (status.content.length < 0) return false; + + // TODO: support language detection + if (status.language === null || locale === status.language || status.content_map?.[locale]) return false; + + return window.Translator.availability({ + sourceLanguage: status.language, + targetLanguage: locale, + }); +}; + interface ITranslateButton { status: Pick; } @@ -52,20 +70,43 @@ const TranslateButton: React.FC = ({ status }) => { const me = useAppSelector((state) => state.me); const { data: translationLanguages = {} } = useTranslationLanguages(); const { fetchTranslation, hideTranslation } = useStatusMetaActions(); - const { targetLanguage } = useStatusMeta(status.id); + const { fetchLocalTranslation, hideLocalTranslation } = useStatusMetaActions(); + const languageModelAvailability = useLanguageModelAvailability(status.language!, intl.locale); + const { setLanguageModelAvailability } = useLanguageModelAvailabilityActions(); + const { targetLanguage, localTargetLanguage } = useStatusMeta(status.id); - const translationQuery = useStatusTranslation(status.id, targetLanguage); + const remoteTranslationQuery = useStatusTranslation(status.id, targetLanguage); + const localTranslationQuery = useLocalStatusTranslation(status.id, localTargetLanguage); + + const translationQuery = localTargetLanguage ? localTranslationQuery : remoteTranslationQuery; + + const [localTranslate, setLocalTranslate] = React.useState | false>(); const remoteTranslate = features.translations && canRemoteTranslate(status, instance, translationLanguages, intl.locale, !!me); + useEffect(() => { + localTranslationAvailability(status, intl.locale).then((availability) => { + setLocalTranslate(availability === 'unavailable' ? false : availability); + if (availability) setLanguageModelAvailability(status.language!, intl.locale, availability); + }).catch(() => {}); + }, [status.language, intl.locale]); + const handleTranslate: React.MouseEventHandler = (e) => { e.stopPropagation(); - if (targetLanguage) { - hideTranslation(status.id); - } else { - fetchTranslation(status.id, intl.locale); + if (localTargetLanguage) return hideLocalTranslation(status.id); + + if (remoteTranslate) { + if (targetLanguage) { + hideTranslation(status.id); + } else { + fetchTranslation(status.id, intl.locale); + } + + return; } + + fetchLocalTranslation(status.id, intl.locale); }; useEffect(() => { @@ -74,19 +115,49 @@ const TranslateButton: React.FC = ({ status }) => { } }, []); - if (!remoteTranslate || translationQuery.data === false) return null; + if (!remoteTranslate && !localTranslate || translationQuery.data === false) return null; + + const translationLabel = () => { + if (translationQuery.data) { + return ( + + ); + } + + if (translationQuery.isLoading) { + if (languageModelAvailability === 'downloading') { + return ( + + ); + } + + return ( + + ); + } + + if (remoteTranslate) { + return ( + + ); + } + + if (localTranslate && languageModelAvailability !== 'downloadable') { + return ( + + ); + } + + return ( + + ); + }; const button = (