diff --git a/package-lock.json b/package-lock.json index 62bffde..804b5f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "embla-carousel-react": "^8.5.1", "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", + "franc-min": "^6.2.0", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6370,6 +6381,19 @@ "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": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7563,6 +7587,16 @@ "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": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -9402,6 +9436,20 @@ "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": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", diff --git a/package.json b/package.json index 297d68f..e062d62 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "embla-carousel-react": "^8.5.1", "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", + "franc-min": "^6.2.0", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "lru-cache": "^11.0.2", diff --git a/src/App.tsx b/src/App.tsx index 77299b2..22eb285 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { NostrProvider } from './providers/NostrProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider' import { ReplyProvider } from './providers/ReplyProvider' import { ScreenSizeProvider } from './providers/ScreenSizeProvider' +import { TranslationServiceProvider } from './providers/TranslationServiceProvider' import { UserTrustProvider } from './providers/UserTrustProvider' import { ZapProvider } from './providers/ZapProvider' @@ -25,26 +26,28 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index cc02355..8b569a9 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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, diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 0059271..0f551c2 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -1,3 +1,4 @@ +import { useTranslatedEvent } from '@/hooks' import { EmbeddedEmojiParser, EmbeddedEventParser, @@ -24,19 +25,20 @@ export default function ContentPreview({ onClick?: React.MouseEventHandler | 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 ( diff --git a/src/components/Note/Highlight.tsx b/src/components/Note/Highlight.tsx index bc2a970..49ff16b 100644 --- a/src/components/Note/Highlight.tsx +++ b/src/components/Note/Highlight.tsx @@ -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 (
{comment && }
-
{event.content}
+
+ {translatedEvent?.content ?? event.content} +
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index d2831a3..eb2a047 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({
- {size === 'normal' && } +
+ + {size === 'normal' && } +
{parentEventId && ( setIsDrawerOpen(true)} > diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index cee6b1f..d6732f5 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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({
-
+
- +
+ + +
{parentEventId && ( 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 ( + + ) +} diff --git a/src/constants.ts b/src/constants.ts index b154cb3..debc353 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ +export const JUMBLE_API_BASE_URL = 'https://api.jumble.social' + export const StorageKey = { VERSION: 'version', THEME_SETTING: 'themeSetting', @@ -16,6 +18,7 @@ export const StorageKey = { AUTOPLAY: 'autoplay', HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications', + TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap', HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // 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 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 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_RELAYS = ['wss://relay.nostr.watch/'] diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 20948f7..2bf759d 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -6,3 +6,4 @@ export * from './useFetchRelayInfo' export * from './useFetchRelayInfos' export * from './useFetchRelayList' export * from './useSearchProfiles' +export * from './useTranslatedEvent' diff --git a/src/hooks/useTranslatedEvent.tsx b/src/hooks/useTranslatedEvent.tsx new file mode 100644 index 0000000..0bcf6b8 --- /dev/null +++ b/src/hooks/useTranslatedEvent.tsx @@ -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(null) + + useEffect(() => { + if (translated && eventId) { + setTranslatedEvent(getTranslatedEvent(eventId)) + } else { + setTranslatedEvent(null) + } + }, [translated, eventId]) + + return translatedEvent +} diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 98ab2d8..e243a1f 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -242,6 +242,23 @@ export default { Quotes: 'الاقتباسات', 'Lightning Invoice': 'فاتورة Lightning', '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': 'عنوان الخدمة' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 83f0833..30c428f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -249,6 +249,23 @@ export default { Quotes: 'Zitate', 'Lightning Invoice': 'Lightning-Rechnung', '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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ceebf17..935337a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -242,6 +242,23 @@ export default { Quotes: 'Quotes', 'Lightning Invoice': 'Lightning Invoice', '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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 7fa3ca9..b871590 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -247,6 +247,23 @@ export default { Quotes: 'Citas', 'Lightning Invoice': 'Factura Lightning', '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' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 8da7a2d..6d70977 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -247,6 +247,23 @@ export default { Quotes: 'Citations', 'Lightning Invoice': 'Facture Lightning', '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' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index cd9e800..20a3378 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -246,6 +246,23 @@ export default { Quotes: 'Citazioni', 'Lightning Invoice': 'Fattura Lightning', '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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 82ba70f..688f012 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -243,6 +243,23 @@ export default { Quotes: '引用', 'Lightning Invoice': 'ライトニングインボイス', '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': 'サービスアドレス' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index e521c8f..37b7f5a 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -245,6 +245,23 @@ export default { Quotes: 'Cytaty', 'Lightning Invoice': 'Faktura Lightning', '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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index d9a517a..bd7ff87 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -245,6 +245,23 @@ export default { Quotes: 'Citações', 'Lightning Invoice': 'Fatura Lightning', '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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 8d9b1ea..1c53c61 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -246,6 +246,23 @@ export default { Quotes: 'Citações', 'Lightning Invoice': 'Fatura Lightning', '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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index c060863..0736073 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -247,6 +247,23 @@ export default { Quotes: 'Цитаты', 'Lightning Invoice': 'Lightning-счет', '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': 'Адрес сервиса' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index d6ea4e9..b06a5f9 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -243,6 +243,23 @@ export default { Quotes: '引用', 'Lightning Invoice': '闪电发票', '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': '服务地址' } } diff --git a/src/lib/link.ts b/src/lib/link.ts index 87d549e..1984a92 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -50,6 +50,7 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => { export const toWallet = () => '/settings/wallet' export const toPostSettings = () => '/settings/posts' export const toGeneralSettings = () => '/settings/general' +export const toTranslation = () => '/settings/translation' export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toMuteList = () => '/mutes' diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index 2f6f2ee..958670d 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -1,7 +1,13 @@ import AboutInfoDialog from '@/components/AboutInfoDialog' import Donation from '@/components/Donation' 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 { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' @@ -11,6 +17,7 @@ import { Copy, Info, KeyRound, + Languages, PencilLine, Server, Settings2, @@ -42,6 +49,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
+ push(toTranslation())}> +
+ +
{t('Translation')}
+
+ +
push(toWallet())}>
diff --git a/src/pages/secondary/TranslationPage/JumbleTranslate/AccountInfo.tsx b/src/pages/secondary/TranslationPage/JumbleTranslate/AccountInfo.tsx new file mode 100644 index 0000000..b733baa --- /dev/null +++ b/src/pages/secondary/TranslationPage/JumbleTranslate/AccountInfo.tsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+ {/* Balance display in characters */} +
+

{t('Balance')}

+
+

{account?.balance.toLocaleString() ?? '0'}

+

{t('characters')}

+
+
+ + {/* API Key section with visibility toggle and copy functionality */} +
+

API key

+
+ + + + +
+

+ {t('jumbleTranslateApiKeyDescription', { + serviceUrl: new URL('/v1/translation', JUMBLE_API_BASE_URL).toString() + })} +

+
+ +
+
+ ) +} diff --git a/src/pages/secondary/TranslationPage/JumbleTranslate/JumbleTranslateAccountProvider.tsx b/src/pages/secondary/TranslationPage/JumbleTranslate/JumbleTranslateAccountProvider.tsx new file mode 100644 index 0000000..78c7f77 --- /dev/null +++ b/src/pages/secondary/TranslationPage/JumbleTranslate/JumbleTranslateAccountProvider.tsx @@ -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 + regenerateApiKey: () => Promise +} + +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(null) + + useEffect(() => { + setAccount(null) + if (!pubkey) return + + setTimeout(() => { + getAccount() + }, 100) + }, [pubkey]) + + const regenerateApiKey = async (): Promise => { + 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 => { + 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 ( + + {children} + + ) +} diff --git a/src/pages/secondary/TranslationPage/JumbleTranslate/RegenerateApiKeyButton.tsx b/src/pages/secondary/TranslationPage/JumbleTranslate/RegenerateApiKeyButton.tsx new file mode 100644 index 0000000..2dc68b5 --- /dev/null +++ b/src/pages/secondary/TranslationPage/JumbleTranslate/RegenerateApiKeyButton.tsx @@ -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 ( + + + + + + + {t('Reset API key')} + + {t('Are you sure you want to reset your API key? This action cannot be undone.')} +
+
+ {t('Warning')}:{' '} + {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.' + )} +
+
+ + + + +
+
+ ) +} diff --git a/src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx b/src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx new file mode 100644 index 0000000..581957f --- /dev/null +++ b/src/pages/secondary/TranslationPage/JumbleTranslate/TopUp.tsx @@ -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(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 | 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 ( +
+

{t('Top up')}

+ + {/* Preset amounts */} +
+ {presetAmounts.map(({ amount, text }) => ( + + ))} +
+ + {/* Custom amount input */} +
+
+ handleInputChange(e.target.value)} + min={1000} + step={1000} + className="w-40" + /> + {t('sats')} +
+ {selectedAmount && selectedAmount >= 1000 && ( +

+ {t('Will receive: {n} characters', { + n: calculateCharacters(selectedAmount).toLocaleString() + })} +

+ )} +
+ + +
+ ) +} diff --git a/src/pages/secondary/TranslationPage/JumbleTranslate/index.tsx b/src/pages/secondary/TranslationPage/JumbleTranslate/index.tsx new file mode 100644 index 0000000..f85e59c --- /dev/null +++ b/src/pages/secondary/TranslationPage/JumbleTranslate/index.tsx @@ -0,0 +1,10 @@ +import { AccountInfo } from './AccountInfo' +import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider' + +export default function JumbleTranslate() { + return ( + + + + ) +} diff --git a/src/pages/secondary/TranslationPage/LibreTranslate/index.tsx b/src/pages/secondary/TranslationPage/LibreTranslate/index.tsx new file mode 100644 index 0000000..c81d697 --- /dev/null +++ b/src/pages/secondary/TranslationPage/LibreTranslate/index.tsx @@ -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 ( +
+
+ + setServer(e.target.value)} + placeholder="Enter server address" + /> +
+
+ + setApiKey(e.target.value)} + placeholder="Enter API Key" + /> +
+
+ ) +} diff --git a/src/pages/secondary/TranslationPage/index.tsx b/src/pages/secondary/TranslationPage/index.tsx new file mode 100644 index 0000000..7e4a2cf --- /dev/null +++ b/src/pages/secondary/TranslationPage/index.tsx @@ -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(i18n.language as TLanguage) + + const handleLanguageChange = (value: TLanguage) => { + i18n.changeLanguage(value) + setLanguage(value) + } + + return ( + +
+
+ + +
+
+ + +
+ {config.service === 'jumble' ? : } +
+
+ ) +}) +TranslationPage.displayName = 'TranslationPage' +export default TranslationPage diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index bb989e2..465cf3f 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -562,9 +562,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return event } - const signHttpAuth = async (url: string, method: string) => { + const signHttpAuth = async (url: string, method: string, content = '') => { const event = await signEvent({ - content: '', + content, kind: kinds.HTTPAuth, created_at: dayjs().unix(), tags: [ diff --git a/src/providers/TranslationServiceProvider.tsx b/src/providers/TranslationServiceProvider.tsx new file mode 100644 index 0000000..4b4dfa5 --- /dev/null +++ b/src/providers/TranslationServiceProvider.tsx @@ -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 = {} + +type TTranslationServiceContext = { + config: TTranslationServiceConfig + translatedEventIdSet: Set + translate: (event: Event) => Promise + getTranslatedEvent: (eventId: string) => Event | null + showOriginalEvent: (eventId: string) => void + getAccount: () => Promise + regenerateApiKey: () => Promise + updateConfig: (newConfig: TTranslationServiceConfig) => void +} + +const TranslationServiceContext = createContext(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({ service: 'jumble' }) + const { pubkey, startLogin } = useNostr() + const [translatedEventIdSet, setTranslatedEventIdSet] = useState>(new Set()) + + useEffect(() => { + translation.changeCurrentPubkey(pubkey) + const config = storage.getTranslationServiceConfig(pubkey) + setConfig(config) + }, [pubkey]) + + const getAccount = async (): Promise => { + if (config.service !== 'jumble') return + if (!pubkey) { + startLogin() + return + } + return await translation.getAccount() + } + + const regenerateApiKey = async (): Promise => { + 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 => { + 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 ( + + {children} + + ) +} diff --git a/src/routes.tsx b/src/routes.tsx index ef617ed..1c7ca6d 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -13,6 +13,7 @@ import ProfilePage from './pages/secondary/ProfilePage' import RelayPage from './pages/secondary/RelayPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import SettingsPage from './pages/secondary/SettingsPage' +import TranslationPage from './pages/secondary/TranslationPage' import WalletPage from './pages/secondary/WalletPage' const ROUTES = [ @@ -28,6 +29,7 @@ const ROUTES = [ { path: '/settings/wallet', element: }, { path: '/settings/posts', element: }, { path: '/settings/general', element: }, + { path: '/settings/translation', element: }, { path: '/profile-editor', element: }, { path: '/mutes', element: } ] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 78e2578..50b91c2 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -144,6 +144,22 @@ class ClientService extends EventTarget { 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) { const stableFilter: any = {} Object.entries(filter) diff --git a/src/services/libre-translate.service.ts b/src/services/libre-translate.service.ts new file mode 100644 index 0000000..3780b48 --- /dev/null +++ b/src/services/libre-translate.service.ts @@ -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 { + 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 diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index d447ecf..bce065c 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -7,7 +7,8 @@ import { TFeedInfo, TNoteListMode, TRelaySet, - TThemeSetting + TThemeSetting, + TTranslationServiceConfig } from '@/types' class LocalStorageService { @@ -27,6 +28,7 @@ class LocalStorageService { private autoplay: boolean = true private hideUntrustedInteractions: boolean = false private hideUntrustedNotifications: boolean = false + private translationServiceConfigMap: Record = {} constructor() { if (!LocalStorageService.instance) { @@ -109,6 +111,13 @@ class LocalStorageService { ? storedHideUntrustedNotifications === 'true' : hideUntrustedEvents + const translationServiceConfigMapStr = window.localStorage.getItem( + StorageKey.TRANSLATION_SERVICE_CONFIG_MAP + ) + if (translationServiceConfigMapStr) { + this.translationServiceConfigMap = JSON.parse(translationServiceConfigMapStr) + } + // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) @@ -290,6 +299,18 @@ class LocalStorageService { 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() diff --git a/src/services/transaction.service.ts b/src/services/transaction.service.ts new file mode 100644 index 0000000..78f3de3 --- /dev/null +++ b/src/services/transaction.service.ts @@ -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 diff --git a/src/services/translation.service.ts b/src/services/translation.service.ts new file mode 100644 index 0000000..2f56581 --- /dev/null +++ b/src/services/translation.service.ts @@ -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 = {} + private currentPubkey: string | null = null + + constructor() { + if (!TranslationService.instance) { + TranslationService.instance = this + } + return TranslationService.instance + } + + async getAccount(): Promise { + 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({ + path, + method, + auth, + retryWhenUnauthorized: !auth + }) + + if (act.api_key && act.pubkey) { + this.apiKeyMap[act.pubkey] = act.api_key + } + + return act + } + + async regenerateApiKey(): Promise { + 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 { + 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({ + path, + method, + body, + auth, + retryWhenUnauthorized = true + }: { + path: string + method: string + body?: string + auth?: string + retryWhenUnauthorized?: boolean + }): Promise { + 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 diff --git a/src/types.ts b/src/types.ts index c91a2a1..b9cec09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -121,3 +121,19 @@ export type TEmoji = { shortcode: 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 + }