feat: support translation for profile abount

This commit is contained in:
codytseng
2025-06-29 14:13:18 +08:00
parent e89cfc03e9
commit d3093a1c4e
20 changed files with 290 additions and 133 deletions

View File

@@ -42,7 +42,7 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
)}
<SecondaryPageLink
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}
</SecondaryPageLink>

View File

@@ -5,19 +5,33 @@ import {
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { useMemo } from 'react'
import { detectLanguage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
EmbeddedHashtag,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedWebsocketUrl
} from '../Embedded'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { toast } from 'sonner'
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(() => {
if (!about) return null
const nodes = parseContent(about, [
const nodes = parseContent(translatedAbout ?? about, [
EmbeddedWebsocketUrlParser,
EmbeddedNormalUrlParser,
EmbeddedHashtagParser,
@@ -40,7 +54,60 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
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>
)
}

View File

@@ -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 { isSupportedKind } from '@/lib/event'
import { toTranslation } from '@/lib/link'
import { cn } from '@/lib/utils'
import { cn, detectLanguage } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { franc } from 'franc-min'
import { Languages, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -29,68 +19,16 @@ export default function TranslateButton({
}) {
const { i18n } = useTranslation()
const { push } = useSecondaryPage()
const { translate, showOriginalEvent } = useTranslationService()
const { translateEvent, showOriginalEvent } = useTranslationService()
const [translating, setTranslating] = useState(false)
const translatedEvent = useTranslatedEvent(event.id)
const supported = useMemo(() => isSupportedKind(event.kind), [event])
const needTranslation = useMemo(() => {
const cleanText = event.content
.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 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
}
const detected = detectLanguage(event.content)
if (!detected) return false
if (detected === 'und') return true
return !i18n.language.startsWith(detected)
}, [event, i18n.language])
if (!supported || !needTranslation) {
@@ -101,7 +39,7 @@ export default function TranslateButton({
if (translating) return
setTranslating(true)
await translate(event)
await translateEvent(event)
.catch((error) => {
toast.error(
'Translation failed: ' + (error.message || 'An error occurred while translating the note')

View File

@@ -275,6 +275,9 @@ export default {
'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.',
Continue: 'متابعة',
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح',
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}'
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}',
'Translating...': 'جارٍ الترجمة...',
Translate: 'ترجمة',
'Show original': 'عرض الأصل'
}
}

View File

@@ -282,6 +282,9 @@ export default {
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
Continue: 'Weiter',
'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'
}
}

View File

@@ -275,6 +275,9 @@ export default {
'Trusted users include people you follow and people they follow.',
Continue: 'Continue',
'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'
}
}

View File

@@ -280,6 +280,9 @@ export default {
'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
Continue: 'Continuar',
'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'
}
}

View File

@@ -280,6 +280,9 @@ export default {
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes quelles suivent.',
Continue: 'Continuer',
'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 loriginal'
}
}

View File

@@ -279,6 +279,9 @@ export default {
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
Continue: 'Continua',
'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'
}
}

View File

@@ -277,6 +277,9 @@ export default {
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
Continue: '続行',
'Successfully updated mute list': 'ミュートリストの更新に成功しました',
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした'
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした',
'Translating...': '翻訳中...',
Translate: '翻訳',
'Show original': '原文を表示'
}
}

View File

@@ -278,6 +278,9 @@ export default {
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
Continue: 'Kontynuuj',
'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ł'
}
}

View File

@@ -278,6 +278,9 @@ export default {
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar',
'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'
}
}

View File

@@ -279,6 +279,9 @@ export default {
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar',
'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'
}
}

View File

@@ -280,6 +280,9 @@ export default {
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
Continue: 'Продолжить',
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей',
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}'
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}',
'Translating...': 'Перевод...',
Translate: 'Перевести',
'Show original': 'Показать оригинал'
}
}

View File

@@ -274,6 +274,9 @@ export default {
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
Continue: 'ดำเนินการต่อ',
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ',
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}'
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}',
'Translating...': 'กำลังแปล...',
Translate: 'แปล',
'Show original': 'แสดงต้นฉบับ'
}
}

View File

@@ -275,6 +275,9 @@ export default {
'受信任的用户包括您关注的人和他们关注的人。',
Continue: '继续',
'Successfully updated mute list': '成功更新屏蔽列表',
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys'
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys',
'Translating...': '翻译中...',
Translate: '翻译',
'Show original': '显示原文'
}
}

View File

@@ -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 { franc } from 'franc-min'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
@@ -38,3 +48,65 @@ export function isInViewport(el: HTMLElement) {
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'
}
}

View File

@@ -7,12 +7,14 @@ import { createContext, useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 = {
config: TTranslationServiceConfig
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
showOriginalEvent: (eventId: string) => void
getAccount: () => Promise<TTranslationAccount | void>
@@ -62,61 +64,85 @@ export function TranslationServiceProvider({ children }: { children: React.React
const getTranslatedEvent = (eventId: string): Event | null => {
const target = i18n.language
const cacheKey = eventId + '_' + target
return translatedEventCache[cacheKey] ?? null
const cacheKey = target + '_' + eventId
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') {
return await translation.translate(text, target)
} 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 cacheKey = target + '_' + text
const cache = translatedTextCache.get(cacheKey)
if (cache) {
return cache
}
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]
if (!event.content && !comment) {
return event
}
const [translatedContent, translatedComment] = await Promise.all([
translate(event.content, target),
!!comment && translate(comment, target)
])
const translatedEvent: Event = {
...event,
content: translatedContent
}
if (translatedComment) {
translatedEvent.tags = event.tags.map((tag) =>
tag[0] === 'comment' ? ['comment', translatedComment] : tag
)
}
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent
}
const translateEvent = async (event: Event): Promise<Event | void> => {
if (config.service === 'jumble' && !pubkey) {
startLogin()
return
}
const target = i18n.language
const cacheKey = event.id + '_' + target
if (translatedEventCache[cacheKey]) {
const cacheKey = target + '_' + event.id
const cache = translatedEventCache.get(cacheKey)
if (cache) {
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEventCache[cacheKey]
return cache
}
let translatedEvent: Event | undefined
if (event.kind === kinds.Highlights) {
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1]
const [translatedContent, translatedComment] = await Promise.all([
config.service === 'jumble'
? await translation.translate(event.content, 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) {
translatedEvent = await translateHighlightEvent(event)
} else {
const translatedText = await translate(event.content, target)
if (!translatedText) {
return
}
const translatedEvent: Event = {
...event,
content: translatedContent
}
if (translatedComment) {
translatedEvent.tags = event.tags.map((tag) =>
tag[0] === 'comment' ? ['comment', translatedComment] : tag
)
}
translatedEventCache[cacheKey] = translatedEvent
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent
translatedEvent = { ...event, content: translatedText }
}
const translatedText =
config.service === 'jumble'
? await translation.translate(event.content, target)
: await libreTranslate.translate(event.content, target, config.server, config.api_key)
if (!translatedText) {
return
}
const translatedEvent: Event = { ...event, content: translatedText }
translatedEventCache[cacheKey] = translatedEvent
translatedEventCache.set(cacheKey, translatedEvent)
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent
}
@@ -141,7 +167,8 @@ export function TranslationServiceProvider({ children }: { children: React.React
translatedEventIdSet,
getAccount,
regenerateApiKey,
translate,
translateText,
translateEvent,
getTranslatedEvent,
showOriginalEvent,
updateConfig

View File

@@ -13,7 +13,10 @@ class LibreTranslateService {
target: string,
server?: string,
api_key?: string
): Promise<string | undefined> {
): Promise<string> {
if (!text) {
return text
}
if (!server) {
throw new Error('LibreTranslate server address is not configured')
}
@@ -27,7 +30,11 @@ class LibreTranslateService {
if (!response.ok) {
throw new Error(data.error ?? 'Failed to translate')
}
return data.translatedText
const translatedText = data.translatedText
if (!translatedText) {
throw new Error('Translation failed')
}
return translatedText
}
}

View File

@@ -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 {
const data = await this._fetch({
path: '/v1/translation/translate',
method: 'POST',
body: JSON.stringify({ q: text, target })
})
return data.translatedText
const translatedText = data.translatedText
if (!translatedText) {
throw new Error('Translation failed')
}
return translatedText
} catch (error) {
const errMsg = error instanceof Error ? error.message : ''
throw new Error(errMsg || 'Failed to translate')