feat: support translation for profile abount
This commit is contained in:
@@ -42,7 +42,7 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
|
|||||||
)}
|
)}
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toNoteList({ domain: nip05Domain })}
|
to={toNoteList({ domain: nip05Domain })}
|
||||||
className={`hover:underline truncate ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
|
className={`hover:underline truncate text-sm ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
>
|
>
|
||||||
{nip05Domain}
|
{nip05Domain}
|
||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
|
|||||||
@@ -5,19 +5,33 @@ import {
|
|||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
parseContent
|
parseContent
|
||||||
} from '@/lib/content-parser'
|
} from '@/lib/content-parser'
|
||||||
import { useMemo } from 'react'
|
import { detectLanguage } from '@/lib/utils'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
EmbeddedHashtag,
|
EmbeddedHashtag,
|
||||||
EmbeddedMention,
|
EmbeddedMention,
|
||||||
EmbeddedNormalUrl,
|
EmbeddedNormalUrl,
|
||||||
EmbeddedWebsocketUrl
|
EmbeddedWebsocketUrl
|
||||||
} from '../Embedded'
|
} from '../Embedded'
|
||||||
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
|
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { translateText } = useTranslationService()
|
||||||
|
const needTranslation = useMemo(() => {
|
||||||
|
const detected = detectLanguage(about)
|
||||||
|
if (!detected) return false
|
||||||
|
if (detected === 'und') return true
|
||||||
|
return !i18n.language.startsWith(detected)
|
||||||
|
}, [about, i18n.language])
|
||||||
|
const [translatedAbout, setTranslatedAbout] = useState<string | null>(null)
|
||||||
|
const [translating, setTranslating] = useState(false)
|
||||||
const aboutNodes = useMemo(() => {
|
const aboutNodes = useMemo(() => {
|
||||||
if (!about) return null
|
if (!about) return null
|
||||||
|
|
||||||
const nodes = parseContent(about, [
|
const nodes = parseContent(translatedAbout ?? about, [
|
||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
EmbeddedHashtagParser,
|
EmbeddedHashtagParser,
|
||||||
@@ -40,7 +54,60 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
|
|||||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [about])
|
}, [about, translatedAbout])
|
||||||
|
|
||||||
return <div className={className}>{aboutNodes}</div>
|
const handleTranslate = async () => {
|
||||||
|
if (translating || translatedAbout) return
|
||||||
|
setTranslating(true)
|
||||||
|
translateText(about ?? '')
|
||||||
|
.then((translated) => {
|
||||||
|
setTranslatedAbout(translated)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
'Translation failed: ' +
|
||||||
|
(error.message || 'An error occurred while translating the about')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setTranslating(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowOriginal = () => {
|
||||||
|
setTranslatedAbout(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={className}>{aboutNodes}</div>
|
||||||
|
{needTranslation && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{translating ? (
|
||||||
|
<div className="text-muted-foreground">{t('Translating...')}</div>
|
||||||
|
) : translatedAbout === null ? (
|
||||||
|
<button
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTranslate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Translate')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleShowOriginal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Show original')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import {
|
|
||||||
EMAIL_REGEX,
|
|
||||||
EMBEDDED_EVENT_REGEX,
|
|
||||||
EMBEDDED_MENTION_REGEX,
|
|
||||||
EMOJI_REGEX,
|
|
||||||
HASHTAG_REGEX,
|
|
||||||
URL_REGEX,
|
|
||||||
WS_URL_REGEX
|
|
||||||
} from '@/constants'
|
|
||||||
import { useTranslatedEvent } from '@/hooks'
|
import { useTranslatedEvent } from '@/hooks'
|
||||||
import { isSupportedKind } from '@/lib/event'
|
import { isSupportedKind } from '@/lib/event'
|
||||||
import { toTranslation } from '@/lib/link'
|
import { toTranslation } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, detectLanguage } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
import { franc } from 'franc-min'
|
|
||||||
import { Languages, Loader } from 'lucide-react'
|
import { Languages, Loader } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
@@ -29,68 +19,16 @@ export default function TranslateButton({
|
|||||||
}) {
|
}) {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { translate, showOriginalEvent } = useTranslationService()
|
const { translateEvent, showOriginalEvent } = useTranslationService()
|
||||||
const [translating, setTranslating] = useState(false)
|
const [translating, setTranslating] = useState(false)
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
const translatedEvent = useTranslatedEvent(event.id)
|
||||||
const supported = useMemo(() => isSupportedKind(event.kind), [event])
|
const supported = useMemo(() => isSupportedKind(event.kind), [event])
|
||||||
|
|
||||||
const needTranslation = useMemo(() => {
|
const needTranslation = useMemo(() => {
|
||||||
const cleanText = event.content
|
const detected = detectLanguage(event.content)
|
||||||
.replace(URL_REGEX, '')
|
if (!detected) return false
|
||||||
.replace(WS_URL_REGEX, '')
|
if (detected === 'und') return true
|
||||||
.replace(EMAIL_REGEX, '')
|
return !i18n.language.startsWith(detected)
|
||||||
.replace(EMBEDDED_MENTION_REGEX, '')
|
|
||||||
.replace(EMBEDDED_EVENT_REGEX, '')
|
|
||||||
.replace(HASHTAG_REGEX, '')
|
|
||||||
.replace(EMOJI_REGEX, '')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
if (!cleanText) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(cleanText)) {
|
|
||||||
return i18n.language !== 'ja'
|
|
||||||
}
|
|
||||||
if (/[\u0e00-\u0e7f]/.test(cleanText)) {
|
|
||||||
return i18n.language !== 'th'
|
|
||||||
}
|
|
||||||
if (/[\u4e00-\u9fff]/.test(cleanText)) {
|
|
||||||
return i18n.language !== 'zh'
|
|
||||||
}
|
|
||||||
if (/[\u0600-\u06ff]/.test(cleanText)) {
|
|
||||||
return i18n.language !== 'ar'
|
|
||||||
}
|
|
||||||
if (/[\u0400-\u04ff]/.test(cleanText)) {
|
|
||||||
return i18n.language !== 'ru'
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const detectedLang = franc(cleanText)
|
|
||||||
const langMap: { [key: string]: string } = {
|
|
||||||
ara: 'ar', // Arabic
|
|
||||||
deu: 'de', // German
|
|
||||||
eng: 'en', // English
|
|
||||||
spa: 'es', // Spanish
|
|
||||||
fra: 'fr', // French
|
|
||||||
ita: 'it', // Italian
|
|
||||||
jpn: 'ja', // Japanese
|
|
||||||
pol: 'pl', // Polish
|
|
||||||
por: 'pt', // Portuguese
|
|
||||||
rus: 'ru', // Russian
|
|
||||||
cmn: 'zh', // Chinese (Mandarin)
|
|
||||||
zho: 'zh' // Chinese (alternative code)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedLang = langMap[detectedLang]
|
|
||||||
if (!normalizedLang) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return !i18n.language.startsWith(normalizedLang)
|
|
||||||
} catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}, [event, i18n.language])
|
}, [event, i18n.language])
|
||||||
|
|
||||||
if (!supported || !needTranslation) {
|
if (!supported || !needTranslation) {
|
||||||
@@ -101,7 +39,7 @@ export default function TranslateButton({
|
|||||||
if (translating) return
|
if (translating) return
|
||||||
|
|
||||||
setTranslating(true)
|
setTranslating(true)
|
||||||
await translate(event)
|
await translateEvent(event)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
'Translation failed: ' + (error.message || 'An error occurred while translating the note')
|
'Translation failed: ' + (error.message || 'An error occurred while translating the note')
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ export default {
|
|||||||
'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.',
|
'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.',
|
||||||
Continue: 'متابعة',
|
Continue: 'متابعة',
|
||||||
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح',
|
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح',
|
||||||
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}'
|
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}',
|
||||||
|
'Translating...': 'جارٍ الترجمة...',
|
||||||
|
Translate: 'ترجمة',
|
||||||
|
'Show original': 'عرض الأصل'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,9 @@ export default {
|
|||||||
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
|
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
|
||||||
Continue: 'Weiter',
|
Continue: 'Weiter',
|
||||||
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert',
|
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert',
|
||||||
'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden'
|
'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden',
|
||||||
|
'Translating...': 'Übersetze...',
|
||||||
|
Translate: 'Übersetzen',
|
||||||
|
'Show original': 'Original anzeigen'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ export default {
|
|||||||
'Trusted users include people you follow and people they follow.',
|
'Trusted users include people you follow and people they follow.',
|
||||||
Continue: 'Continue',
|
Continue: 'Continue',
|
||||||
'Successfully updated mute list': 'Successfully updated mute list',
|
'Successfully updated mute list': 'Successfully updated mute list',
|
||||||
'No pubkeys found from {url}': 'No pubkeys found from {{url}}'
|
'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
|
||||||
|
'Translating...': 'Translating...',
|
||||||
|
Translate: 'Translate',
|
||||||
|
'Show original': 'Show original'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ export default {
|
|||||||
'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
|
'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
|
||||||
Continue: 'Continuar',
|
Continue: 'Continuar',
|
||||||
'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito',
|
'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito',
|
||||||
'No pubkeys found from {url}': 'No se encontraron pubkeys desde {{url}}'
|
'No pubkeys found from {url}': 'No se encontraron pubkeys desde {{url}}',
|
||||||
|
'Translating...': 'Traduciendo...',
|
||||||
|
Translate: 'Traducir',
|
||||||
|
'Show original': 'Mostrar original'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ export default {
|
|||||||
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes qu’elles suivent.',
|
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes qu’elles suivent.',
|
||||||
Continue: 'Continuer',
|
Continue: 'Continuer',
|
||||||
'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès',
|
'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès',
|
||||||
'No pubkeys found from {url}': 'Aucun pubkey trouvé à partir de {{url}}'
|
'No pubkeys found from {url}': 'Aucun pubkey trouvé à partir de {{url}}',
|
||||||
|
'Translating...': 'Traduction en cours...',
|
||||||
|
Translate: 'Traduire',
|
||||||
|
'Show original': 'Afficher l’original'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ export default {
|
|||||||
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
|
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
|
||||||
Continue: 'Continua',
|
Continue: 'Continua',
|
||||||
'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo',
|
'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo',
|
||||||
'No pubkeys found from {url}': 'Nessun pubkey trovato da {{url}}'
|
'No pubkeys found from {url}': 'Nessun pubkey trovato da {{url}}',
|
||||||
|
'Translating...': 'Traduzione in corso...',
|
||||||
|
Translate: 'Traduci',
|
||||||
|
'Show original': 'Mostra originale'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,9 @@ export default {
|
|||||||
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
|
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
|
||||||
Continue: '続行',
|
Continue: '続行',
|
||||||
'Successfully updated mute list': 'ミュートリストの更新に成功しました',
|
'Successfully updated mute list': 'ミュートリストの更新に成功しました',
|
||||||
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした'
|
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした',
|
||||||
|
'Translating...': '翻訳中...',
|
||||||
|
Translate: '翻訳',
|
||||||
|
'Show original': '原文を表示'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,9 @@ export default {
|
|||||||
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
|
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
|
||||||
Continue: 'Kontynuuj',
|
Continue: 'Kontynuuj',
|
||||||
'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników',
|
'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników',
|
||||||
'No pubkeys found from {url}': 'Nie znaleziono kluczy publicznych z {{url}}'
|
'No pubkeys found from {url}': 'Nie znaleziono kluczy publicznych z {{url}}',
|
||||||
|
'Translating...': 'Tłumaczenie...',
|
||||||
|
Translate: 'Przetłumacz',
|
||||||
|
'Show original': 'Pokaż oryginał'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,9 @@ export default {
|
|||||||
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
|
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
|
||||||
Continue: 'Continuar',
|
Continue: 'Continuar',
|
||||||
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
|
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
|
||||||
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}'
|
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}',
|
||||||
|
'Translating...': 'Traduzindo...',
|
||||||
|
Translate: 'Traduzir',
|
||||||
|
'Show original': 'Mostrar original'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ export default {
|
|||||||
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
|
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
|
||||||
Continue: 'Continuar',
|
Continue: 'Continuar',
|
||||||
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
|
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
|
||||||
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}'
|
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}',
|
||||||
|
'Translating...': 'Traduzindo...',
|
||||||
|
Translate: 'Traduzir',
|
||||||
|
'Show original': 'Mostrar original'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ export default {
|
|||||||
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
|
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
|
||||||
Continue: 'Продолжить',
|
Continue: 'Продолжить',
|
||||||
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей',
|
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей',
|
||||||
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}'
|
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}',
|
||||||
|
'Translating...': 'Перевод...',
|
||||||
|
Translate: 'Перевести',
|
||||||
|
'Show original': 'Показать оригинал'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ export default {
|
|||||||
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
|
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
|
||||||
Continue: 'ดำเนินการต่อ',
|
Continue: 'ดำเนินการต่อ',
|
||||||
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ',
|
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ',
|
||||||
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}'
|
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}',
|
||||||
|
'Translating...': 'กำลังแปล...',
|
||||||
|
Translate: 'แปล',
|
||||||
|
'Show original': 'แสดงต้นฉบับ'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ export default {
|
|||||||
'受信任的用户包括您关注的人和他们关注的人。',
|
'受信任的用户包括您关注的人和他们关注的人。',
|
||||||
Continue: '继续',
|
Continue: '继续',
|
||||||
'Successfully updated mute list': '成功更新屏蔽列表',
|
'Successfully updated mute list': '成功更新屏蔽列表',
|
||||||
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys'
|
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys',
|
||||||
|
'Translating...': '翻译中...',
|
||||||
|
Translate: '翻译',
|
||||||
|
'Show original': '显示原文'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import {
|
||||||
|
EMAIL_REGEX,
|
||||||
|
EMBEDDED_EVENT_REGEX,
|
||||||
|
EMBEDDED_MENTION_REGEX,
|
||||||
|
EMOJI_REGEX,
|
||||||
|
HASHTAG_REGEX,
|
||||||
|
URL_REGEX,
|
||||||
|
WS_URL_REGEX
|
||||||
|
} from '@/constants'
|
||||||
import { clsx, type ClassValue } from 'clsx'
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { franc } from 'franc-min'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
@@ -38,3 +48,65 @@ export function isInViewport(el: HTMLElement) {
|
|||||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectLanguage(text?: string): string | null {
|
||||||
|
if (!text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const cleanText = text
|
||||||
|
.replace(URL_REGEX, '')
|
||||||
|
.replace(WS_URL_REGEX, '')
|
||||||
|
.replace(EMAIL_REGEX, '')
|
||||||
|
.replace(EMBEDDED_MENTION_REGEX, '')
|
||||||
|
.replace(EMBEDDED_EVENT_REGEX, '')
|
||||||
|
.replace(HASHTAG_REGEX, '')
|
||||||
|
.replace(EMOJI_REGEX, '')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (!cleanText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(cleanText)) {
|
||||||
|
return 'ja'
|
||||||
|
}
|
||||||
|
if (/[\u0e00-\u0e7f]/.test(cleanText)) {
|
||||||
|
return 'th'
|
||||||
|
}
|
||||||
|
if (/[\u4e00-\u9fff]/.test(cleanText)) {
|
||||||
|
return 'zh'
|
||||||
|
}
|
||||||
|
if (/[\u0600-\u06ff]/.test(cleanText)) {
|
||||||
|
return 'ar'
|
||||||
|
}
|
||||||
|
if (/[\u0400-\u04ff]/.test(cleanText)) {
|
||||||
|
return 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detectedLang = franc(cleanText)
|
||||||
|
const langMap: { [key: string]: string } = {
|
||||||
|
ara: 'ar', // Arabic
|
||||||
|
deu: 'de', // German
|
||||||
|
eng: 'en', // English
|
||||||
|
spa: 'es', // Spanish
|
||||||
|
fra: 'fr', // French
|
||||||
|
ita: 'it', // Italian
|
||||||
|
jpn: 'ja', // Japanese
|
||||||
|
pol: 'pl', // Polish
|
||||||
|
por: 'pt', // Portuguese
|
||||||
|
rus: 'ru', // Russian
|
||||||
|
cmn: 'zh', // Chinese (Mandarin)
|
||||||
|
zho: 'zh' // Chinese (alternative code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLang = langMap[detectedLang]
|
||||||
|
if (!normalizedLang) {
|
||||||
|
return 'und'
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedLang
|
||||||
|
} catch {
|
||||||
|
return 'und'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { createContext, useContext, useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNostr } from './NostrProvider'
|
import { useNostr } from './NostrProvider'
|
||||||
|
|
||||||
const translatedEventCache: Record<string, Event> = {}
|
const translatedEventCache: Map<string, Event> = new Map()
|
||||||
|
const translatedTextCache: Map<string, string> = new Map()
|
||||||
|
|
||||||
type TTranslationServiceContext = {
|
type TTranslationServiceContext = {
|
||||||
config: TTranslationServiceConfig
|
config: TTranslationServiceConfig
|
||||||
translatedEventIdSet: Set<string>
|
translatedEventIdSet: Set<string>
|
||||||
translate: (event: Event) => Promise<Event | void>
|
translateText: (text: string) => Promise<string>
|
||||||
|
translateEvent: (event: Event) => Promise<Event | void>
|
||||||
getTranslatedEvent: (eventId: string) => Event | null
|
getTranslatedEvent: (eventId: string) => Event | null
|
||||||
showOriginalEvent: (eventId: string) => void
|
showOriginalEvent: (eventId: string) => void
|
||||||
getAccount: () => Promise<TTranslationAccount | void>
|
getAccount: () => Promise<TTranslationAccount | void>
|
||||||
@@ -62,38 +64,46 @@ export function TranslationServiceProvider({ children }: { children: React.React
|
|||||||
|
|
||||||
const getTranslatedEvent = (eventId: string): Event | null => {
|
const getTranslatedEvent = (eventId: string): Event | null => {
|
||||||
const target = i18n.language
|
const target = i18n.language
|
||||||
const cacheKey = eventId + '_' + target
|
const cacheKey = target + '_' + eventId
|
||||||
return translatedEventCache[cacheKey] ?? null
|
return translatedEventCache.get(cacheKey) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const translate = async (event: Event): Promise<Event | void> => {
|
const translate = async (text: string, target: string): Promise<string> => {
|
||||||
if (config.service === 'jumble' && !pubkey) {
|
if (config.service === 'jumble') {
|
||||||
startLogin()
|
return await translation.translate(text, target)
|
||||||
return
|
} else {
|
||||||
|
return await libreTranslate.translate(text, target, config.server, config.api_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const translateText = async (text: string): Promise<string> => {
|
||||||
|
if (!text) {
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = i18n.language
|
const target = i18n.language
|
||||||
const cacheKey = event.id + '_' + target
|
const cacheKey = target + '_' + text
|
||||||
if (translatedEventCache[cacheKey]) {
|
const cache = translatedTextCache.get(cacheKey)
|
||||||
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
if (cache) {
|
||||||
return translatedEventCache[cacheKey]
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === kinds.Highlights) {
|
const translatedText = await translate(text, target)
|
||||||
|
translatedTextCache.set(cacheKey, translatedText)
|
||||||
|
return translatedText
|
||||||
|
}
|
||||||
|
|
||||||
|
const translateHighlightEvent = async (event: Event): Promise<Event> => {
|
||||||
|
const target = i18n.language
|
||||||
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1]
|
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1]
|
||||||
|
if (!event.content && !comment) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
const [translatedContent, translatedComment] = await Promise.all([
|
const [translatedContent, translatedComment] = await Promise.all([
|
||||||
config.service === 'jumble'
|
translate(event.content, target),
|
||||||
? await translation.translate(event.content, target)
|
!!comment && translate(comment, target)
|
||||||
: await libreTranslate.translate(event.content, target, config.server, config.api_key),
|
|
||||||
!!comment &&
|
|
||||||
(config.service === 'jumble'
|
|
||||||
? await translation.translate(comment, target)
|
|
||||||
: await libreTranslate.translate(comment, target, config.server, config.api_key))
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!translatedContent) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const translatedEvent: Event = {
|
const translatedEvent: Event = {
|
||||||
...event,
|
...event,
|
||||||
content: translatedContent
|
content: translatedContent
|
||||||
@@ -103,20 +113,36 @@ export function TranslationServiceProvider({ children }: { children: React.React
|
|||||||
tag[0] === 'comment' ? ['comment', translatedComment] : tag
|
tag[0] === 'comment' ? ['comment', translatedComment] : tag
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
translatedEventCache[cacheKey] = translatedEvent
|
|
||||||
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
||||||
return translatedEvent
|
return translatedEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
const translatedText =
|
const translateEvent = async (event: Event): Promise<Event | void> => {
|
||||||
config.service === 'jumble'
|
if (config.service === 'jumble' && !pubkey) {
|
||||||
? await translation.translate(event.content, target)
|
startLogin()
|
||||||
: await libreTranslate.translate(event.content, target, config.server, config.api_key)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = i18n.language
|
||||||
|
const cacheKey = target + '_' + event.id
|
||||||
|
const cache = translatedEventCache.get(cacheKey)
|
||||||
|
if (cache) {
|
||||||
|
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
let translatedEvent: Event | undefined
|
||||||
|
if (event.kind === kinds.Highlights) {
|
||||||
|
translatedEvent = await translateHighlightEvent(event)
|
||||||
|
} else {
|
||||||
|
const translatedText = await translate(event.content, target)
|
||||||
if (!translatedText) {
|
if (!translatedText) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const translatedEvent: Event = { ...event, content: translatedText }
|
translatedEvent = { ...event, content: translatedText }
|
||||||
translatedEventCache[cacheKey] = translatedEvent
|
}
|
||||||
|
|
||||||
|
translatedEventCache.set(cacheKey, translatedEvent)
|
||||||
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
||||||
return translatedEvent
|
return translatedEvent
|
||||||
}
|
}
|
||||||
@@ -141,7 +167,8 @@ export function TranslationServiceProvider({ children }: { children: React.React
|
|||||||
translatedEventIdSet,
|
translatedEventIdSet,
|
||||||
getAccount,
|
getAccount,
|
||||||
regenerateApiKey,
|
regenerateApiKey,
|
||||||
translate,
|
translateText,
|
||||||
|
translateEvent,
|
||||||
getTranslatedEvent,
|
getTranslatedEvent,
|
||||||
showOriginalEvent,
|
showOriginalEvent,
|
||||||
updateConfig
|
updateConfig
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ class LibreTranslateService {
|
|||||||
target: string,
|
target: string,
|
||||||
server?: string,
|
server?: string,
|
||||||
api_key?: string
|
api_key?: string
|
||||||
): Promise<string | undefined> {
|
): Promise<string> {
|
||||||
|
if (!text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
if (!server) {
|
if (!server) {
|
||||||
throw new Error('LibreTranslate server address is not configured')
|
throw new Error('LibreTranslate server address is not configured')
|
||||||
}
|
}
|
||||||
@@ -27,7 +30,11 @@ class LibreTranslateService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error ?? 'Failed to translate')
|
throw new Error(data.error ?? 'Failed to translate')
|
||||||
}
|
}
|
||||||
return data.translatedText
|
const translatedText = data.translatedText
|
||||||
|
if (!translatedText) {
|
||||||
|
throw new Error('Translation failed')
|
||||||
|
}
|
||||||
|
return translatedText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,14 +60,21 @@ class TranslationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async translate(text: string, target: string): Promise<string | undefined> {
|
async translate(text: string, target: string): Promise<string> {
|
||||||
|
if (!text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await this._fetch({
|
const data = await this._fetch({
|
||||||
path: '/v1/translation/translate',
|
path: '/v1/translation/translate',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ q: text, target })
|
body: JSON.stringify({ q: text, target })
|
||||||
})
|
})
|
||||||
return data.translatedText
|
const translatedText = data.translatedText
|
||||||
|
if (!translatedText) {
|
||||||
|
throw new Error('Translation failed')
|
||||||
|
}
|
||||||
|
return translatedText
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = error instanceof Error ? error.message : ''
|
const errMsg = error instanceof Error ? error.message : ''
|
||||||
throw new Error(errMsg || 'Failed to translate')
|
throw new Error(errMsg || 'Failed to translate')
|
||||||
|
|||||||
Reference in New Issue
Block a user