feat: translation (#389)
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -42,6 +42,7 @@
|
|||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
|
"franc-min": "^6.2.0",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
@@ -5480,6 +5481,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/collapse-white-space": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -6370,6 +6381,19 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/franc-min": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/franc-min/-/franc-min-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"trigram-utils": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
|
||||||
@@ -7563,6 +7587,16 @@
|
|||||||
"thenify-all": "^1.0.0"
|
"thenify-all": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/n-gram": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||||
@@ -9402,6 +9436,20 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/trigram-utils": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"collapse-white-space": "^2.0.0",
|
||||||
|
"n-gram": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
|
"franc-min": "^6.2.0",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { NostrProvider } from './providers/NostrProvider'
|
|||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
import { ReplyProvider } from './providers/ReplyProvider'
|
import { ReplyProvider } from './providers/ReplyProvider'
|
||||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||||
|
import { TranslationServiceProvider } from './providers/TranslationServiceProvider'
|
||||||
import { UserTrustProvider } from './providers/UserTrustProvider'
|
import { UserTrustProvider } from './providers/UserTrustProvider'
|
||||||
import { ZapProvider } from './providers/ZapProvider'
|
import { ZapProvider } from './providers/ZapProvider'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export default function App(): JSX.Element {
|
|||||||
<ScreenSizeProvider>
|
<ScreenSizeProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
|
<TranslationServiceProvider>
|
||||||
<FavoriteRelaysProvider>
|
<FavoriteRelaysProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
@@ -45,6 +47,7 @@ export default function App(): JSX.Element {
|
|||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
|
</TranslationServiceProvider>
|
||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</ScreenSizeProvider>
|
</ScreenSizeProvider>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslatedEvent } from '@/hooks'
|
||||||
import {
|
import {
|
||||||
EmbeddedEmojiParser,
|
EmbeddedEmojiParser,
|
||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
@@ -19,8 +20,8 @@ import { Event } from 'nostr-tools'
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
EmbeddedHashtag,
|
EmbeddedHashtag,
|
||||||
EmbeddedMention,
|
|
||||||
EmbeddedLNInvoice,
|
EmbeddedLNInvoice,
|
||||||
|
EmbeddedMention,
|
||||||
EmbeddedNormalUrl,
|
EmbeddedNormalUrl,
|
||||||
EmbeddedNote,
|
EmbeddedNote,
|
||||||
EmbeddedWebsocketUrl
|
EmbeddedWebsocketUrl
|
||||||
@@ -31,7 +32,8 @@ import VideoPlayer from '../VideoPlayer'
|
|||||||
import WebPreview from '../WebPreview'
|
import WebPreview from '../WebPreview'
|
||||||
|
|
||||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
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,
|
EmbeddedImageParser,
|
||||||
EmbeddedVideoParser,
|
EmbeddedVideoParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslatedEvent } from '@/hooks'
|
||||||
import {
|
import {
|
||||||
EmbeddedEmojiParser,
|
EmbeddedEmojiParser,
|
||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
@@ -24,19 +25,20 @@ export default function ContentPreview({
|
|||||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const translatedEvent = useTranslatedEvent(event?.id)
|
||||||
const nodes = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
if (!event) return [{ type: 'text', data: `[${t('Not found the note')}]` }]
|
if (!event) return [{ type: 'text', data: `[${t('Not found the note')}]` }]
|
||||||
|
|
||||||
if (event.kind === kinds.Highlights) return []
|
if (event.kind === kinds.Highlights) return []
|
||||||
|
|
||||||
return parseContent(event.content, [
|
return parseContent(translatedEvent?.content ?? event.content, [
|
||||||
EmbeddedImageParser,
|
EmbeddedImageParser,
|
||||||
EmbeddedVideoParser,
|
EmbeddedVideoParser,
|
||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
EmbeddedEmojiParser
|
EmbeddedEmojiParser
|
||||||
])
|
])
|
||||||
}, [event])
|
}, [event, translatedEvent])
|
||||||
|
|
||||||
if (event?.kind === kinds.Highlights) {
|
if (event?.kind === kinds.Highlights) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
||||||
import { createFakeEvent, isSupportedKind } from '@/lib/event'
|
import { createFakeEvent, isSupportedKind } from '@/lib/event'
|
||||||
import { toNjump, toNote } from '@/lib/link'
|
import { toNjump, toNote } from '@/lib/link'
|
||||||
import { isValidPubkey } from '@/lib/pubkey'
|
import { isValidPubkey } from '@/lib/pubkey'
|
||||||
@@ -13,14 +13,20 @@ import ContentPreview from '../ContentPreview'
|
|||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
||||||
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
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 (
|
return (
|
||||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
||||||
{comment && <Content event={createFakeEvent({ content: comment })} />}
|
{comment && <Content event={createFakeEvent({ content: comment })} />}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
<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>
|
</div>
|
||||||
<HighlightSource event={event} />
|
<HighlightSource event={event} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
|||||||
import ImageGallery from '../ImageGallery'
|
import ImageGallery from '../ImageGallery'
|
||||||
import NoteOptions from '../NoteOptions'
|
import NoteOptions from '../NoteOptions'
|
||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
|
import TranslateButton from '../TranslateButton'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
import Highlight from './Highlight'
|
import Highlight from './Highlight'
|
||||||
@@ -65,8 +66,11 @@ export default function Note({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
|
||||||
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{parentEventId && (
|
{parentEventId && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
eventId={parentEventId}
|
eventId={parentEventId}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function NoteOptions({ event, className }: { event: Event; classN
|
|||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<button
|
<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)}
|
onClick={() => setIsDrawerOpen(true)}
|
||||||
>
|
>
|
||||||
<Ellipsis />
|
<Ellipsis />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import NoteStats from '../NoteStats'
|
|||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import TranslateButton from '../TranslateButton'
|
||||||
|
|
||||||
export default function ReplyNote({
|
export default function ReplyNote({
|
||||||
event,
|
event,
|
||||||
@@ -45,7 +46,7 @@ export default function ReplyNote({
|
|||||||
<UserAvatar userId={event.pubkey} className="shrink-0 h-8 w-8" />
|
<UserAvatar userId={event.pubkey} className="shrink-0 h-8 w-8" />
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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
|
<Username
|
||||||
userId={event.pubkey}
|
userId={event.pubkey}
|
||||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||||
@@ -55,8 +56,11 @@ export default function ReplyNote({
|
|||||||
<FormattedTimestamp timestamp={event.created_at} />
|
<FormattedTimestamp timestamp={event.created_at} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
<TranslateButton event={event} />
|
||||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{parentEventId && (
|
{parentEventId && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
|
||||||
|
|
||||||
export const StorageKey = {
|
export const StorageKey = {
|
||||||
VERSION: 'version',
|
VERSION: 'version',
|
||||||
THEME_SETTING: 'themeSetting',
|
THEME_SETTING: 'themeSetting',
|
||||||
@@ -16,6 +18,7 @@ export const StorageKey = {
|
|||||||
AUTOPLAY: 'autoplay',
|
AUTOPLAY: 'autoplay',
|
||||||
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
|
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
|
||||||
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
|
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
|
||||||
|
TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap',
|
||||||
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
||||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||||
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
||||||
@@ -62,6 +65,8 @@ export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|n
|
|||||||
export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
|
export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
|
||||||
export const HASHTAG_REGEX = /#[\p{L}\p{N}\p{M}_]+/gu
|
export const HASHTAG_REGEX = /#[\p{L}\p{N}\p{M}_]+/gu
|
||||||
export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g
|
export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g
|
||||||
|
export const EMOJI_REGEX =
|
||||||
|
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu
|
||||||
|
|
||||||
export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
|
export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
|
||||||
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './useFetchRelayInfo'
|
|||||||
export * from './useFetchRelayInfos'
|
export * from './useFetchRelayInfos'
|
||||||
export * from './useFetchRelayList'
|
export * from './useFetchRelayList'
|
||||||
export * from './useSearchProfiles'
|
export * from './useSearchProfiles'
|
||||||
|
export * from './useTranslatedEvent'
|
||||||
|
|||||||
21
src/hooks/useTranslatedEvent.tsx
Normal file
21
src/hooks/useTranslatedEvent.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export function useTranslatedEvent(eventId?: string) {
|
||||||
|
const { translatedEventIdSet, getTranslatedEvent } = useTranslationService()
|
||||||
|
const translated = useMemo(() => {
|
||||||
|
return eventId ? translatedEventIdSet.has(eventId) : false
|
||||||
|
}, [eventId, translatedEventIdSet])
|
||||||
|
const [translatedEvent, setTranslatedEvent] = useState<Event | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (translated && eventId) {
|
||||||
|
setTranslatedEvent(getTranslatedEvent(eventId))
|
||||||
|
} else {
|
||||||
|
setTranslatedEvent(null)
|
||||||
|
}
|
||||||
|
}, [translated, eventId])
|
||||||
|
|
||||||
|
return translatedEvent
|
||||||
|
}
|
||||||
@@ -242,6 +242,23 @@ export default {
|
|||||||
Quotes: 'الاقتباسات',
|
Quotes: 'الاقتباسات',
|
||||||
'Lightning Invoice': 'فاتورة Lightning',
|
'Lightning Invoice': 'فاتورة Lightning',
|
||||||
'Bookmark failed': 'فشل في الإشارة المرجعية',
|
'Bookmark failed': 'فشل في الإشارة المرجعية',
|
||||||
'Remove bookmark failed': 'فشل في إزالة الإشارة المرجعية'
|
'Remove bookmark failed': 'فشل في إزالة الإشارة المرجعية',
|
||||||
|
Translation: 'الترجمة',
|
||||||
|
Balance: 'الرصيد',
|
||||||
|
characters: 'الحروف',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'يمكنك استخدام مفتاح API هذا في أي مكان آخر يدعم LibreTranslate. عنوان الخدمة هو {{serviceUrl}}',
|
||||||
|
'Top up': 'إعادة شحن',
|
||||||
|
'Will receive: {n} characters': 'ستتلقى: {{n}} حروف',
|
||||||
|
'Top up {n} sats': 'إعادة شحن {{n}} ساتوشي',
|
||||||
|
'Minimum top up is {n} sats': 'الحد الأدنى لإعادة الشحن هو {{n}} ساتوشي',
|
||||||
|
Service: 'الخدمة',
|
||||||
|
'Reset API key': 'إعادة تعيين مفتاح API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'هل أنت متأكد أنك تريد إعادة تعيين مفتاح API الخاص بك؟ لا يمكن التراجع عن هذا الإجراء.',
|
||||||
|
Warning: 'تحذير',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'مفتاح API الحالي الخاص بك سيصبح غير صالح على الفور، وأي تطبيقات تستخدمه ستتوقف عن العمل حتى تقوم بتحديثها بالمفتاح الجديد.',
|
||||||
|
'Service address': 'عنوان الخدمة'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,23 @@ export default {
|
|||||||
Quotes: 'Zitate',
|
Quotes: 'Zitate',
|
||||||
'Lightning Invoice': 'Lightning-Rechnung',
|
'Lightning Invoice': 'Lightning-Rechnung',
|
||||||
'Bookmark failed': 'Bookmark fehlgeschlagen',
|
'Bookmark failed': 'Bookmark fehlgeschlagen',
|
||||||
'Remove bookmark failed': 'Bookmark entfernen fehlgeschlagen'
|
'Remove bookmark failed': 'Bookmark entfernen fehlgeschlagen',
|
||||||
|
Translation: 'Übersetzung',
|
||||||
|
Balance: 'Guthaben',
|
||||||
|
characters: 'Zeichen',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Du kannst diesen API-Schlüssel überall dort verwenden, wo LibreTranslate unterstützt wird. Die Service-URL ist {{serviceUrl}}',
|
||||||
|
'Top up': 'Aufladen',
|
||||||
|
'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen',
|
||||||
|
'Top up {n} sats': 'Lade {{n}} sats auf',
|
||||||
|
'Minimum top up is {n} sats': 'Minimale Aufladung beträgt {{n}} sats',
|
||||||
|
Service: 'Dienst',
|
||||||
|
'Reset API key': 'API-Schlüssel zurücksetzen',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Bist du sicher, dass du deinen API-Schlüssel zurücksetzen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
Warning: 'Warnung',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Dein aktueller API-Schlüssel wird sofort ungültig, und alle Anwendungen, die ihn verwenden, werden nicht mehr funktionieren, bis du sie mit dem neuen Schlüssel aktualisierst.',
|
||||||
|
'Service address': 'Service-Adresse'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,23 @@ export default {
|
|||||||
Quotes: 'Quotes',
|
Quotes: 'Quotes',
|
||||||
'Lightning Invoice': 'Lightning Invoice',
|
'Lightning Invoice': 'Lightning Invoice',
|
||||||
'Bookmark failed': 'Bookmark failed',
|
'Bookmark failed': 'Bookmark failed',
|
||||||
'Remove bookmark failed': 'Remove bookmark failed'
|
'Remove bookmark failed': 'Remove bookmark failed',
|
||||||
|
Translation: 'Translation',
|
||||||
|
Balance: 'Balance',
|
||||||
|
characters: 'characters',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'You can use this API key anywhere else that supports LibreTranslate. The service URL is {{serviceUrl}}',
|
||||||
|
'Top up': 'Top up',
|
||||||
|
'Will receive: {n} characters': 'Will receive: {{n}} characters',
|
||||||
|
'Top up {n} sats': 'Top up {{n}} sats',
|
||||||
|
'Minimum top up is {n} sats': 'Minimum top up is {{n}} sats',
|
||||||
|
Service: 'Service',
|
||||||
|
'Reset API key': 'Reset API key',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.',
|
||||||
|
Warning: 'Warning',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.',
|
||||||
|
'Service address': 'Service address'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,23 @@ export default {
|
|||||||
Quotes: 'Citas',
|
Quotes: 'Citas',
|
||||||
'Lightning Invoice': 'Factura Lightning',
|
'Lightning Invoice': 'Factura Lightning',
|
||||||
'Bookmark failed': 'Error al marcar',
|
'Bookmark failed': 'Error al marcar',
|
||||||
'Remove bookmark failed': 'Error al quitar marcador'
|
'Remove bookmark failed': 'Error al quitar marcador',
|
||||||
|
Translation: 'Traducción',
|
||||||
|
Balance: 'Saldo',
|
||||||
|
characters: 'caracteres',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}',
|
||||||
|
'Top up': 'Recargar',
|
||||||
|
'Will receive: {n} characters': 'Recibirás: {{n}} caracteres',
|
||||||
|
'Top up {n} sats': 'Recargar {{n}} satoshis',
|
||||||
|
'Minimum top up is {n} sats': 'La recarga mínima es de {{n}} satoshis',
|
||||||
|
Service: 'Servicio',
|
||||||
|
'Reset API key': 'Restablecer clave API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'¿Estás seguro de que deseas restablecer tu clave API? Esta acción no se puede deshacer.',
|
||||||
|
Warning: 'Advertencia',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Tu clave API actual se volverá inválida de inmediato, y cualquier aplicación que la use dejará de funcionar hasta que las actualices con la nueva clave.',
|
||||||
|
'Service address': 'Dirección del servicio'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,23 @@ export default {
|
|||||||
Quotes: 'Citations',
|
Quotes: 'Citations',
|
||||||
'Lightning Invoice': 'Facture Lightning',
|
'Lightning Invoice': 'Facture Lightning',
|
||||||
'Bookmark failed': 'Échec de la mise en favori',
|
'Bookmark failed': 'Échec de la mise en favori',
|
||||||
'Remove bookmark failed': 'Échec de la suppression du favori'
|
'Remove bookmark failed': 'Échec de la suppression du favori',
|
||||||
|
Translation: 'Traduction',
|
||||||
|
Balance: 'Solde',
|
||||||
|
characters: 'caractères',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Vous pouvez utiliser cette clé API ailleurs qui prend en charge LibreTranslate. L’URL du service est {{serviceUrl}}',
|
||||||
|
'Top up': 'Recharger',
|
||||||
|
'Will receive: {n} characters': 'Vous recevrez : {{n}} caractères',
|
||||||
|
'Top up {n} sats': 'Recharger {{n}} sats',
|
||||||
|
'Minimum top up is {n} sats': 'Le rechargement minimum est de {{n}} sats',
|
||||||
|
Service: 'Service',
|
||||||
|
'Reset API key': 'Réinitialiser la clé API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Êtes-vous sûr de vouloir réinitialiser votre clé API ? Cette action ne peut pas être annulée.',
|
||||||
|
Warning: 'Avertissement',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Votre clé API actuelle deviendra immédiatement invalide, et toutes les applications qui l’utilisent cesseront de fonctionner jusqu’à ce que vous les mettiez à jour avec la nouvelle clé.',
|
||||||
|
'Service address': 'Adresse du service'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,23 @@ export default {
|
|||||||
Quotes: 'Citazioni',
|
Quotes: 'Citazioni',
|
||||||
'Lightning Invoice': 'Fattura Lightning',
|
'Lightning Invoice': 'Fattura Lightning',
|
||||||
'Bookmark failed': 'Impossibile aggiungere segnalibro',
|
'Bookmark failed': 'Impossibile aggiungere segnalibro',
|
||||||
'Remove bookmark failed': 'Impossibile rimuovere segnalibro'
|
'Remove bookmark failed': 'Impossibile rimuovere segnalibro',
|
||||||
|
Translation: 'Traduzione',
|
||||||
|
Balance: 'Saldo',
|
||||||
|
characters: 'caratteri',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
"Puoi utilizzare questa chiave API ovunque supporti LibreTranslate. L'URL del servizio è {{serviceUrl}}",
|
||||||
|
'Top up': 'Torna al saldo',
|
||||||
|
'Will receive: {n} characters': 'Riceverai: {{n}} caratteri',
|
||||||
|
'Top up {n} sats': 'Ricarica {{n}} sats',
|
||||||
|
'Minimum top up is {n} sats': 'La ricarica minima è di {{n}} sats',
|
||||||
|
Service: 'Servizio',
|
||||||
|
'Reset API key': 'Reimposta chiave API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Sei sicuro di voler reimpostare la tua chiave API? Questa azione non può essere annullata.',
|
||||||
|
Warning: 'Attenzione',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'La tua attuale chiave API diventerà immediatamente non valida e tutte le applicazioni che la utilizzano smetteranno di funzionare finché non le aggiornerai con la nuova chiave.',
|
||||||
|
'Service address': 'Indirizzo del servizio'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ export default {
|
|||||||
Quotes: '引用',
|
Quotes: '引用',
|
||||||
'Lightning Invoice': 'ライトニングインボイス',
|
'Lightning Invoice': 'ライトニングインボイス',
|
||||||
'Bookmark failed': 'ブックマークに失敗しました',
|
'Bookmark failed': 'ブックマークに失敗しました',
|
||||||
'Remove bookmark failed': 'ブックマークの削除に失敗しました'
|
'Remove bookmark failed': 'ブックマークの削除に失敗しました',
|
||||||
|
Translation: '翻訳',
|
||||||
|
Balance: '残高',
|
||||||
|
characters: '文字',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'このAPIキーは、LibreTranslateをサポートする他の場所でも使用できます。サービスURLは{{serviceUrl}}です',
|
||||||
|
'Top up': 'チャージ',
|
||||||
|
'Will receive: {n} characters': '受け取る文字数: {{n}} 文字',
|
||||||
|
'Top up {n} sats': 'チャージ {{n}} サッツ',
|
||||||
|
'Minimum top up is {n} sats': '最低チャージは {{n}} サッツです',
|
||||||
|
Service: 'サービス',
|
||||||
|
'Reset API key': 'APIキーをリセット',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'APIキーをリセットしますか?この操作は元に戻せません。',
|
||||||
|
Warning: '警告',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'現在のAPIキーはすぐに無効になり、それを使用しているアプリケーションは新しいキーで更新するまで動作しなくなります。',
|
||||||
|
'Service address': 'サービスアドレス'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,23 @@ export default {
|
|||||||
Quotes: 'Cytaty',
|
Quotes: 'Cytaty',
|
||||||
'Lightning Invoice': 'Faktura Lightning',
|
'Lightning Invoice': 'Faktura Lightning',
|
||||||
'Bookmark failed': 'Nie udało się dodać zakładki',
|
'Bookmark failed': 'Nie udało się dodać zakładki',
|
||||||
'Remove bookmark failed': 'Nie udało się usunąć zakładki'
|
'Remove bookmark failed': 'Nie udało się usunąć zakładki',
|
||||||
|
Translation: 'Tłumaczenie',
|
||||||
|
Balance: 'Saldo',
|
||||||
|
characters: 'znaków',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Ten klucz API możesz używać wszędzie tam, gdzie obsługiwane jest LibreTranslate. Adres usługi to {{serviceUrl}}',
|
||||||
|
'Top up': 'Doładuj',
|
||||||
|
'Will receive: {n} characters': 'Otrzymasz: {{n}} znaków',
|
||||||
|
'Top up {n} sats': 'Doładuj {{n}} satsów',
|
||||||
|
'Minimum top up is {n} sats': 'Minimalne doładowanie to {{n}} satsów',
|
||||||
|
Service: 'Usługa',
|
||||||
|
'Reset API key': 'Zresetuj klucz API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Czy na pewno chcesz zresetować swój klucz API? Ta akcja jest nieodwracalna.',
|
||||||
|
Warning: 'Ostrzeżenie',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Twój obecny klucz API stanie się nieaktywny natychmiast, a wszystkie aplikacje korzystające z niego przestaną działać, dopóki nie zaktualizujesz ich nowym kluczem.',
|
||||||
|
'Service address': 'Adres usługi'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,23 @@ export default {
|
|||||||
Quotes: 'Citações',
|
Quotes: 'Citações',
|
||||||
'Lightning Invoice': 'Fatura Lightning',
|
'Lightning Invoice': 'Fatura Lightning',
|
||||||
'Bookmark failed': 'Falha ao favoritar',
|
'Bookmark failed': 'Falha ao favoritar',
|
||||||
'Remove bookmark failed': 'Falha ao remover favorito'
|
'Remove bookmark failed': 'Falha ao remover favorito',
|
||||||
|
Translation: 'Tradução',
|
||||||
|
Balance: 'Saldo',
|
||||||
|
characters: 'caracteres',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Esta chave API pode ser usada em qualquer outro lugar que suporte LibreTranslate. O URL do serviço é {{serviceUrl}}',
|
||||||
|
'Top up': 'Carregar saldo',
|
||||||
|
'Will receive: {n} characters': 'Receberá: {{n}} caracteres',
|
||||||
|
'Top up {n} sats': 'Carregar {{n}} sats',
|
||||||
|
'Minimum top up is {n} sats': 'Carregamento mínimo é {{n}} sats',
|
||||||
|
Service: 'Serviço',
|
||||||
|
'Reset API key': 'Redefinir chave API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Tem certeza de que deseja redefinir sua chave API? Esta ação não pode ser desfeita.',
|
||||||
|
Warning: 'Aviso',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Sua chave API atual se tornará inválida imediatamente, e qualquer aplicativo que a utilize deixará de funcionar até que você os atualize com a nova chave.',
|
||||||
|
'Service address': 'Endereço do serviço'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,23 @@ export default {
|
|||||||
Quotes: 'Citações',
|
Quotes: 'Citações',
|
||||||
'Lightning Invoice': 'Fatura Lightning',
|
'Lightning Invoice': 'Fatura Lightning',
|
||||||
'Bookmark failed': 'Falha ao favoritar',
|
'Bookmark failed': 'Falha ao favoritar',
|
||||||
'Remove bookmark failed': 'Falha ao remover favorito'
|
'Remove bookmark failed': 'Falha ao remover favorito',
|
||||||
|
Translation: 'Tradução',
|
||||||
|
Balance: 'Saldo',
|
||||||
|
characters: 'caracteres',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Esta chave API pode ser usada em qualquer outro lugar que suporte LibreTranslate. O URL do serviço é {{serviceUrl}}',
|
||||||
|
'Top up': 'Carregar',
|
||||||
|
'Will receive: {n} characters': 'Receberá: {{n}} caracteres',
|
||||||
|
'Top up {n} sats': 'Carregar {{n}} sats',
|
||||||
|
'Minimum top up is {n} sats': 'O carregamento mínimo é de {{n}} sats',
|
||||||
|
Service: 'Serviço',
|
||||||
|
'Reset API key': 'Redefinir chave API',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Tem certeza de que deseja redefinir sua chave API? Esta ação não pode ser desfeita.',
|
||||||
|
Warning: 'Aviso',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Sua chave API atual se tornará inválida imediatamente, e qualquer aplicativo que a utilize deixará de funcionar até que você os atualize com a nova chave.',
|
||||||
|
'Service address': 'Endereço do serviço'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,23 @@ export default {
|
|||||||
Quotes: 'Цитаты',
|
Quotes: 'Цитаты',
|
||||||
'Lightning Invoice': 'Lightning-счет',
|
'Lightning Invoice': 'Lightning-счет',
|
||||||
'Bookmark failed': 'Не удалось добавить закладку',
|
'Bookmark failed': 'Не удалось добавить закладку',
|
||||||
'Remove bookmark failed': 'Не удалось удалить закладку'
|
'Remove bookmark failed': 'Не удалось удалить закладку',
|
||||||
|
Translation: 'Перевод',
|
||||||
|
Balance: 'Баланс',
|
||||||
|
characters: 'символов',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'Вы можете использовать этот API-ключ в любом другом месте, которое поддерживает LibreTranslate. URL сервиса: {{serviceUrl}}',
|
||||||
|
'Top up': 'Пополнить',
|
||||||
|
'Will receive: {n} characters': 'Получите: {{n}} символов',
|
||||||
|
'Top up {n} sats': 'Пополнить на {{n}} сатс',
|
||||||
|
'Minimum top up is {n} sats': 'Минимальное пополнение составляет {{n}} сатс',
|
||||||
|
Service: 'Сервис',
|
||||||
|
'Reset API key': 'Сбросить API-ключ',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'Вы уверены, что хотите сбросить ваш API-ключ? Это действие не может быть отменено.',
|
||||||
|
Warning: 'Предупреждение',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'Ваш текущий API-ключ станет недействительным немедленно, и любые приложения, использующие его, перестанут работать, пока вы не обновите их новым ключом.',
|
||||||
|
'Service address': 'Адрес сервиса'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ export default {
|
|||||||
Quotes: '引用',
|
Quotes: '引用',
|
||||||
'Lightning Invoice': '闪电发票',
|
'Lightning Invoice': '闪电发票',
|
||||||
'Bookmark failed': '收藏失败',
|
'Bookmark failed': '收藏失败',
|
||||||
'Remove bookmark failed': '取消收藏失败'
|
'Remove bookmark failed': '取消收藏失败',
|
||||||
|
Translation: '翻译',
|
||||||
|
Balance: '余额',
|
||||||
|
characters: '字符',
|
||||||
|
jumbleTranslateApiKeyDescription:
|
||||||
|
'您可以在任何支持 LibreTranslate 的地方使用此 API key。服务地址是 {{serviceUrl}}',
|
||||||
|
'Top up': '充值',
|
||||||
|
'Will receive: {n} characters': '将获得: {{n}} 字符',
|
||||||
|
'Top up {n} sats': '充值 {{n}} 聪',
|
||||||
|
'Minimum top up is {n} sats': '最低充值金额为 {{n}} 聪',
|
||||||
|
Service: '服务',
|
||||||
|
'Reset API key': '重置 API key',
|
||||||
|
'Are you sure you want to reset your API key? This action cannot be undone.':
|
||||||
|
'您确定要重置您的 API key?此操作无法撤销。',
|
||||||
|
Warning: '警告',
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
||||||
|
'您当前的 API key 将立即失效,任何使用它的应用程序将停止工作,直到您用新 key 更新它们。',
|
||||||
|
'Service address': '服务地址'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
|
|||||||
export const toWallet = () => '/settings/wallet'
|
export const toWallet = () => '/settings/wallet'
|
||||||
export const toPostSettings = () => '/settings/posts'
|
export const toPostSettings = () => '/settings/posts'
|
||||||
export const toGeneralSettings = () => '/settings/general'
|
export const toGeneralSettings = () => '/settings/general'
|
||||||
|
export const toTranslation = () => '/settings/translation'
|
||||||
export const toProfileEditor = () => '/profile-editor'
|
export const toProfileEditor = () => '/profile-editor'
|
||||||
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
||||||
export const toMuteList = () => '/mutes'
|
export const toMuteList = () => '/mutes'
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||||
import Donation from '@/components/Donation'
|
import Donation from '@/components/Donation'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { toGeneralSettings, toPostSettings, toRelaySettings, toWallet } from '@/lib/link'
|
import {
|
||||||
|
toGeneralSettings,
|
||||||
|
toPostSettings,
|
||||||
|
toRelaySettings,
|
||||||
|
toTranslation,
|
||||||
|
toWallet
|
||||||
|
} from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
@@ -11,6 +17,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Info,
|
Info,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Languages,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -42,6 +49,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
<SettingItem className="clickable" onClick={() => push(toTranslation())}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Languages />
|
||||||
|
<div>{t('Translation')}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight />
|
||||||
|
</SettingItem>
|
||||||
<SettingItem className="clickable" onClick={() => push(toWallet())}>
|
<SettingItem className="clickable" onClick={() => push(toWallet())}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Wallet />
|
<Wallet />
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { JUMBLE_API_BASE_URL } from '@/constants'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { Check, Copy, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
|
||||||
|
import RegenerateApiKeyButton from './RegenerateApiKeyButton'
|
||||||
|
import TopUp from './TopUp'
|
||||||
|
|
||||||
|
export function AccountInfo() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey, startLogin } = useNostr()
|
||||||
|
const { account } = useJumbleTranslateAccount()
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<Button onClick={() => startLogin()}>{t('Login')}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Balance display in characters */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">{t('Balance')}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-3xl font-bold">{account?.balance.toLocaleString() ?? '0'}</p>
|
||||||
|
<p className="text-muted-foreground">{t('characters')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key section with visibility toggle and copy functionality */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">API key</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
value={account?.api_key ?? ''}
|
||||||
|
readOnly
|
||||||
|
className="font-mono flex-1 max-w-fit"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={() => setShowApiKey(!showApiKey)}>
|
||||||
|
{showApiKey ? <Eye /> : <EyeOff />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!account?.api_key}
|
||||||
|
onClick={() => {
|
||||||
|
if (!account?.api_key) return
|
||||||
|
navigator.clipboard.writeText(account.api_key)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 4000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check /> : <Copy />}
|
||||||
|
</Button>
|
||||||
|
<RegenerateApiKeyButton />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground select-text">
|
||||||
|
{t('jumbleTranslateApiKeyDescription', {
|
||||||
|
serviceUrl: new URL('/v1/translation', JUMBLE_API_BASE_URL).toString()
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TopUp />
|
||||||
|
<div className="h-40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
|
import { TTranslationAccount } from '@/types'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type TJumbleTranslateAccountContext = {
|
||||||
|
account: TTranslationAccount | null
|
||||||
|
getAccount: () => Promise<void>
|
||||||
|
regenerateApiKey: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JumbleTranslateAccountContext = createContext<
|
||||||
|
TJumbleTranslateAccountContext | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
export const useJumbleTranslateAccount = () => {
|
||||||
|
const context = useContext(JumbleTranslateAccountContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useJumbleTranslateAccount must be used within a JumbleTranslateAccountProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JumbleTranslateAccountProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { getAccount: _getAccount, regenerateApiKey: _regenerateApiKey } = useTranslationService()
|
||||||
|
const [account, setAccount] = useState<TTranslationAccount | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAccount(null)
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
getAccount()
|
||||||
|
}, 100)
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const regenerateApiKey = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!account) {
|
||||||
|
await getAccount()
|
||||||
|
}
|
||||||
|
const newApiKey = await _regenerateApiKey()
|
||||||
|
if (newApiKey) {
|
||||||
|
setAccount((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
api_key: newApiKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
'Failed to regenerate Jumble translation API key: ' +
|
||||||
|
(error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'An error occurred while regenerating the API key')
|
||||||
|
)
|
||||||
|
setAccount(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAccount = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await _getAccount()
|
||||||
|
if (data) {
|
||||||
|
setAccount(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
'Failed to fetch Jumble translation account: ' +
|
||||||
|
(error instanceof Error ? error.message : 'An error occurred while fetching the account')
|
||||||
|
)
|
||||||
|
setAccount(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JumbleTranslateAccountContext.Provider value={{ account, getAccount, regenerateApiKey }}>
|
||||||
|
{children}
|
||||||
|
</JumbleTranslateAccountContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader, RotateCcw } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
|
||||||
|
|
||||||
|
export default function RegenerateApiKeyButton() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { account, regenerateApiKey } = useJumbleTranslateAccount()
|
||||||
|
const [resettingApiKey, setResettingApiKey] = useState(false)
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||||
|
|
||||||
|
const handleRegenerateApiKey = async () => {
|
||||||
|
if (resettingApiKey || !account) return
|
||||||
|
|
||||||
|
setResettingApiKey(true)
|
||||||
|
await regenerateApiKey()
|
||||||
|
setShowResetDialog(false)
|
||||||
|
setResettingApiKey(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" disabled={!account?.api_key}>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Reset API key')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Are you sure you want to reset your API key? This action cannot be undone.')}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<strong>{t('Warning')}:</strong>{' '}
|
||||||
|
{t(
|
||||||
|
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.'
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowResetDialog(false)}
|
||||||
|
disabled={resettingApiKey}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleRegenerateApiKey} disabled={resettingApiKey}>
|
||||||
|
{resettingApiKey && <Loader className="animate-spin" />}
|
||||||
|
{t('Reset API key')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx
Normal file
164
src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import transaction from '@/services/transaction.service'
|
||||||
|
import { closeModal, launchPaymentModal } from '@getalby/bitcoin-connect-react'
|
||||||
|
import { Loader } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function TopUp() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { getAccount } = useJumbleTranslateAccount()
|
||||||
|
const [topUpLoading, setTopUpLoading] = useState(false)
|
||||||
|
const [topUpAmount, setTopUpAmount] = useState(1000)
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState<number | null>(1000)
|
||||||
|
|
||||||
|
const presetAmounts = [
|
||||||
|
{ amount: 1_000, text: '1k' },
|
||||||
|
{ amount: 5_000, text: '5k' },
|
||||||
|
{ amount: 10_000, text: '10k' },
|
||||||
|
{ amount: 25_000, text: '25k' },
|
||||||
|
{ amount: 50_000, text: '50k' },
|
||||||
|
{ amount: 100_000, text: '100k' }
|
||||||
|
]
|
||||||
|
const charactersPerUnit = 100 // 1 unit = 100 characters
|
||||||
|
|
||||||
|
const calculateCharacters = (amount: number) => {
|
||||||
|
return amount * charactersPerUnit
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePresetClick = (amount: number) => {
|
||||||
|
setSelectedAmount(amount)
|
||||||
|
setTopUpAmount(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value) || 0
|
||||||
|
setTopUpAmount(numValue)
|
||||||
|
setSelectedAmount(numValue >= 1000 ? numValue : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTopUp = async (amount: number | null) => {
|
||||||
|
if (topUpLoading || !pubkey || !amount || amount < 1000) return
|
||||||
|
|
||||||
|
setTopUpLoading(true)
|
||||||
|
try {
|
||||||
|
const { transactionId, invoiceId } = await transaction.createTransaction(pubkey, amount)
|
||||||
|
|
||||||
|
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined = undefined
|
||||||
|
const { setPaid } = launchPaymentModal({
|
||||||
|
invoice: invoiceId,
|
||||||
|
onCancelled: () => {
|
||||||
|
clearInterval(checkPaymentInterval)
|
||||||
|
setTopUpLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let failedCount = 0
|
||||||
|
checkPaymentInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { state } = await transaction.checkTransaction(transactionId)
|
||||||
|
if (state === 'pending') return
|
||||||
|
|
||||||
|
clearInterval(checkPaymentInterval)
|
||||||
|
setTopUpLoading(false)
|
||||||
|
|
||||||
|
if (state === 'settled') {
|
||||||
|
setPaid({ preimage: '' }) // Preimage is not returned, but we can assume payment is successful
|
||||||
|
getAccount() // Refresh account balance
|
||||||
|
} else {
|
||||||
|
closeModal()
|
||||||
|
toast.error('The invoice has expired or the payment was not successful')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failedCount++
|
||||||
|
if (failedCount <= 3) return
|
||||||
|
|
||||||
|
clearInterval(checkPaymentInterval)
|
||||||
|
setTopUpLoading(false)
|
||||||
|
toast.error(
|
||||||
|
'Top up failed: ' +
|
||||||
|
(err instanceof Error ? err.message : 'An error occurred while topping up')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setTopUpLoading(false)
|
||||||
|
toast.error(
|
||||||
|
'Top up failed: ' +
|
||||||
|
(err instanceof Error ? err.message : 'An error occurred while topping up')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="font-medium">{t('Top up')}</p>
|
||||||
|
|
||||||
|
{/* Preset amounts */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{presetAmounts.map(({ amount, text }) => (
|
||||||
|
<Button
|
||||||
|
key={amount}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handlePresetClick(amount)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col h-auto py-3 hover:bg-primary/10',
|
||||||
|
selectedAmount === amount && 'border border-primary bg-primary/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{text} {t('sats')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{calculateCharacters(amount).toLocaleString()} {t('characters')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom amount input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Custom amount"
|
||||||
|
value={topUpAmount}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
min={1000}
|
||||||
|
step={1000}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('sats')}</span>
|
||||||
|
</div>
|
||||||
|
{selectedAmount && selectedAmount >= 1000 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Will receive: {n} characters', {
|
||||||
|
n: calculateCharacters(selectedAmount).toLocaleString()
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={topUpLoading || !selectedAmount || selectedAmount < 1000}
|
||||||
|
onClick={() => handleTopUp(selectedAmount)}
|
||||||
|
>
|
||||||
|
{topUpLoading && <Loader className="animate-spin" />}
|
||||||
|
{selectedAmount && selectedAmount >= 1000
|
||||||
|
? t('Top up {n} sats', {
|
||||||
|
n: selectedAmount?.toLocaleString()
|
||||||
|
})
|
||||||
|
: t('Minimum top up is {n} sats', {
|
||||||
|
n: new Number(1000).toLocaleString()
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AccountInfo } from './AccountInfo'
|
||||||
|
import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider'
|
||||||
|
|
||||||
|
export default function JumbleTranslate() {
|
||||||
|
return (
|
||||||
|
<JumbleTranslateAccountProvider>
|
||||||
|
<AccountInfo />
|
||||||
|
</JumbleTranslateAccountProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/pages/secondary/TranslationPage/LibreTranslate/index.tsx
Normal file
59
src/pages/secondary/TranslationPage/LibreTranslate/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function LibreTranslate() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { config, updateConfig } = useTranslationService()
|
||||||
|
const [server, setServer] = useState(
|
||||||
|
config.service === 'libre_translate' ? (config.server ?? '') : ''
|
||||||
|
)
|
||||||
|
const [apiKey, setApiKey] = useState(
|
||||||
|
config.service === 'libre_translate' ? (config.api_key ?? '') : ''
|
||||||
|
)
|
||||||
|
const initialized = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized.current) {
|
||||||
|
initialized.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig({
|
||||||
|
service: 'libre_translate',
|
||||||
|
server,
|
||||||
|
api_key: apiKey
|
||||||
|
})
|
||||||
|
}, [server, apiKey])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="libre-translate-server" className="text-base">
|
||||||
|
{t('Service address')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="libre-translate-server"
|
||||||
|
type="text"
|
||||||
|
value={server}
|
||||||
|
onChange={(e) => setServer(e.target.value)}
|
||||||
|
placeholder="Enter server address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="libre-translate-api-key" className="text-base">
|
||||||
|
API key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="libre-translate-api-key"
|
||||||
|
type="text"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="Enter API Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/pages/secondary/TranslationPage/index.tsx
Normal file
74
src/pages/secondary/TranslationPage/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { LocalizedLanguageNames } from '@/i18n'
|
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
|
import { TLanguage } from '@/types'
|
||||||
|
import { forwardRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import JumbleTranslate from './JumbleTranslate'
|
||||||
|
import LibreTranslate from './LibreTranslate'
|
||||||
|
|
||||||
|
const TranslationPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { config, updateConfig } = useTranslationService()
|
||||||
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
|
|
||||||
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
|
i18n.changeLanguage(value)
|
||||||
|
setLanguage(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout ref={ref} index={index} title={t('Translation')}>
|
||||||
|
<div className="px-4 pt-2 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="languages" className="text-base font-medium">
|
||||||
|
{t('Languages')}
|
||||||
|
</Label>
|
||||||
|
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
|
||||||
|
<SelectTrigger id="languages" className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(LocalizedLanguageNames).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="translation-service-select" className="text-base font-medium">
|
||||||
|
{t('Service')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={config.service}
|
||||||
|
value={config.service}
|
||||||
|
onValueChange={(newService) => {
|
||||||
|
updateConfig({ service: newService as 'jumble' | 'libre_translate' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="translation-service-select" className="w-[180px]">
|
||||||
|
<SelectValue placeholder={t('Select Translation Service')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="jumble">Jumble</SelectItem>
|
||||||
|
<SelectItem value="libre_translate">LibreTranslate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{config.service === 'jumble' ? <JumbleTranslate /> : <LibreTranslate />}
|
||||||
|
</div>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
TranslationPage.displayName = 'TranslationPage'
|
||||||
|
export default TranslationPage
|
||||||
@@ -562,9 +562,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
const signHttpAuth = async (url: string, method: string) => {
|
const signHttpAuth = async (url: string, method: string, content = '') => {
|
||||||
const event = await signEvent({
|
const event = await signEvent({
|
||||||
content: '',
|
content,
|
||||||
kind: kinds.HTTPAuth,
|
kind: kinds.HTTPAuth,
|
||||||
created_at: dayjs().unix(),
|
created_at: dayjs().unix(),
|
||||||
tags: [
|
tags: [
|
||||||
|
|||||||
153
src/providers/TranslationServiceProvider.tsx
Normal file
153
src/providers/TranslationServiceProvider.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import libreTranslate from '@/services/libre-translate.service'
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
|
import translation from '@/services/translation.service'
|
||||||
|
import { TTranslationAccount, TTranslationServiceConfig } from '@/types'
|
||||||
|
import { Event, kinds } from 'nostr-tools'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
|
||||||
|
const translatedEventCache: Record<string, Event> = {}
|
||||||
|
|
||||||
|
type TTranslationServiceContext = {
|
||||||
|
config: TTranslationServiceConfig
|
||||||
|
translatedEventIdSet: Set<string>
|
||||||
|
translate: (event: Event) => Promise<Event | void>
|
||||||
|
getTranslatedEvent: (eventId: string) => Event | null
|
||||||
|
showOriginalEvent: (eventId: string) => void
|
||||||
|
getAccount: () => Promise<TTranslationAccount | void>
|
||||||
|
regenerateApiKey: () => Promise<string | undefined>
|
||||||
|
updateConfig: (newConfig: TTranslationServiceConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranslationServiceContext = createContext<TTranslationServiceContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useTranslationService = () => {
|
||||||
|
const context = useContext(TranslationServiceContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTranslation must be used within a TranslationProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranslationServiceProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const [config, setConfig] = useState<TTranslationServiceConfig>({ service: 'jumble' })
|
||||||
|
const { pubkey, startLogin } = useNostr()
|
||||||
|
const [translatedEventIdSet, setTranslatedEventIdSet] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
translation.changeCurrentPubkey(pubkey)
|
||||||
|
const config = storage.getTranslationServiceConfig(pubkey)
|
||||||
|
setConfig(config)
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const getAccount = async (): Promise<TTranslationAccount | void> => {
|
||||||
|
if (config.service !== 'jumble') return
|
||||||
|
if (!pubkey) {
|
||||||
|
startLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return await translation.getAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateApiKey = async (): Promise<string | undefined> => {
|
||||||
|
if (config.service !== 'jumble') return
|
||||||
|
if (!pubkey) {
|
||||||
|
startLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return await translation.regenerateApiKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTranslatedEvent = (eventId: string): Event | null => {
|
||||||
|
const target = i18n.language
|
||||||
|
const cacheKey = eventId + '_' + target
|
||||||
|
return translatedEventCache[cacheKey] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const translate = 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]) {
|
||||||
|
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
||||||
|
return translatedEventCache[cacheKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
|
||||||
|
return translatedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const showOriginalEvent = (eventId: string) => {
|
||||||
|
setTranslatedEventIdSet((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(eventId)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConfig = (newConfig: TTranslationServiceConfig) => {
|
||||||
|
setConfig(newConfig)
|
||||||
|
storage.setTranslationServiceConfig(newConfig, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TranslationServiceContext.Provider
|
||||||
|
value={{
|
||||||
|
config,
|
||||||
|
translatedEventIdSet,
|
||||||
|
getAccount,
|
||||||
|
regenerateApiKey,
|
||||||
|
translate,
|
||||||
|
getTranslatedEvent,
|
||||||
|
showOriginalEvent,
|
||||||
|
updateConfig
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TranslationServiceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ProfilePage from './pages/secondary/ProfilePage'
|
|||||||
import RelayPage from './pages/secondary/RelayPage'
|
import RelayPage from './pages/secondary/RelayPage'
|
||||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||||
import SettingsPage from './pages/secondary/SettingsPage'
|
import SettingsPage from './pages/secondary/SettingsPage'
|
||||||
|
import TranslationPage from './pages/secondary/TranslationPage'
|
||||||
import WalletPage from './pages/secondary/WalletPage'
|
import WalletPage from './pages/secondary/WalletPage'
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
@@ -28,6 +29,7 @@ const ROUTES = [
|
|||||||
{ path: '/settings/wallet', element: <WalletPage /> },
|
{ path: '/settings/wallet', element: <WalletPage /> },
|
||||||
{ path: '/settings/posts', element: <PostSettingsPage /> },
|
{ path: '/settings/posts', element: <PostSettingsPage /> },
|
||||||
{ path: '/settings/general', element: <GeneralSettingsPage /> },
|
{ path: '/settings/general', element: <GeneralSettingsPage /> },
|
||||||
|
{ path: '/settings/translation', element: <TranslationPage /> },
|
||||||
{ path: '/profile-editor', element: <ProfileEditorPage /> },
|
{ path: '/profile-editor', element: <ProfileEditorPage /> },
|
||||||
{ path: '/mutes', element: <MuteListPage /> }
|
{ path: '/mutes', element: <MuteListPage /> }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -144,6 +144,22 @@ class ClientService extends EventTarget {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signHttpAuth(url: string, method: string, description = '') {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error('Please login first to sign the event')
|
||||||
|
}
|
||||||
|
const event = await this.signer?.signEvent({
|
||||||
|
content: description,
|
||||||
|
kind: kinds.HTTPAuth,
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||||
|
}
|
||||||
|
|
||||||
private generateTimelineKey(urls: string[], filter: Filter) {
|
private generateTimelineKey(urls: string[], filter: Filter) {
|
||||||
const stableFilter: any = {}
|
const stableFilter: any = {}
|
||||||
Object.entries(filter)
|
Object.entries(filter)
|
||||||
|
|||||||
35
src/services/libre-translate.service.ts
Normal file
35
src/services/libre-translate.service.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class LibreTranslateService {
|
||||||
|
static instance: LibreTranslateService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!LibreTranslateService.instance) {
|
||||||
|
LibreTranslateService.instance = this
|
||||||
|
}
|
||||||
|
return LibreTranslateService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async translate(
|
||||||
|
text: string,
|
||||||
|
target: string,
|
||||||
|
server?: string,
|
||||||
|
api_key?: string
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!server) {
|
||||||
|
throw new Error('LibreTranslate server address is not configured')
|
||||||
|
}
|
||||||
|
const url = new URL('/translate', server).toString()
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, target, source: 'auto', api_key })
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error ?? 'Failed to translate')
|
||||||
|
}
|
||||||
|
return data.translatedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new LibreTranslateService()
|
||||||
|
export default instance
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
TFeedInfo,
|
TFeedInfo,
|
||||||
TNoteListMode,
|
TNoteListMode,
|
||||||
TRelaySet,
|
TRelaySet,
|
||||||
TThemeSetting
|
TThemeSetting,
|
||||||
|
TTranslationServiceConfig
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
class LocalStorageService {
|
class LocalStorageService {
|
||||||
@@ -27,6 +28,7 @@ class LocalStorageService {
|
|||||||
private autoplay: boolean = true
|
private autoplay: boolean = true
|
||||||
private hideUntrustedInteractions: boolean = false
|
private hideUntrustedInteractions: boolean = false
|
||||||
private hideUntrustedNotifications: boolean = false
|
private hideUntrustedNotifications: boolean = false
|
||||||
|
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -109,6 +111,13 @@ class LocalStorageService {
|
|||||||
? storedHideUntrustedNotifications === 'true'
|
? storedHideUntrustedNotifications === 'true'
|
||||||
: hideUntrustedEvents
|
: hideUntrustedEvents
|
||||||
|
|
||||||
|
const translationServiceConfigMapStr = window.localStorage.getItem(
|
||||||
|
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP
|
||||||
|
)
|
||||||
|
if (translationServiceConfigMapStr) {
|
||||||
|
this.translationServiceConfigMap = JSON.parse(translationServiceConfigMapStr)
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up deprecated data
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||||
@@ -290,6 +299,18 @@ class LocalStorageService {
|
|||||||
hideUntrustedNotifications.toString()
|
hideUntrustedNotifications.toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTranslationServiceConfig(pubkey?: string | null) {
|
||||||
|
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' }
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranslationServiceConfig(config: TTranslationServiceConfig, pubkey?: string | null) {
|
||||||
|
this.translationServiceConfigMap[pubkey ?? '_'] = config
|
||||||
|
window.localStorage.setItem(
|
||||||
|
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP,
|
||||||
|
JSON.stringify(this.translationServiceConfigMap)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = new LocalStorageService()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
55
src/services/transaction.service.ts
Normal file
55
src/services/transaction.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { JUMBLE_API_BASE_URL } from '@/constants'
|
||||||
|
|
||||||
|
class TransactionService {
|
||||||
|
static instance: TransactionService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!TransactionService.instance) {
|
||||||
|
TransactionService.instance = this
|
||||||
|
}
|
||||||
|
return TransactionService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTransaction(
|
||||||
|
pubkey: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<{
|
||||||
|
transactionId: string
|
||||||
|
invoiceId: string
|
||||||
|
}> {
|
||||||
|
const url = new URL('/v1/transactions', JUMBLE_API_BASE_URL).toString()
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
pubkey,
|
||||||
|
amount,
|
||||||
|
purpose: 'translation'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error ?? 'Failed to create transaction')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkTransaction(transactionId: string): Promise<{
|
||||||
|
state: 'pending' | 'failed' | 'settled'
|
||||||
|
}> {
|
||||||
|
const url = new URL(`/v1/transactions/${transactionId}/check`, JUMBLE_API_BASE_URL).toString()
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error ?? 'Failed to complete transaction')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new TransactionService()
|
||||||
|
export default instance
|
||||||
129
src/services/translation.service.ts
Normal file
129
src/services/translation.service.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { JUMBLE_API_BASE_URL } from '@/constants'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TTranslationAccount } from '@/types'
|
||||||
|
|
||||||
|
class TranslationService {
|
||||||
|
static instance: TranslationService
|
||||||
|
|
||||||
|
private apiKeyMap: Record<string, string | undefined> = {}
|
||||||
|
private currentPubkey: string | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!TranslationService.instance) {
|
||||||
|
TranslationService.instance = this
|
||||||
|
}
|
||||||
|
return TranslationService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(): Promise<TTranslationAccount> {
|
||||||
|
if (!this.currentPubkey) {
|
||||||
|
throw new Error('Please login first')
|
||||||
|
}
|
||||||
|
const apiKey = this.apiKeyMap[this.currentPubkey]
|
||||||
|
const path = '/v1/translation/account'
|
||||||
|
const method = 'GET'
|
||||||
|
let auth: string | undefined
|
||||||
|
if (!apiKey) {
|
||||||
|
auth = await client.signHttpAuth(
|
||||||
|
new URL(path, JUMBLE_API_BASE_URL).toString(),
|
||||||
|
method,
|
||||||
|
'Auth to get Jumble translation service account'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const act = await this._fetch<TTranslationAccount>({
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
auth,
|
||||||
|
retryWhenUnauthorized: !auth
|
||||||
|
})
|
||||||
|
|
||||||
|
if (act.api_key && act.pubkey) {
|
||||||
|
this.apiKeyMap[act.pubkey] = act.api_key
|
||||||
|
}
|
||||||
|
|
||||||
|
return act
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerateApiKey(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await this._fetch({
|
||||||
|
path: '/v1/translation/regenerate-api-key',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (data.api_key && this.currentPubkey) {
|
||||||
|
this.apiKeyMap[this.currentPubkey] = data.api_key
|
||||||
|
}
|
||||||
|
return data.api_key
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : ''
|
||||||
|
throw new Error(errMsg || 'Failed to regenerate API key')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async translate(text: string, target: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const data = await this._fetch({
|
||||||
|
path: '/v1/translation/translate',
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ q: text, target })
|
||||||
|
})
|
||||||
|
return data.translatedText
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : ''
|
||||||
|
throw new Error(errMsg || 'Failed to translate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changeCurrentPubkey(pubkey: string | null): void {
|
||||||
|
this.currentPubkey = pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetch<T = any>({
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
auth,
|
||||||
|
retryWhenUnauthorized = true
|
||||||
|
}: {
|
||||||
|
path: string
|
||||||
|
method: string
|
||||||
|
body?: string
|
||||||
|
auth?: string
|
||||||
|
retryWhenUnauthorized?: boolean
|
||||||
|
}): Promise<T> {
|
||||||
|
if (!this.currentPubkey) {
|
||||||
|
throw new Error('Please login first')
|
||||||
|
}
|
||||||
|
const apiKey = this.apiKeyMap[this.currentPubkey]
|
||||||
|
const hasApiKey = !!apiKey
|
||||||
|
let _auth: string
|
||||||
|
if (auth) {
|
||||||
|
_auth = auth
|
||||||
|
} else if (hasApiKey) {
|
||||||
|
_auth = `Bearer ${apiKey}`
|
||||||
|
} else {
|
||||||
|
const act = await this.getAccount()
|
||||||
|
_auth = `Bearer ${act.api_key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(path, JUMBLE_API_BASE_URL).toString()
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: _auth },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data.code === '00403' && hasApiKey && retryWhenUnauthorized) {
|
||||||
|
this.apiKeyMap[this.currentPubkey] = undefined
|
||||||
|
return this._fetch({ path, method, body, retryWhenUnauthorized: false })
|
||||||
|
}
|
||||||
|
throw new Error(data.error)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new TranslationService()
|
||||||
|
export default instance
|
||||||
16
src/types.ts
16
src/types.ts
@@ -121,3 +121,19 @@ export type TEmoji = {
|
|||||||
shortcode: string
|
shortcode: string
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TTranslationAccount = {
|
||||||
|
pubkey: string
|
||||||
|
api_key: string
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TTranslationServiceConfig =
|
||||||
|
| {
|
||||||
|
service: 'jumble'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
service: 'libre_translate'
|
||||||
|
server?: string
|
||||||
|
api_key?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user