feat: translation (#389)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
@@ -19,8 +20,8 @@ import { Event } from 'nostr-tools'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
EmbeddedHashtag,
|
||||
EmbeddedMention,
|
||||
EmbeddedLNInvoice,
|
||||
EmbeddedMention,
|
||||
EmbeddedNormalUrl,
|
||||
EmbeddedNote,
|
||||
EmbeddedWebsocketUrl
|
||||
@@ -31,7 +32,8 @@ import VideoPlayer from '../VideoPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
|
||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||
const nodes = parseContent(event.content, [
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
||||
EmbeddedImageParser,
|
||||
EmbeddedVideoParser,
|
||||
EmbeddedNormalUrlParser,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
@@ -24,19 +25,20 @@ export default function ContentPreview({
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const nodes = useMemo(() => {
|
||||
if (!event) return [{ type: 'text', data: `[${t('Not found the note')}]` }]
|
||||
|
||||
if (event.kind === kinds.Highlights) return []
|
||||
|
||||
return parseContent(event.content, [
|
||||
return parseContent(translatedEvent?.content ?? event.content, [
|
||||
EmbeddedImageParser,
|
||||
EmbeddedVideoParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
}, [event])
|
||||
}, [event, translatedEvent])
|
||||
|
||||
if (event?.kind === kinds.Highlights) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
||||
import { createFakeEvent, isSupportedKind } from '@/lib/event'
|
||||
import { toNjump, toNote } from '@/lib/link'
|
||||
import { isValidPubkey } from '@/lib/pubkey'
|
||||
@@ -13,14 +13,20 @@ import ContentPreview from '../ContentPreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
||||
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
||||
const comment = useMemo(() => event.tags.find((tag) => tag[0] === 'comment')?.[1], [event])
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const comment = useMemo(
|
||||
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1],
|
||||
[event, translatedEvent]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
||||
{comment && <Content event={createFakeEvent({ content: comment })} />}
|
||||
<div className="flex gap-4">
|
||||
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
||||
<div className="italic whitespace-pre-line">{event.content}</div>
|
||||
<div className="italic whitespace-pre-line">
|
||||
{translatedEvent?.content ?? event.content}
|
||||
</div>
|
||||
</div>
|
||||
<HighlightSource event={event} />
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import Highlight from './Highlight'
|
||||
@@ -65,7 +66,10 @@ export default function Note({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
||||
<div className="flex items-center">
|
||||
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
|
||||
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
||||
</div>
|
||||
</div>
|
||||
{parentEventId && (
|
||||
<ParentNotePreview
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex items-center text-muted-foreground hover:text-foreground pl-3 h-full"
|
||||
className="flex items-center text-muted-foreground hover:text-foreground pl-2 h-full"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
>
|
||||
<Ellipsis />
|
||||
|
||||
@@ -14,6 +14,7 @@ import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
@@ -45,7 +46,7 @@ export default function ReplyNote({
|
||||
<UserAvatar userId={event.pubkey} className="shrink-0 h-8 w-8" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<div className="flex gap-2 items-center flex-1 w-0">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
@@ -55,7 +56,10 @@ export default function ReplyNote({
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
<div className="flex items-center shrink-0">
|
||||
<TranslateButton event={event} />
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
</div>
|
||||
{parentEventId && (
|
||||
<ParentNotePreview
|
||||
|
||||
140
src/components/TranslateButton/index.tsx
Normal file
140
src/components/TranslateButton/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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 { 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'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function TranslateButton({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { i18n } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { translate, 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
|
||||
}
|
||||
|
||||
const hasChinese = /[\u4e00-\u9fff]/.test(cleanText)
|
||||
const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(cleanText)
|
||||
const hasArabic = /[\u0600-\u06ff]/.test(cleanText)
|
||||
const hasRussian = /[\u0400-\u04ff]/.test(cleanText)
|
||||
|
||||
if (hasJapanese) return i18n.language !== 'ja'
|
||||
if (hasChinese && !hasJapanese) return i18n.language !== 'zh'
|
||||
|
||||
if (hasArabic) return i18n.language !== 'ar'
|
||||
if (hasRussian) 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])
|
||||
|
||||
if (!supported || !needTranslation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (translating) return
|
||||
|
||||
setTranslating(true)
|
||||
await translate(event)
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
'Translation failed: ' + (error.message || 'An error occurred while translating the note')
|
||||
)
|
||||
if (error.message === 'Insufficient balance.') {
|
||||
push(toTranslation())
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setTranslating(false)
|
||||
})
|
||||
}
|
||||
|
||||
const showOriginal = () => {
|
||||
showOriginalEvent(event.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center text-muted-foreground hover:text-pink-400 px-2 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
|
||||
className
|
||||
)}
|
||||
disabled={translating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (translatedEvent) {
|
||||
showOriginal()
|
||||
} else {
|
||||
handleTranslate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translating ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<Languages className={translatedEvent ? 'text-pink-400 hover:text-pink-400/60' : ''} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user