234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import { FormattedMessage, useIntl } from 'react-intl';
|
|
|
|
import Icon from '@/components/ui/icon';
|
|
import Stack from '@/components/ui/stack';
|
|
import Text from '@/components/ui/text';
|
|
import { useAppSelector } from '@/hooks/use-app-selector';
|
|
import { useFeatures } from '@/hooks/use-features';
|
|
import { useInstance } from '@/hooks/use-instance';
|
|
import { useTranslationLanguages } from '@/queries/instance/use-translation-languages';
|
|
import { useLocalStatusTranslation } from '@/queries/statuses/use-local-status-translation';
|
|
import { useStatusTranslation } from '@/queries/statuses/use-status-translation';
|
|
import {
|
|
useLanguageModelAvailability,
|
|
useLanguageModelAvailabilityActions,
|
|
} from '@/stores/language-model-availability';
|
|
import { useSettings } from '@/stores/settings';
|
|
import { useStatusMeta, useStatusMetaActions } from '@/stores/status-meta';
|
|
|
|
import type { Status } from '@/normalizers/status';
|
|
import type { Instance } from 'pl-api';
|
|
|
|
const canRemoteTranslate = (
|
|
status: ITranslateButton['status'],
|
|
instance: Instance,
|
|
supportedLanguages: Record<string, Array<string>>,
|
|
locale: string,
|
|
isLoggedIn?: boolean,
|
|
) => {
|
|
const { allow_remote: allowRemote, allow_unauthenticated: allowUnauthenticated } =
|
|
instance.pleroma.metadata.translation;
|
|
|
|
if (status.content.length < 0) return false;
|
|
|
|
// TODO: support language detection
|
|
if (status.language === null || locale === status.language || status.content_map?.[locale])
|
|
return false;
|
|
|
|
if (!['public', 'unlisted'].includes(status.visibility)) return false;
|
|
|
|
if (!isLoggedIn && !allowUnauthenticated) return false;
|
|
|
|
if (!status.account.local && !allowRemote) return false;
|
|
|
|
if (!supportedLanguages[status.language]?.includes(locale)) return false;
|
|
|
|
return true;
|
|
};
|
|
|
|
type Availability = Awaited<ReturnType<typeof Translator.availability>>;
|
|
|
|
const localTranslationAvailability = async (
|
|
status: ITranslateButton['status'],
|
|
locale: string,
|
|
): Promise<Availability | false> => {
|
|
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 Translator.availability({
|
|
sourceLanguage: status.language,
|
|
targetLanguage: locale,
|
|
});
|
|
};
|
|
|
|
interface ITranslateButton {
|
|
status: Pick<Status, 'id' | 'account' | 'content' | 'content_map' | 'language' | 'visibility'>;
|
|
}
|
|
|
|
const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
|
|
const intl = useIntl();
|
|
const features = useFeatures();
|
|
const instance = useInstance();
|
|
const settings = useSettings();
|
|
const autoTranslate = settings.autoTranslate;
|
|
const knownLanguages = autoTranslate ? [...settings.knownLanguages, intl.locale] : [intl.locale];
|
|
|
|
const me = useAppSelector((state) => state.me);
|
|
const { data: translationLanguages = {} } = useTranslationLanguages();
|
|
const { fetchTranslation, hideTranslation } = useStatusMetaActions();
|
|
const { fetchLocalTranslation, hideLocalTranslation } = useStatusMetaActions();
|
|
const languageModelAvailability = useLanguageModelAvailability(status.language!, intl.locale);
|
|
const { setLanguageModelAvailability } = useLanguageModelAvailabilityActions();
|
|
const { targetLanguage, localTargetLanguage } = useStatusMeta(status.id);
|
|
|
|
const remoteTranslationQuery = useStatusTranslation(status.id, targetLanguage);
|
|
const localTranslationQuery = useLocalStatusTranslation(status.id, localTargetLanguage);
|
|
|
|
const translationQuery = localTargetLanguage ? localTranslationQuery : remoteTranslationQuery;
|
|
|
|
const [localTranslate, setLocalTranslate] = React.useState<
|
|
Exclude<Availability, 'unavailable'> | 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<HTMLButtonElement> = (e) => {
|
|
e.stopPropagation();
|
|
|
|
if (localTargetLanguage) {
|
|
hideLocalTranslation(status.id);
|
|
return;
|
|
}
|
|
|
|
if (remoteTranslate) {
|
|
if (targetLanguage) {
|
|
hideTranslation(status.id);
|
|
} else {
|
|
fetchTranslation(status.id, intl.locale);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
fetchLocalTranslation(status.id, intl.locale);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (
|
|
translationQuery.data === undefined &&
|
|
settings.autoTranslate &&
|
|
remoteTranslate &&
|
|
status.language !== null &&
|
|
!knownLanguages.includes(status.language)
|
|
) {
|
|
fetchTranslation(status.id, intl.locale);
|
|
}
|
|
}, []);
|
|
|
|
if ((!remoteTranslate && !localTranslate) || translationQuery.data === false) return null;
|
|
|
|
const translationLabel = () => {
|
|
if (translationQuery.data) {
|
|
return <FormattedMessage id='status.show_original' defaultMessage='Show original' />;
|
|
}
|
|
|
|
if (translationQuery.isLoading) {
|
|
if (languageModelAvailability === 'downloading') {
|
|
return (
|
|
<FormattedMessage
|
|
id='status.translating.downloading'
|
|
defaultMessage='Downloading model…'
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <FormattedMessage id='status.translating' defaultMessage='Translating…' />;
|
|
}
|
|
|
|
if (remoteTranslate) {
|
|
return <FormattedMessage id='status.translate' defaultMessage='Translate' />;
|
|
}
|
|
|
|
if (localTranslate && languageModelAvailability !== 'downloadable') {
|
|
return <FormattedMessage id='status.translate.local' defaultMessage='Translate locally' />;
|
|
}
|
|
|
|
return (
|
|
<FormattedMessage
|
|
id='status.translate.download'
|
|
defaultMessage='Download model and translate locally'
|
|
/>
|
|
);
|
|
};
|
|
|
|
const button = (
|
|
<button
|
|
className='flex w-fit items-center gap-1 text-primary-600 hover:underline dark:text-gray-600'
|
|
onClick={handleTranslate}
|
|
>
|
|
<Icon src={require('@phosphor-icons/core/regular/translate.svg')} className='size-4' />
|
|
<span>{translationLabel()}</span>
|
|
{translationQuery.isLoading && (
|
|
<Icon
|
|
src={require('@phosphor-icons/core/regular/circle-notch.svg')}
|
|
className='size-4 animate-spin'
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
if (translationQuery.data) {
|
|
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
|
|
const languageName = languageNames.of(status.language!);
|
|
const provider = translationQuery.data.provider;
|
|
|
|
return (
|
|
<Stack space={3} alignItems='start'>
|
|
{button}
|
|
<Text theme='muted'>
|
|
<FormattedMessage
|
|
id='status.translated_from_with'
|
|
defaultMessage='Translated from {lang} {provider}'
|
|
values={{
|
|
lang: languageName,
|
|
provider: localTargetLanguage ? (
|
|
<FormattedMessage
|
|
id='status.translated_from_with.provider.local'
|
|
defaultMessage='using local model'
|
|
/>
|
|
) : provider ? (
|
|
<FormattedMessage
|
|
id='status.translated_from_with.provider'
|
|
defaultMessage='with {provider}'
|
|
values={{ provider }}
|
|
/>
|
|
) : undefined,
|
|
}}
|
|
/>
|
|
</Text>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return button;
|
|
};
|
|
|
|
export { TranslateButton as default };
|