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')