26 Commits

Author SHA1 Message Date
codytseng
a519bbb048 💨 2025-12-25 18:24:08 +08:00
codytseng
078a8fd348 💨 2025-12-25 18:07:22 +08:00
codytseng
17d90a298a refactor: thread 2025-12-25 17:06:32 +08:00
codytseng
d964c7b7b3 fix: return 0 instead of null for missing user percentile data 2025-12-25 09:21:29 +08:00
codytseng
25b2831fcc feat: 💨 2025-12-24 23:31:18 +08:00
bitcoinuser
1553227e13 feat: improve signup copy in Portuguese translations (#703) 2025-12-24 22:58:26 +08:00
codytseng
f04981f5b9 fix: improve description display in RelaySimpleInfo component 2025-12-24 22:54:58 +08:00
codytseng
2662373704 fix: adjust layout for Signup component 2025-12-24 22:51:59 +08:00
codytseng
526b64aec0 feat: add border to image hash placeholder 2025-12-24 22:48:38 +08:00
codytseng
41a65338b5 fix: 🐛 2025-12-24 22:30:00 +08:00
codytseng
56f0aa9fd5 fix: 🐛 2025-12-24 13:22:38 +08:00
codytseng
89f79b999c refactor: reverse top-level replies order 2025-12-24 13:01:03 +08:00
bitcoinuser
7459a3d33a feat: update Portuguese translations for clarity and accuracy (#702) 2025-12-24 10:58:24 +08:00
codytseng
49eca495f5 refactor: 🎨 2025-12-24 10:55:05 +08:00
codytseng
96abe5f24f feat: add compatibility for legacy comments 2025-12-23 23:30:57 +08:00
codytseng
0ee93718da feat: add relay recommendations based on user language 2025-12-23 22:28:07 +08:00
codytseng
a880a92748 feat: simplify account creation flow 2025-12-23 21:52:32 +08:00
codytseng
cd7c52eda0 feat: batch fetch user percentiles 2025-12-22 22:34:29 +08:00
codytseng
ef6d44d112 feat: add Traditional Chinese language support 2025-12-22 18:13:31 +08:00
bitcoinuser
2925c0c5f9 feat: update Portuguese translations for clarity (#697) 2025-12-22 14:52:55 +08:00
Max Blake
5705d8c9b3 feat: update pl.ts (#698)
Co-authored-by: Cody Tseng <codytseng98@gmail.com>
2025-12-22 14:52:16 +08:00
codytseng
944246b582 feat: 💨 2025-12-21 23:50:49 +08:00
codytseng
163f3212d8 chore: 🎨 2025-12-21 21:11:51 +08:00
codytseng
1193c81c78 fix: 🐛 2025-12-20 19:35:51 +08:00
codytseng
ddb88bf074 refactor: restructure the reply list 2025-12-20 19:22:27 +08:00
codytseng
079a2f90ef feat: add support for publishing highlights 2025-12-18 21:53:07 +08:00
72 changed files with 2804 additions and 730 deletions

View File

@@ -151,7 +151,7 @@ Jumble is a multi-language application. When you add new text content, please en
- Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW
#### Adding New Language

View File

@@ -15,7 +15,6 @@ import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
@@ -43,14 +42,12 @@ export default function App(): JSX.Element {
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>

View File

@@ -1,85 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function GenerateNewAccount({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState(generateNsec())
const [copied, setCopied] = useState(false)
const [password, setPassword] = useState('')
const handleLogin = () => {
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
)}
</div>
<div className="grid gap-2">
<Label>nsec</Label>
<div className="flex gap-2">
<Input value={nsec} />
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -0,0 +1,227 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Download, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import InfoCard from '../InfoCard'
type Step = 'generate' | 'password'
export default function Signup({
back,
onSignupSuccess
}: {
back: () => void
onSignupSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [step, setStep] = useState<Step>('generate')
const [nsec, setNsec] = useState(generateNsec())
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [copied, setCopied] = useState(false)
const handleDownload = () => {
const blob = new Blob([nsec], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'nostr-private-key.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleSignup = async () => {
await nsecLogin(nsec, password || undefined, true)
onSignupSuccess()
}
const passwordsMatch = password === confirmPassword
const canSubmit = !password || passwordsMatch
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2">
{(['generate', 'password'] as Step[]).map((s, index) => (
<div key={s} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
step === s
? 'bg-primary text-primary-foreground'
: step === 'password' && s === 'generate'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
</div>
))}
</div>
)
if (step === 'generate') {
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Create Your Nostr Account')}</h3>
<p className="text-sm text-muted-foreground">
{t('Generate your unique private key. This is your digital identity.')}
</p>
</div>
<InfoCard
variant="alert"
title={t('Critical: Save Your Private Key')}
content={t(
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.'
)}
/>
<div className="space-y-1">
<Label>{t('Your Private Key')}</Label>
<div className="flex gap-2">
<Input
value={nsec}
readOnly
className="font-mono text-sm"
onClick={(e) => e.currentTarget.select()}
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => setNsec(generateNsec())}
title={t('Generate new key')}
>
<RefreshCcw />
</Button>
</div>
</div>
<div className="w-full flex flex-col sm:flex-row gap-2 items-stretch">
<Button onClick={handleDownload} className="w-full">
<Download />
{t('Download Backup File')}
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
variant="secondary"
className="w-full"
>
{copied ? <Check /> : <Copy />}
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
</Button>
</div>
<div className="flex items-center gap-2 ml-2">
<Checkbox
id="acknowledge-checkbox"
checked={checkedSaveKey}
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
/>
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
{t('I have safely backed up my private key')}
</Label>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={back} className="w-fit px-6">
{t('Back')}
</Button>
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
{t('Continue')}
</Button>
</div>
</div>
)
}
// step === 'password'
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Secure Your Account')}</h3>
<p className="text-sm text-muted-foreground">
{t('Add an extra layer of protection with a password')}
</p>
</div>
<InfoCard
title={t('Password Protection (Recommended)')}
content={t(
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.'
)}
/>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('Create a password (or skip)')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{password && (
<div className="space-y-1">
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
<Input
id="confirm-password-input"
type="password"
placeholder={t('Enter your password again')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
)}
</div>
)}
</div>
<div className="w-full flex gap-2">
<Button
variant="secondary"
onClick={() => {
setStep('generate')
setPassword('')
setConfirmPassword('')
}}
className="w-fit px-6"
>
{t('Back')}
</Button>
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
{t('Complete Signup')}
</Button>
</div>
</div>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -2,17 +2,15 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isDevEnv } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
import Signup from './Signup'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'signup' ? (
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
) : (
<AccountManagerNav setPage={setPage} close={close} />
)}
@@ -41,9 +39,8 @@ function AccountManagerNav({
setPage: (page: TAccountManagerPage) => void
close?: () => void
}) {
const { t, i18n } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
const { t } = useTranslation()
const { nip07Login, accounts } = useNostr()
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@@ -75,38 +72,8 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
<Button onClick={() => setPage('signup')} className="w-full mt-4">
{t('Create New Account')}
</Button>
</div>
{accounts.length > 0 && (

View File

@@ -1,13 +0,0 @@
import { TriangleAlert } from 'lucide-react'
export default function AlertCard({ title, content }: { title: string; content: string }) {
return (
<div className="p-3 rounded-lg text-sm bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500 [&_svg]:size-4">
<div className="flex items-center gap-2">
<TriangleAlert />
<div className="font-medium">{title}</div>
</div>
<div className="pl-6">{content}</div>
</div>
)
}

View File

@@ -1,22 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link'
import { recommendRelaysByLanguage } from '@/lib/relay'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { cn } from '@/lib/utils'
export default function Explore() {
const { t, i18n } = useTranslation()
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
const recommendedRelays = useMemo(() => {
const lang = i18n.language
const relays = recommendRelaysByLanguage(lang)
return relays
}, [i18n.language])
useEffect(() => {
relayInfoService.getAwesomeRelayCollections().then(setCollections)
}, [])
if (!collections) {
if (!collections && recommendedRelays.length === 0) {
return (
<div>
<div className="p-4 max-md:border-b">
@@ -31,9 +39,19 @@ export default function Explore() {
return (
<div className="space-y-6">
{collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
{recommendedRelays.length > 0 && (
<RelayCollection
collection={{
id: 'recommended',
name: t('Recommended'),
relays: recommendedRelays
}}
/>
)}
{collections &&
collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div>
)
}

View File

@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs'
export default function ExternalContentInteractions({
pageIndex,
externalContent
}: {
pageIndex?: number
externalContent: string
}) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
list = <ReplyNoteList stuff={externalContent} />
break
case 'reactions':
list = <ReactionList stuff={externalContent} />

View File

@@ -60,39 +60,41 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
}
return isFollowing ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="rounded-full min-w-28"
variant={hover ? 'destructive' : 'secondary'}
disabled={updating}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{updating ? (
<Loader className="animate-spin" />
) : hover ? (
t('Unfollow')
) : (
t('buttonFollowing')
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
<AlertDialogDescription>
{t('Are you sure you want to unfollow this user?')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
{t('Unfollow')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div onClick={(e) => e.stopPropagation()}>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="rounded-full min-w-28"
variant={hover ? 'destructive' : 'secondary'}
disabled={updating}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{updating ? (
<Loader className="animate-spin" />
) : hover ? (
t('Unfollow')
) : (
t('buttonFollowing')
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
<AlertDialogDescription>
{t('Are you sure you want to unfollow this user?')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
{t('Unfollow')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : (
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
{updating ? <Loader className="animate-spin" /> : t('Follow')}

View File

@@ -73,13 +73,13 @@ export default function Image({
}
return (
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
<div className={cn('relative overflow-hidden rounded-xl', classNames.wrapper)} {...props}>
{/* Spacer: transparent image to maintain dimensions when image is loading */}
{isLoading && dim?.width && dim?.height && (
<img
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
className={cn(
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
'object-cover transition-opacity pointer-events-none w-full h-full',
className
)}
alt=""
@@ -91,7 +91,7 @@ export default function Image({
<ThumbHashPlaceholder
thumbHash={thumbHash}
className={cn(
'w-full h-full transition-opacity rounded-xl',
'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
@@ -99,14 +99,14 @@ export default function Image({
<BlurHashCanvas
blurHash={blurHash}
className={cn(
'w-full h-full transition-opacity rounded-xl',
'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : (
<Skeleton
className={cn(
'w-full h-full transition-opacity rounded-xl',
'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0',
classNames.skeleton
)}
@@ -124,7 +124,7 @@ export default function Image({
onLoad={handleLoad}
onError={handleError}
className={cn(
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
'object-cover transition-opacity pointer-events-none w-full h-full',
isLoading ? 'opacity-0 absolute inset-0' : '',
className
)}
@@ -137,7 +137,7 @@ export default function Image({
alt={alt}
decoding="async"
loading="lazy"
className={cn('object-cover rounded-xl w-full h-full transition-opacity', className)}
className={cn('object-cover w-full h-full transition-opacity', className)}
/>
) : (
<div

View File

@@ -94,9 +94,9 @@ export default function ImageGallery({
<ImageWithLightbox
key={i}
image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: cn('w-fit max-w-full', className)
wrapper: cn('w-fit max-w-full border', className)
}}
/>
))
@@ -107,10 +107,10 @@ export default function ImageGallery({
imageContent = (
<Image
key={0}
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
errorPlaceholder: 'aspect-square h-[30vh]',
wrapper: 'cursor-zoom-in'
wrapper: 'cursor-zoom-in border'
}}
image={displayImages[0]}
onClick={(e) => handlePhotoClick(e, 0)}
@@ -122,8 +122,8 @@ export default function ImageGallery({
{displayImages.map((image, i) => (
<Image
key={i}
className="aspect-square w-full border"
classNames={{ wrapper: 'cursor-zoom-in' }}
className="aspect-square w-full"
classNames={{ wrapper: 'cursor-zoom-in border' }}
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>
@@ -136,8 +136,8 @@ export default function ImageGallery({
{displayImages.map((image, i) => (
<Image
key={i}
className="aspect-square w-full border"
classNames={{ wrapper: 'cursor-zoom-in' }}
className="aspect-square w-full"
classNames={{ wrapper: 'cursor-zoom-in border' }}
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>

View File

@@ -67,7 +67,7 @@ export default function ImageWithLightbox({
key={0}
className={className}
classNames={{
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
wrapper: cn('border cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]',
skeleton: classNames.skeleton
}}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils'
import { CheckCircle2, Info, TriangleAlert } from 'lucide-react'
const ICON_MAP = {
info: <Info />,
success: <CheckCircle2 />,
alert: <TriangleAlert />
}
const VARIANT_STYLES = {
info: 'bg-blue-100/20 dark:bg-blue-950/20 border border-blue-500 text-blue-500',
success: 'bg-green-100/20 dark:bg-green-950/20 border border-green-500 text-green-500',
alert: 'bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500'
}
export default function InfoCard({
title,
content,
icon,
variant = 'info'
}: {
title: string
content?: string
icon?: React.ReactNode
variant?: 'info' | 'success' | 'alert'
}) {
return (
<div className={cn('p-3 rounded-lg text-sm [&_svg]:size-4', VARIANT_STYLES[variant])}>
<div className="flex items-center gap-2">
{icon ?? ICON_MAP[variant]}
<div className="font-medium">{title}</div>
</div>
{content && <div className="pl-6">{content}</div>}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { TMailboxRelay } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard'
import InfoCard from '../InfoCard'
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
const { t } = useTranslation()
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
}
return (
<AlertCard
<InfoCard
variant="alert"
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
content={
showReadWarning

View File

@@ -15,13 +15,15 @@ export default function NormalFeed({
areAlgoRelays = false,
isMainFeed = false,
showRelayCloseReason = false,
disable24hMode = false
disable24hMode = false,
onRefresh
}: {
subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean
isMainFeed?: boolean
showRelayCloseReason?: boolean
disable24hMode?: boolean
onRefresh?: () => void
}) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter()
@@ -65,6 +67,10 @@ export default function NormalFeed({
{!supportTouch && (
<RefreshButton
onClick={() => {
if (onRefresh) {
onRefresh()
return
}
if (listMode === '24h') {
userAggregationListRef.current?.refresh()
} else {

View File

@@ -26,7 +26,7 @@ export default function FollowPack({ event, className }: { event: Event; classNa
{image && (
<Image
image={{ url: image, pubkey: event.pubkey }}
className="w-24 h-20 object-cover rounded-lg"
className="w-24 h-20 object-cover"
classNames={{
wrapper: 'w-24 h-20 flex-shrink-0',
errorPlaceholder: 'w-24 h-20'

View File

@@ -25,7 +25,12 @@ export default function Highlight({ event, className }: { event: Event; classNam
{comment && <Content event={createFakeEvent({ content: comment, tags: event.tags })} />}
<div className="flex gap-4">
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
<div className="italic whitespace-pre-line">
<div
className="italic whitespace-pre-line"
style={{
overflowWrap: 'anywhere'
}}
>
{translatedEvent?.content ?? event.content}
</div>
</div>

View File

@@ -67,7 +67,7 @@ export default function LongFormArticlePreview({
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
className="aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError
/>
)}

View File

@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({
pageIndex,
event
}: {
pageIndex?: number
event: Event
}) {
export default function NoteInteractions({ event }: { event: Event }) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={event} />
list = <ReplyNoteList stuff={event} />
break
case 'quotes':
list = <QuoteList stuff={event} />

View File

@@ -7,9 +7,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import threadService from '@/services/thread.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
@@ -76,7 +76,6 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
@@ -92,6 +91,7 @@ const NoteList = forwardRef<
const supportTouch = useMemo(() => isTouchDevice(), [])
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const loadingRef = useRef(false)
const shouldHideEvent = useCallback(
(evt: Event) => {
@@ -273,12 +273,14 @@ const NoteList = forwardRef<
if (!subRequests.length) return
async function init() {
loadingRef.current = true
setLoading(true)
setEvents([])
setNewEvents([])
setHasMore(true)
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
loadingRef.current = false
setLoading(false)
setHasMore(false)
return () => {}
@@ -309,8 +311,9 @@ const NoteList = forwardRef<
setHasMore(false)
}
if (eosed) {
loadingRef.current = false
setLoading(false)
addReplies(events)
threadService.addRepliesToThread(events)
}
},
onNew: (event) => {
@@ -323,7 +326,7 @@ const NoteList = forwardRef<
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
addReplies([event])
threadService.addRepliesToThread([event])
},
onClose: (url, reason) => {
if (!showRelayCloseReason) return
@@ -374,13 +377,15 @@ const NoteList = forwardRef<
}
}
if (!timelineKey || loading || !hasMore) return
if (!timelineKey || loadingRef.current || !hasMore) return
loadingRef.current = true
setLoading(true)
const newEvents = await client.loadMoreTimeline(
timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT
)
loadingRef.current = false
setLoading(false)
if (newEvents.length === 0) {
setHasMore(false)
@@ -406,7 +411,7 @@ const NoteList = forwardRef<
observerInstance.unobserve(currentBottomRef)
}
}
}, [loading, hasMore, events, showCount, timelineKey])
}, [hasMore, events, showCount, timelineKey, loading])
const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents])

View File

@@ -40,8 +40,8 @@ export function ReactionNotification({
<Image
image={{ url: emojiUrl, pubkey: notification.pubkey }}
alt={emojiName}
className="w-6 h-6 rounded-md"
classNames={{ errorPlaceholder: 'bg-transparent' }}
className="w-6 h-6"
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
errorPlaceholder={<Heart size={24} className="text-red-400" />}
/>
)

View File

@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import client from '@/services/client.service'
import stuffStatsService from '@/services/stuff-stats.service'
import threadService from '@/services/thread.service'
import { TNotificationType } from '@/types'
import dayjs from 'dayjs'
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
const { pubkey } = useNostr()
const { getNotificationsSeenAt } = useNotification()
const { notificationListStyle } = useUserPreferences()
const { addReplies } = useReply()
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0)
@@ -143,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
if (eosed) {
setLoading(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
addReplies(events)
threadService.addRepliesToThread(events)
stuffStatsService.updateStuffStatsByEvents(events)
}
},
onNew: (event) => {
handleNewEvent(event)
addReplies([event])
threadService.addRepliesToThread([event])
}
}
)

View File

@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
import { Eraser, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard'
import InfoCard from '../InfoCard'
export default function PollEditor({
pollCreateData,
@@ -125,7 +125,8 @@ export default function PollEditor({
</div>
<div className="grid gap-2">
<AlertCard
<InfoCard
variant="alert"
title={t('This is a poll note.')}
content={t(
'Unlike regular notes, polls are not widely supported and may not display on other clients.'

View File

@@ -11,8 +11,8 @@ import {
} from '@/lib/draft-event'
import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import postEditorCache from '@/services/post-editor-cache.service'
import threadService from '@/services/thread.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
@@ -42,7 +42,6 @@ export default function PostContent({
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const { addReplies } = useReply()
const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null)
const [posting, setPosting] = useState(false)
@@ -157,7 +156,7 @@ export default function PostContent({
})
postEditorCache.clearPostCache({ defaultContent, parentStuff })
deleteDraftEventCache(draftEvent)
addReplies([newEvent])
threadService.addRepliesToThread([newEvent])
toast.success(t('Post successful'), { duration: 2000 })
close()
} catch (error) {

View File

@@ -1,5 +1,4 @@
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import Image from '../Image'
@@ -27,7 +26,10 @@ export default function ProfileBanner({
<Image
image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`}
className={cn('rounded-none', className)}
className={className}
classNames={{
wrapper: 'rounded-none'
}}
errorPlaceholder={defaultBanner}
/>
)

View File

@@ -36,9 +36,9 @@ export default function RelayInfo({ url, className }: { url: string; className?:
<div className="px-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2 justify-between">
<div className="flex gap-2 items-center truncate">
<div className="flex gap-2 items-center flex-1">
<RelayIcon url={url} className="w-8 h-8" />
<div className="text-2xl font-semibold truncate select-text">
<div className="text-2xl font-semibold truncate select-text flex-1 w-0">
{relayInfo.name || relayInfo.shortUrl}
</div>
</div>

View File

@@ -32,7 +32,16 @@ export default function RelaySimpleInfo({
</div>
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
</div>
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
{!!relayInfo?.description && (
<div
className="line-clamp-3 break-words whitespace-pre-wrap"
style={{
overflowWrap: 'anywhere'
}}
>
{relayInfo.description}
</div>
)}
{!!users?.length && (
<div className="flex items-center gap-2">
<div className="text-muted-foreground">{t('Favorited by')} </div>

View File

@@ -1,11 +1,14 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers } from '@/lib/event'
import { useThread } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,9 +18,10 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import StuffStats from '../StuffStats'
import ParentNotePreview from '../ParentNotePreview'
import StuffStats from '../StuffStats'
import TranslateButton from '../TranslateButton'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@@ -25,18 +29,23 @@ export default function ReplyNote({
event,
parentEventId,
onClickParent = () => {},
highlight = false
highlight = false,
className = ''
}: {
event: Event
parentEventId?: string
onClickParent?: () => void
highlight?: boolean
className?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { mutePubkeySet } = useMuteList()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const eventKey = useMemo(() => getEventKey(event), [event])
const replies = useThread(eventKey)
const [showMuted, setShowMuted] = useState(false)
const show = useMemo(() => {
if (showMuted) {
@@ -50,12 +59,35 @@ export default function ReplyNote({
}
return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
const hasReplies = useMemo(() => {
if (!replies || replies.length === 0) {
return false
}
for (const reply of replies) {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
}
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
return true
}
}, [replies])
return (
<div
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`}
className={cn(
'relative pb-3 transition-colors duration-500 clickable',
highlight ? 'bg-primary/40' : '',
className
)}
onClick={() => push(toNote(event))}
>
{hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
@@ -68,6 +100,7 @@ export default function ReplyNote({
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
<TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">

View File

@@ -0,0 +1,156 @@
import { useSecondaryPage } from '@/PageManager'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [isExpanded, setIsExpanded] = useState(false)
const replies = useMemo(() => {
const replyKeySet = new Set<string>()
const replyEvents: NostrEvent[] = []
let parentKeys = [parentKey]
while (parentKeys.length > 0) {
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
events.forEach((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return
}
}
replyKeySet.add(key)
replyEvents.push(evt)
})
parentKeys = events.map((evt) => getEventKey(evt))
}
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [
parentKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
])
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
let found = false
if (scrollTo) {
const ref = replyRefs.current[key]
if (ref) {
found = true
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
if (!found) {
if (eventId) push(toNote(eventId))
return
}
setHighlightReplyKey(key)
setTimeout(() => {
setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
}, 1500)
}, [])
if (replies.length === 0) return null
return (
<div>
{replies.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable"
>
<div
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
style={{
background: isExpanded
? 'currentColor'
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
}}
/>
{isExpanded ? (
<>
<ChevronUp className="size-3.5" />
<span>
{t('Hide replies')} ({replies.length})
</span>
</>
) : (
<>
<ChevronDown className="size-3.5" />
<span>
{t('Show replies')} ({replies.length})
</span>
</>
)}
</button>
)}
{(isExpanded || replies.length === 1) && (
<div>
{replies.map((reply, index) => {
const currentReplyKey = getEventKey(reply)
const _parentTag = getParentTag(reply)
if (_parentTag?.type !== 'e') return null
const _parentKey = _parentTag ? getKeyFromTag(_parentTag.tag) : undefined
const _parentEventId = generateBech32IdFromETag(_parentTag.tag)
return (
<div
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
key={currentReplyKey}
className="scroll-mt-12 flex relative"
>
<div className="absolute left-[34px] top-0 h-8 w-4 rounded-bl-lg border-l border-b z-20" />
{index < replies.length - 1 && (
<div className="absolute left-[34px] top-0 bottom-0 border-l z-20" />
)}
<ReplyNote
className="flex-1 w-0 pl-10"
event={reply}
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
onClickParent={() => {
if (!_parentKey) return
highlightReply(_parentKey, _parentEventId)
}}
highlight={highlightReplyKey === currentReplyKey}
/>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,339 +1,151 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { useStuff } from '@/hooks/useStuff'
import {
getEventKey,
getKeyFromTag,
getParentTag,
getReplaceableCoordinateFromEvent,
getRootTag,
isMentioningMutedUsers,
isProtectedEvent,
isReplaceableEvent
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import threadService from '@/services/thread.service'
import { Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
import SubReplies from './SubReplies'
const LIMIT = 100
const SHOW_COUNT = 10
export default function ReplyNoteList({
stuff,
index
}: {
stuff: NEvent | string
index?: number
}) {
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
const { t } = useTranslation()
const { push, currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
const { event, externalContent, stuffKey } = useStuff(stuff)
const { stuffKey } = useStuff(stuff)
const allThreads = useAllDescendantThreads(stuffKey)
const replies = useMemo(() => {
const replyKeySet = new Set<string>()
const replyEvents: NEvent[] = []
const thread = allThreads.get(stuffKey) || []
const replyEvents = thread.filter((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return false
if (mutePubkeySet.has(evt.pubkey)) return false
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
return false
}
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return false
}
}
let parentKeys = [stuffKey]
while (parentKeys.length > 0) {
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
events.forEach((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
replyKeySet.add(key)
replyEvents.push(evt)
})
parentKeys = events.map((evt) => getEventKey(evt))
}
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [stuffKey, repliesMap])
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
replyKeySet.add(key)
return true
})
return replyEvents.sort((a, b) => b.created_at - a.created_at)
}, [
stuffKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
])
const [hasMore, setHasMore] = useState(true)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const [loading, setLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement | null>(null)
const stateRef = useRef({ loading, hasMore, showCount, repliesLength: replies.length })
stateRef.current = { loading, hasMore, showCount, repliesLength: replies.length }
// Initial subscription
useEffect(() => {
const fetchRootEvent = async () => {
if (!event && !externalContent) return
let root: TRootInfo = event
? isReplaceableEvent(event.kind)
? {
type: 'A',
id: getReplaceableCoordinateFromEvent(event),
pubkey: event.pubkey,
relay: client.getEventHint(event.id)
}
: { type: 'E', id: event.id, pubkey: event.pubkey }
: { type: 'I', id: externalContent! }
const rootTag = getRootTag(event)
if (rootTag?.type === 'e') {
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
if (rootEventHexId && rootEventPubkey) {
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
} else {
const rootEventId = generateBech32IdFromETag(rootTag.tag)
if (rootEventId) {
const rootEvent = await client.fetchEvent(rootEventId)
if (rootEvent) {
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
}
}
}
} else if (rootTag?.type === 'a') {
const [, coordinate, relay] = rootTag.tag
const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, pubkey, relay }
} else if (rootTag?.type === 'i') {
root = { type: 'I', id: rootTag.tag[1] }
}
setRootInfo(root)
}
fetchRootEvent()
}, [event])
useEffect(() => {
if (loading || !rootInfo || currentIndex !== index) return
const init = async () => {
setLoading(true)
try {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
}
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
// If current event is protected, we can assume its replies are also protected and stored on the same relays
if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
} else if (rootInfo.type === 'A') {
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
}
)
if (rootInfo.relay) {
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{
onEvents: (evts, eosed) => {
if (evts.length > 0) {
addReplies(evts)
}
if (eosed) {
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
setLoading(false)
}
},
onNew: (evt) => {
addReplies([evt])
}
}
)
setTimelineKey(timelineKey)
return closer
} catch {
setLoading(false)
}
return
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [rootInfo, currentIndex, index])
useEffect(() => {
if (replies.length === 0) {
loadMore()
}
}, [replies])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && showCount < replies.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
setLoading(true)
threadService.subscribe(stuff, LIMIT).finally(() => {
setLoading(false)
})
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
threadService.unsubscribe(stuff)
}
}, [replies, showCount])
}, [stuff])
const loadMore = useCallback(async () => {
if (loading || !until || !timelineKey) return
const { loading, hasMore, showCount, repliesLength } = stateRef.current
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
addReplies(events)
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
}, [loading, until, timelineKey])
if (loading || !hasMore) return
const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
let found = false
if (scrollTo) {
const ref = replyRefs.current[key]
if (ref) {
found = true
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
// If there are more items to show, increase showCount first
if (showCount < repliesLength) {
setShowCount((prev) => prev + SHOW_COUNT)
// Only fetch more data when remaining items are running low
if (repliesLength - showCount > LIMIT / 2) {
return
}
}
if (!found) {
if (eventId) push(toNote(eventId))
return
}
setHighlightReplyKey(key)
setTimeout(() => {
setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
}, 1500)
}, [])
setLoading(true)
const newHasMore = await threadService.loadMore(stuff, LIMIT)
setHasMore(newHasMore)
setLoading(false)
}, [stuff])
// IntersectionObserver setup
useEffect(() => {
const currentBottomRef = bottomRef.current
if (!currentBottomRef) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{
root: null,
rootMargin: '100px',
threshold: 0
}
)
observer.observe(currentBottomRef)
return () => {
observer.disconnect()
}
}, [loadMore])
return (
<div className="min-h-[80vh]">
{loading && <LoadingBar />}
{!loading && until && (!event || until > event.created_at) && (
<div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{t('load more older replies')}
</div>
)}
<div>
{replies.slice(0, showCount).map((reply) => {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
const replyKey = getEventKey(reply)
const repliesForThisReply = repliesMap.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
) {
return null
}
}
const rootKey = event ? getEventKey(event) : externalContent!
const currentReplyKey = getEventKey(reply)
const parentTag = getParentTag(reply)
const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
const parentEventId = parentTag
? parentTag.type === 'e'
? generateBech32IdFromETag(parentTag.tag)
: parentTag.type === 'a'
? generateBech32IdFromATag(parentTag.tag)
: undefined
: undefined
return (
<div
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
key={currentReplyKey}
className="scroll-mt-12"
>
<ReplyNote
event={reply}
parentEventId={rootKey !== parentKey ? parentEventId : undefined}
onClickParent={() => {
if (!parentKey) return
highlightReply(parentKey, parentEventId)
}}
highlight={highlightReplyKey === currentReplyKey}
/>
</div>
)
})}
{replies.slice(0, showCount).map((reply) => (
<Item key={reply.id} reply={reply} />
))}
</div>
{!loading && (
{hasMore || showCount < replies.length || loading ? (
<ReplyNoteSkeleton />
) : (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{replies.length > 0 ? t('no more replies') : t('no replies')}
</div>
)}
<div ref={bottomRef} />
{loading && <ReplyNoteSkeleton />}
</div>
)
}
function Item({ reply }: { reply: NEvent }) {
const key = useMemo(() => getEventKey(reply), [reply])
return (
<div className="relative border-b">
<ReplyNote event={reply} />
<SubReplies parentKey={key} />
</div>
)
}

View File

@@ -1,10 +1,10 @@
import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const { repliesMap } = useReply()
const allThreads = useAllDescendantThreads(stuffKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
: false
let replyCount = 0
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
const replies = [...(allThreads.get(stuffKey) ?? [])]
while (replies.length > 0) {
const reply = replies.pop()
if (!reply) break
const replyKey = getEventKey(reply)
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
const nestedReplies = allThreads.get(replyKey) ?? []
replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
}
return { replyCount, hasReplied }
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
const [open, setOpen] = useState(false)
return (

View File

@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
const dismissed = storage.getDismissedTooManyRelaysAlert()
if (dismissed) return
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) {
if (relayList && (relayList.read.length > 5 || relayList.write.length > 5)) {
setOpen(true)
} else {
setOpen(false)

View File

@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import threadService from '@/services/thread.service'
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
const { pinnedPubkeySet } = usePinnedUsers()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
if (eosed) {
setLoading(false)
setHasMore(events.length > 0)
addReplies(events)
threadService.addRepliesToThread(events)
}
},
onNew: (event) => {
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
addReplies([event])
threadService.addRepliesToThread([event])
},
onClose: (url, reason) => {
if (!showRelayCloseReason) return

View File

@@ -68,9 +68,9 @@ export default function WebPreview({
{image && (
<Image
image={{ url: image }}
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 rounded-none border-r"
className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
classNames={{
skeleton: 'rounded-none border-r'
wrapper: 'rounded-none border-r'
}}
hideIfError
/>

View File

@@ -25,7 +25,7 @@ const buttonVariants = cva(
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9',
icon: 'h-9 w-9 shrink-0',
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
}
},

View File

@@ -1,4 +1,5 @@
import { kinds } from 'nostr-tools'
import { TMailboxRelay } from './types'
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
@@ -71,6 +72,13 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
export const NEW_USER_RELAY_LIST: TMailboxRelay[] = [
{ url: 'wss://nos.lol/', scope: 'both' },
{ url: 'wss://offchain.pub/', scope: 'both' },
{ url: 'wss://relay.damus.io/', scope: 'both' },
{ url: 'wss://nostr.mom/', scope: 'both' }
]
export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {

View File

@@ -1,5 +1,4 @@
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
@@ -7,7 +6,6 @@ import { useEffect, useState } from 'react'
export function useFetchEvent(eventId?: string) {
const { isEventDeleted } = useDeletedEvent()
const [isFetching, setIsFetching] = useState(true)
const { addReplies } = useReply()
const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(undefined)
@@ -23,7 +21,6 @@ export function useFetchEvent(eventId?: string) {
const event = await client.fetchEvent(eventId)
if (event && !isEventDeleted(event)) {
setEvent(event)
addReplies([event])
}
}

16
src/hooks/useThread.tsx Normal file
View File

@@ -0,0 +1,16 @@
import threadService from '@/services/thread.service'
import { useSyncExternalStore } from 'react'
export function useThread(stuffKey: string) {
return useSyncExternalStore(
(cb) => threadService.listenThread(stuffKey, cb),
() => threadService.getThread(stuffKey)
)
}
export function useAllDescendantThreads(stuffKey: string) {
return useSyncExternalStore(
(cb) => threadService.listenAllDescendantThreads(stuffKey, cb),
() => threadService.getAllDescendantThreads(stuffKey)
)
}

View File

@@ -19,6 +19,7 @@ import pt_PT from './locales/pt-PT'
import ru from './locales/ru'
import th from './locales/th'
import zh from './locales/zh'
import zh_TW from './locales/zh-TW'
const languages = {
ar: { resource: ar, name: 'العربية' },
@@ -37,7 +38,8 @@ const languages = {
'pt-PT': { resource: pt_PT, name: 'Português (Portugal)' },
ru: { resource: ru, name: 'Русский' },
th: { resource: th, name: 'ไทย' },
zh: { resource: zh, name: '简体中文' }
zh: { resource: zh, name: '简体中文' },
'zh-TW': { resource: zh_TW, name: '繁體中文' }
} as const
export type TLanguage = keyof typeof languages
@@ -62,6 +64,10 @@ i18n
},
detection: {
convertDetectedLanguage: (lng) => {
console.log('Detected language:', lng)
if (lng.startsWith('zh')) {
return ['zh', 'zh-CN', 'zh-SG'].includes(lng) ? 'zh' : 'zh-TW'
}
const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
return supported || 'en'
}
@@ -71,6 +77,7 @@ i18n
i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) {
case 'zh':
case 'zh-TW':
case 'ja':
return dayjs(timestamp).format('YYYY年MM月DD日')
case 'pl':

View File

@@ -587,6 +587,53 @@ export default {
'Relay Feeds': 'تدفقات الترحيل',
'Create Highlight': 'إنشاء تمييز',
'Write your thoughts about this highlight...': 'اكتب أفكارك حول هذا التمييز...',
'Publish Highlight': 'نشر التمييز'
'Publish Highlight': 'نشر التمييز',
'Show replies': 'إظهار الردود',
'Hide replies': 'إخفاء الردود',
'Welcome to Jumble!': 'مرحبًا بك في Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'خلاصتك فارغة لأنك لا تتابع أي شخص بعد. ابدأ باستكشاف محتوى مثير للاهتمام ومتابعة المستخدمين الذين تحبهم!',
'Search Users': 'البحث عن المستخدمين',
'Create New Account': 'إنشاء حساب جديد',
Important: 'مهم',
'Generate Your Account': 'إنشاء حسابك',
'Your private key IS your account. Keep it safe!': 'مفتاحك الخاص هو حسابك. احتفظ به بأمان!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'في Nostr، مفتاحك الخاص هو حسابك. إذا فقدت مفتاحك الخاص، ستفقد حسابك إلى الأبد.',
'Your Private Key': 'مفتاحك الخاص',
'Generate new key': 'إنشاء مفتاح جديد',
'Download Backup File': 'تنزيل ملف النسخ الاحتياطي',
'Copied to Clipboard': 'تم النسخ إلى الحافظة',
'Copy to Clipboard': 'نسخ إلى الحافظة',
'I already saved my private key securely.': 'لقد حفظت مفتاحي الخاص بشكل آمن بالفعل.',
'Almost Done!': 'على وشك الانتهاء!',
'Set a password to encrypt your key, or skip to finish':
'قم بتعيين كلمة مرور لتشفير مفتاحك، أو تخطى للانتهاء',
'Password Protection (Optional)': 'الحماية بكلمة مرور (اختياري)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'يؤدي تعيين كلمة مرور إلى تشفير مفتاحك الخاص في هذا المتصفح. يمكنك تخطي هذه الخطوة، لكننا نوصي بتعيين واحدة لمزيد من الأمان.',
'Password (Optional)': 'كلمة المرور (اختياري)',
'Enter password or leave empty to skip': 'أدخل كلمة المرور أو اتركها فارغة للتخطي',
'Confirm Password': 'تأكيد كلمة المرور',
'Re-enter password': 'أعد إدخال كلمة المرور',
'Passwords do not match': 'كلمات المرور غير متطابقة',
'Finish Signup': 'إنهاء التسجيل',
// New improved signup copy
'Create Your Nostr Account': 'أنشئ حساب Nostr الخاص بك',
'Generate your unique private key. This is your digital identity.':
'أنشئ مفتاحك الخاص الفريد. هذه هي هويتك الرقمية.',
'Critical: Save Your Private Key': 'حرج: احفظ مفتاحك الخاص',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'مفتاحك الخاص هو حسابك. لا يوجد استرداد لكلمة المرور. إذا فقدته، ستفقد حسابك للأبد. يرجى حفظه في مكان آمن.',
'I have safely backed up my private key': 'لقد قمت بعمل نسخة احتياطية آمنة لمفتاحي الخاص',
'Secure Your Account': 'أمّن حسابك',
'Add an extra layer of protection with a password': 'أضف طبقة إضافية من الحماية بكلمة مرور',
'Password Protection (Recommended)': 'الحماية بكلمة مرور (موصى به)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'أضف كلمة مرور لتشفير مفتاحك الخاص في هذا المتصفح. هذا اختياري لكنه موصى به بشدة لأمان أفضل.',
'Create a password (or skip)': 'أنشئ كلمة مرور (أو تخطى)',
'Enter your password again': 'أدخل كلمة المرور مرة أخرى',
'Complete Signup': 'إكمال التسجيل',
Recommended: 'موصى به'
}
}

View File

@@ -604,6 +604,57 @@ export default {
'Create Highlight': 'Markierung Erstellen',
'Write your thoughts about this highlight...':
'Schreiben Sie Ihre Gedanken zu dieser Markierung...',
'Publish Highlight': 'Markierung Veröffentlichen'
'Publish Highlight': 'Markierung Veröffentlichen',
'Show replies': 'Antworten anzeigen',
'Hide replies': 'Antworten ausblenden',
'Welcome to Jumble!': 'Willkommen bei Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Ihr Feed ist leer, weil Sie noch niemandem folgen. Beginnen Sie damit, interessante Inhalte zu erkunden und Benutzern zu folgen, die Ihnen gefallen!',
'Search Users': 'Benutzer suchen',
'Create New Account': 'Neues Konto erstellen',
Important: 'Wichtig',
'Generate Your Account': 'Konto generieren',
'Your private key IS your account. Keep it safe!':
'Ihr privater Schlüssel IST Ihr Konto. Bewahren Sie ihn sicher auf!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'In Nostr IST Ihr privater Schlüssel Ihr Konto. Wenn Sie Ihren privaten Schlüssel verlieren, verlieren Sie Ihr Konto für immer.',
'Your Private Key': 'Ihr privater Schlüssel',
'Generate new key': 'Neuen Schlüssel generieren',
'Download Backup File': 'Sicherungsdatei herunterladen',
'Copied to Clipboard': 'In Zwischenablage kopiert',
'Copy to Clipboard': 'In Zwischenablage kopieren',
'I already saved my private key securely.':
'Ich habe meinen privaten Schlüssel bereits sicher gespeichert.',
'Almost Done!': 'Fast fertig!',
'Set a password to encrypt your key, or skip to finish':
'Legen Sie ein Passwort fest, um Ihren Schlüssel zu verschlüsseln, oder überspringen Sie, um fertig zu werden',
'Password Protection (Optional)': 'Passwortschutz (optional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Das Festlegen eines Passworts verschlüsselt Ihren privaten Schlüssel in diesem Browser. Sie können diesen Schritt überspringen, aber wir empfehlen, eines für zusätzliche Sicherheit festzulegen.',
'Password (Optional)': 'Passwort (optional)',
'Enter password or leave empty to skip':
'Passwort eingeben oder leer lassen, um zu überspringen',
'Confirm Password': 'Passwort bestätigen',
'Re-enter password': 'Passwort erneut eingeben',
'Passwords do not match': 'Passwörter stimmen nicht überein',
'Finish Signup': 'Registrierung abschließen',
// New improved signup copy
'Create Your Nostr Account': 'Erstellen Sie Ihr Nostr-Konto',
'Generate your unique private key. This is your digital identity.':
'Generieren Sie Ihren einzigartigen privaten Schlüssel. Dies ist Ihre digitale Identität.',
'Critical: Save Your Private Key': 'Kritisch: Speichern Sie Ihren privaten Schlüssel',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Ihr privater Schlüssel IST Ihr Konto. Es gibt keine Passwortwiederherstellung. Wenn Sie ihn verlieren, verlieren Sie Ihr Konto für immer. Bitte speichern Sie ihn an einem sicheren Ort.',
'I have safely backed up my private key': 'Ich habe meinen privaten Schlüssel sicher gesichert',
'Secure Your Account': 'Sichern Sie Ihr Konto',
'Add an extra layer of protection with a password':
'Fügen Sie eine zusätzliche Schutzebene mit einem Passwort hinzu',
'Password Protection (Recommended)': 'Passwortschutz (empfohlen)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Fügen Sie ein Passwort hinzu, um Ihren privaten Schlüssel in diesem Browser zu verschlüsseln. Dies ist optional, aber für bessere Sicherheit dringend empfohlen.',
'Create a password (or skip)': 'Erstellen Sie ein Passwort (oder überspringen)',
'Enter your password again': 'Geben Sie Ihr Passwort erneut ein',
'Complete Signup': 'Registrierung abschließen',
Recommended: 'Empfohlen'
}
}

View File

@@ -590,6 +590,55 @@ export default {
'Relay Feeds': 'Relay Feeds',
'Create Highlight': 'Create Highlight',
'Write your thoughts about this highlight...': 'Write your thoughts about this highlight...',
'Publish Highlight': 'Publish Highlight'
'Publish Highlight': 'Publish Highlight',
'Show replies': 'Show replies',
'Hide replies': 'Hide replies',
'Welcome to Jumble!': 'Welcome to Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!',
'Search Users': 'Search Users',
'Create New Account': 'Create New Account',
Important: 'Important',
'Generate Your Account': 'Generate Your Account',
'Your private key IS your account. Keep it safe!':
'Your private key IS your account. Keep it safe!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'In Nostr, your private key IS your account. If you lose your account forever.',
'Your Private Key': 'Your Private Key',
'Generate new key': 'Generate new key',
'Download Backup File': 'Download Backup File',
'Copied to Clipboard': 'Copied to Clipboard',
'Copy to Clipboard': 'Copy to Clipboard',
'I already saved my private key securely.': 'I already saved my private key securely.',
'Almost Done!': 'Almost Done!',
'Set a password to encrypt your key, or skip to finish':
'Set a password to encrypt your key, or skip to finish',
'Password Protection (Optional)': 'Password Protection (Optional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.',
'Password (Optional)': 'Password (Optional)',
'Enter password or leave empty to skip': 'Enter password or leave empty to skip',
'Confirm Password': 'Confirm Password',
'Re-enter password': 'Re-enter password',
'Passwords do not match': 'Passwords do not match',
'Finish Signup': 'Finish Signup',
// New improved signup copy
'Create Your Nostr Account': 'Create Your Nostr Account',
'Generate your unique private key. This is your digital identity.':
'Generate your unique private key. This is your digital identity.',
'Critical: Save Your Private Key': 'Critical: Save Your Private Key',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.',
'I have safely backed up my private key': 'I have safely backed up my private key',
'Secure Your Account': 'Secure Your Account',
'Add an extra layer of protection with a password':
'Add an extra layer of protection with a password',
'Password Protection (Recommended)': 'Password Protection (Recommended)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.',
'Create a password (or skip)': 'Create a password (or skip)',
'Enter your password again': 'Enter your password again',
'Complete Signup': 'Complete Signup',
Recommended: 'Recommended'
}
}

View File

@@ -600,6 +600,55 @@ export default {
'Create Highlight': 'Crear Resaltado',
'Write your thoughts about this highlight...':
'Escribe tus pensamientos sobre este resaltado...',
'Publish Highlight': 'Publicar Resaltado'
'Publish Highlight': 'Publicar Resaltado',
'Show replies': 'Mostrar respuestas',
'Hide replies': 'Ocultar respuestas',
'Welcome to Jumble!': '¡Bienvenido a Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Tu feed está vacío porque aún no sigues a nadie. ¡Comienza explorando contenido interesante y siguiendo a los usuarios que te gusten!',
'Search Users': 'Buscar Usuarios',
'Create New Account': 'Crear nueva cuenta',
Important: 'Importante',
'Generate Your Account': 'Generar tu cuenta',
'Your private key IS your account. Keep it safe!':
'¡Tu clave privada ES tu cuenta. Mantenla segura!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'En Nostr, tu clave privada ES tu cuenta. Si pierdes tu clave privada, pierdes tu cuenta para siempre.',
'Your Private Key': 'Tu clave privada',
'Generate new key': 'Generar nueva clave',
'Download Backup File': 'Descargar archivo de respaldo',
'Copied to Clipboard': 'Copiado al portapapeles',
'Copy to Clipboard': 'Copiar al portapapeles',
'I already saved my private key securely.': 'Ya guardé mi clave privada de forma segura.',
'Almost Done!': '¡Casi terminado!',
'Set a password to encrypt your key, or skip to finish':
'Establece una contraseña para cifrar tu clave, o omítela para finalizar',
'Password Protection (Optional)': 'Protección con contraseña (opcional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Establecer una contraseña cifra tu clave privada en este navegador. Puedes omitir este paso, pero recomendamos establecer una para mayor seguridad.',
'Password (Optional)': 'Contraseña (opcional)',
'Enter password or leave empty to skip': 'Ingresa una contraseña o déjalo vacío para omitir',
'Confirm Password': 'Confirmar contraseña',
'Re-enter password': 'Vuelve a ingresar la contraseña',
'Passwords do not match': 'Las contraseñas no coinciden',
'Finish Signup': 'Finalizar registro',
// New improved signup copy
'Create Your Nostr Account': 'Crea tu cuenta de Nostr',
'Generate your unique private key. This is your digital identity.':
'Genera tu clave privada única. Esta es tu identidad digital.',
'Critical: Save Your Private Key': 'Crítico: Guarda tu clave privada',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Tu clave privada ES tu cuenta. No hay recuperación de contraseña. Si la pierdes, perderás tu cuenta para siempre. Por favor, guárdala en un lugar seguro.',
'I have safely backed up my private key': 'He respaldado mi clave privada de forma segura',
'Secure Your Account': 'Asegura tu cuenta',
'Add an extra layer of protection with a password':
'Añade una capa adicional de protección con una contraseña',
'Password Protection (Recommended)': 'Protección con contraseña (recomendado)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Añade una contraseña para cifrar tu clave privada en este navegador. Esto es opcional pero muy recomendado para mayor seguridad.',
'Create a password (or skip)': 'Crear una contraseña (o saltar)',
'Enter your password again': 'Ingresa tu contraseña nuevamente',
'Complete Signup': 'Completar registro',
Recommended: 'Recomendado'
}
}

View File

@@ -593,6 +593,57 @@ export default {
'Relay Feeds': 'فیدهای رله',
'Create Highlight': 'ایجاد برجسته‌سازی',
'Write your thoughts about this highlight...': 'نظرات خود را درباره این برجسته‌سازی بنویسید...',
'Publish Highlight': 'انتشار برجسته‌سازی'
'Publish Highlight': 'انتشار برجسته‌سازی',
'Show replies': 'نمایش پاسخ‌ها',
'Hide replies': 'پنهان کردن پاسخ‌ها',
'Welcome to Jumble!': 'به Jumble خوش آمدید!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'فید شما خالی است زیرا هنوز کسی را دنبال نمی‌کنید. با کاوش محتوای جالب و دنبال کردن کاربرانی که دوست دارید شروع کنید!',
'Search Users': 'جستجوی کاربران',
'Create New Account': 'ایجاد حساب کاربری جدید',
Important: 'مهم',
'Generate Your Account': 'ایجاد حساب کاربری',
'Your private key IS your account. Keep it safe!':
'کلید خصوصی شما همان حساب کاربری شماست. آن را ایمن نگه دارید!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'در Nostr، کلید خصوصی شما همان حساب کاربری شماست. اگر کلید خصوصی خود را گم کنید، برای همیشه حساب خود را از دست می‌دهید.',
'Your Private Key': 'کلید خصوصی شما',
'Generate new key': 'ایجاد کلید جدید',
'Download Backup File': 'دانلود فایل پشتیبان',
'Copied to Clipboard': 'در کلیپ‌بورد کپی شد',
'Copy to Clipboard': 'کپی در کلیپ‌بورد',
'I already saved my private key securely.':
'من قبلاً کلید خصوصی خود را به طور ایمن ذخیره کرده‌ام.',
'Almost Done!': 'تقریباً تمام شد!',
'Set a password to encrypt your key, or skip to finish':
'یک رمز عبور برای رمزگذاری کلید خود تنظیم کنید، یا برای پایان دادن رد کنید',
'Password Protection (Optional)': 'حفاظت با رمز عبور (اختیاری)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'تنظیم رمز عبور، کلید خصوصی شما را در این مرورگر رمزگذاری می‌کند. می‌توانید این مرحله را رد کنید، اما ما برای امنیت بیشتر توصیه می‌کنیم یکی تنظیم کنید.',
'Password (Optional)': 'رمز عبور (اختیاری)',
'Enter password or leave empty to skip': 'رمز عبور را وارد کنید یا برای رد کردن خالی بگذارید',
'Confirm Password': 'تأیید رمز عبور',
'Re-enter password': 'رمز عبور را دوباره وارد کنید',
'Passwords do not match': 'رمزهای عبور مطابقت ندارند',
'Finish Signup': 'پایان ثبت‌نام',
// New improved signup copy
'Create Your Nostr Account': 'حساب Nostr خود را ایجاد کنید',
'Generate your unique private key. This is your digital identity.':
'کلید خصوصی منحصر به فرد خود را ایجاد کنید. این هویت دیجیتال شماست.',
'Critical: Save Your Private Key': 'حیاتی: کلید خصوصی خود را ذخیره کنید',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'کلید خصوصی شما حساب شماست. بازیابی رمز عبور وجود ندارد. اگر آن را گم کنید، حساب خود را برای همیشه از دست خواهید داد. لطفاً آن را در مکانی امن ذخیره کنید.',
'I have safely backed up my private key':
'من به طور ایمن از کلید خصوصی خود نسخه پشتیبان تهیه کرده‌ام',
'Secure Your Account': 'حساب خود را ایمن کنید',
'Add an extra layer of protection with a password':
'یک لایه حفاظتی اضافی با رمز عبور اضافه کنید',
'Password Protection (Recommended)': 'حفاظت با رمز عبور (توصیه شده)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'یک رمز عبور برای رمزگذاری کلید خصوصی خود در این مرورگر اضافه کنید. این اختیاری است اما برای امنیت بهتر به شدت توصیه می‌شود.',
'Create a password (or skip)': 'یک رمز عبور ایجاد کنید (یا رد کنید)',
'Enter your password again': 'رمز عبور خود را دوباره وارد کنید',
'Complete Signup': 'تکمیل ثبت‌نام',
Recommended: 'توصیه شده'
}
}

View File

@@ -602,6 +602,56 @@ export default {
'Relay Feeds': 'Flux de Relais',
'Create Highlight': 'Créer un Surlignage',
'Write your thoughts about this highlight...': 'Écrivez vos pensées sur ce surlignage...',
'Publish Highlight': 'Publier le Surlignage'
'Publish Highlight': 'Publier le Surlignage',
'Show replies': 'Afficher les réponses',
'Hide replies': 'Masquer les réponses',
'Welcome to Jumble!': 'Bienvenue sur Jumble !',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Votre flux est vide car vous ne suivez personne pour le moment. Commencez par explorer du contenu intéressant et suivez les utilisateurs que vous aimez !',
'Search Users': 'Rechercher des utilisateurs',
'Create New Account': 'Créer un nouveau compte',
Important: 'Important',
'Generate Your Account': 'Générer votre compte',
'Your private key IS your account. Keep it safe!':
'Votre clé privée EST votre compte. Gardez-la en sécurité !',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Dans Nostr, votre clé privée EST votre compte. Si vous perdez votre clé privée, vous perdez votre compte pour toujours.',
'Your Private Key': 'Votre clé privée',
'Generate new key': 'Générer une nouvelle clé',
'Download Backup File': 'Télécharger le fichier de sauvegarde',
'Copied to Clipboard': 'Copié dans le presse-papiers',
'Copy to Clipboard': 'Copier dans le presse-papiers',
'I already saved my private key securely.':
"J'ai déjà sauvegardé ma clé privée en toute sécurité.",
'Almost Done!': 'Presque terminé !',
'Set a password to encrypt your key, or skip to finish':
'Définissez un mot de passe pour chiffrer votre clé, ou ignorez pour terminer',
'Password Protection (Optional)': 'Protection par mot de passe (facultatif)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
"Définir un mot de passe chiffre votre clé privée dans ce navigateur. Vous pouvez ignorer cette étape, mais nous recommandons d'en définir un pour plus de sécurité.",
'Password (Optional)': 'Mot de passe (facultatif)',
'Enter password or leave empty to skip': 'Entrez un mot de passe ou laissez vide pour ignorer',
'Confirm Password': 'Confirmer le mot de passe',
'Re-enter password': 'Ressaisissez le mot de passe',
'Passwords do not match': 'Les mots de passe ne correspondent pas',
'Finish Signup': "Terminer l'inscription",
// New improved signup copy
'Create Your Nostr Account': 'Créez votre compte Nostr',
'Generate your unique private key. This is your digital identity.':
"Générez votre clé privée unique. C'est votre identité numérique.",
'Critical: Save Your Private Key': 'Critique : Sauvegardez votre clé privée',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
"Votre clé privée EST votre compte. Il n'y a pas de récupération de mot de passe. Si vous la perdez, vous perdrez votre compte pour toujours. Veuillez la sauvegarder dans un endroit sécurisé.",
'I have safely backed up my private key': "J'ai sauvegardé ma clé privée en toute sécurité",
'Secure Your Account': 'Sécurisez votre compte',
'Add an extra layer of protection with a password':
'Ajoutez une couche de protection supplémentaire avec un mot de passe',
'Password Protection (Recommended)': 'Protection par mot de passe (recommandé)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
"Ajoutez un mot de passe pour chiffrer votre clé privée dans ce navigateur. C'est facultatif mais fortement recommandé pour une meilleure sécurité.",
'Create a password (or skip)': 'Créez un mot de passe (ou ignorez)',
'Enter your password again': 'Entrez à nouveau votre mot de passe',
'Complete Signup': "Terminer l'inscription",
Recommended: 'Recommandé'
}
}

View File

@@ -594,6 +594,57 @@ export default {
'Relay Feeds': 'रिले फ़ीड',
'Create Highlight': 'हाइलाइट बनाएं',
'Write your thoughts about this highlight...': 'इस हाइलाइट के बारे में अपने विचार लिखें...',
'Publish Highlight': 'हाइलाइट प्रकाशित करें'
'Publish Highlight': 'हाइलाइट प्रकाशित करें',
'Show replies': 'जवाब दिखाएं',
'Hide replies': 'जवाब छुपाएं',
'Welcome to Jumble!': 'Jumble में आपका स्वागत है!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'आपका फ़ीड खाली है क्योंकि आप अभी तक किसी को फ़ॉलो नहीं कर रहे हैं। दिलचस्प सामग्री का अन्वेषण करके और अपनी पसंद के उपयोगकर्ताओं को फ़ॉलो करके शुरू करें!',
'Search Users': 'उपयोगकर्ता खोजें',
'Create New Account': 'नया खाता बनाएं',
Important: 'महत्वपूर्ण',
'Generate Your Account': 'अपना खाता बनाएं',
'Your private key IS your account. Keep it safe!':
'आपकी निजी कुंजी ही आपका खाता है। इसे सुरक्षित रखें!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Nostr में, आपकी निजी कुंजी ही आपका खाता है। यदि आप अपनी निजी कुंजी खो देते हैं, तो आप अपना खाता हमेशा के लिए खो देते हैं।',
'Your Private Key': 'आपकी निजी कुंजी',
'Generate new key': 'नई कुंजी बनाएं',
'Download Backup File': 'बैकअप फ़ाइल डाउनलोड करें',
'Copied to Clipboard': 'क्लिपबोर्ड पर कॉपी किया गया',
'Copy to Clipboard': 'क्लिपबोर्ड पर कॉपी करें',
'I already saved my private key securely.':
'मैंने पहले ही अपनी निजी कुंजी को सुरक्षित रूप से सहेज लिया है।',
'Almost Done!': 'लगभग हो गया!',
'Set a password to encrypt your key, or skip to finish':
'अपनी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड सेट करें, या समाप्त करने के लिए छोड़ें',
'Password Protection (Optional)': 'पासवर्ड सुरक्षा (वैकल्पिक)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'पासवर्ड सेट करने से इस ब्राउज़र में आपकी निजी कुंजी एन्क्रिप्ट हो जाती है। आप इस चरण को छोड़ सकते हैं, लेकिन हम अतिरिक्त सुरक्षा के लिए एक सेट करने की सलाह देते हैं।',
'Password (Optional)': 'पासवर्ड (वैकल्पिक)',
'Enter password or leave empty to skip': 'पासवर्ड दर्ज करें या छोड़ने के लिए खाली छोड़ें',
'Confirm Password': 'पासवर्ड की पुष्टि करें',
'Re-enter password': 'पासवर्ड फिर से दर्ज करें',
'Passwords do not match': 'पासवर्ड मेल नहीं खाते',
'Finish Signup': 'साइनअप समाप्त करें',
// New improved signup copy
'Create Your Nostr Account': 'अपना Nostr खाता बनाएं',
'Generate your unique private key. This is your digital identity.':
'अपनी अद्वितीय निजी कुंजी उत्पन्न करें। यह आपकी डिजिटल पहचान है।',
'Critical: Save Your Private Key': 'महत्वपूर्ण: अपनी निजी कुंजी सहेजें',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'आपकी निजी कुंजी आपका खाता है। कोई पासवर्ड पुनर्प्राप्ति नहीं है। यदि आप इसे खो देते हैं, तो आप हमेशा के लिए अपना खाता खो देंगे। कृपया इसे सुरक्षित स्थान पर सहेजें।',
'I have safely backed up my private key':
'मैंने अपनी निजी कुंजी को सुरक्षित रूप से बैकअप कर लिया है',
'Secure Your Account': 'अपने खाते को सुरक्षित करें',
'Add an extra layer of protection with a password':
'पासवर्ड के साथ सुरक्षा की एक अतिरिक्त परत जोड़ें',
'Password Protection (Recommended)': 'पासवर्ड सुरक्षा (अनुशंसित)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'इस ब्राउज़र में अपनी निजी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड जोड़ें। यह वैकल्पिक है लेकिन बेहतर सुरक्षा के लिए दृढ़ता से अनुशंसित है।',
'Create a password (or skip)': 'एक पासवर्ड बनाएं (या छोड़ें)',
'Enter your password again': 'अपना पासवर्ड फिर से दर्ज करें',
'Complete Signup': 'साइनअप पूर्ण करें',
Recommended: 'अनुशंसित'
}
}

View File

@@ -588,6 +588,55 @@ export default {
'Relay Feeds': 'Relay Feedek',
'Create Highlight': 'Kiemelés Létrehozása',
'Write your thoughts about this highlight...': 'Írd le a gondolataidat erről a kiemelésről...',
'Publish Highlight': 'Kiemelés Közzététele'
'Publish Highlight': 'Kiemelés Közzététele',
'Show replies': 'Válaszok megjelenítése',
'Hide replies': 'Válaszok elrejtése',
'Welcome to Jumble!': 'Üdvözlünk a Jumble-ban!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'A hírcsatornád üres, mert még nem követsz senkit. Kezdd el érdekes tartalmak felfedezésével és kövesd azokat a felhasználókat, akik tetszenek!',
'Search Users': 'Felhasználók keresése',
'Create New Account': 'Új fiók létrehozása',
Important: 'Fontos',
'Generate Your Account': 'Fiók létrehozása',
'Your private key IS your account. Keep it safe!':
'A privát kulcsod A fiókodat jelenti. Tartsd biztonságban!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'A Nostr-ban a privát kulcsod A fiókodat jelenti. Ha elveszíted a privát kulcsodat, örökre elveszíted a fiókodat.',
'Your Private Key': 'Privát kulcsod',
'Generate new key': 'Új kulcs generálása',
'Download Backup File': 'Biztonsági mentés letöltése',
'Copied to Clipboard': 'Vágólapra másolva',
'Copy to Clipboard': 'Másolás vágólapra',
'I already saved my private key securely.': 'Már biztonságosan elmentettem a privát kulcsomat.',
'Almost Done!': 'Majdnem kész!',
'Set a password to encrypt your key, or skip to finish':
'Állíts be jelszót a kulcsod titkosításához, vagy hagyd ki a befejezéshez',
'Password Protection (Optional)': 'Jelszavas védelem (opcionális)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'A jelszó beállítása titkosítja a privát kulcsodat ebben a böngészőben. Kihagyhatod ezt a lépést, de javasoljuk a beállítását a nagyobb biztonság érdekében.',
'Password (Optional)': 'Jelszó (opcionális)',
'Enter password or leave empty to skip': 'Írj be jelszót, vagy hagyd üresen a kihagyáshoz',
'Confirm Password': 'Jelszó megerősítése',
'Re-enter password': 'Jelszó újbóli megadása',
'Passwords do not match': 'A jelszavak nem egyeznek',
'Finish Signup': 'Regisztráció befejezése',
// New improved signup copy
'Create Your Nostr Account': 'Hozd létre Nostr fiókodat',
'Generate your unique private key. This is your digital identity.':
'Generáld le egyedi privát kulcsodat. Ez a digitális identitásod.',
'Critical: Save Your Private Key': 'Kritikus: Mentsd el a privát kulcsodat',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'A privát kulcsod A fiókod. Nincs jelszó-visszaállítás. Ha elveszíted, örökre elveszíted a fiókodat. Kérjük, mentsd el biztonságos helyre.',
'I have safely backed up my private key': 'Biztonságosan elmentettem a privát kulcsomat',
'Secure Your Account': 'Védd meg a fiókodat',
'Add an extra layer of protection with a password':
'Adj hozzá egy extra védelmi réteget jelszóval',
'Password Protection (Recommended)': 'Jelszavas védelem (ajánlott)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Adj hozzá jelszót a privát kulcsod titkosításához ebben a böngészőben. Ez opcionális, de erősen ajánlott a jobb biztonság érdekében.',
'Create a password (or skip)': 'Hozz létre jelszót (vagy hagyd ki)',
'Enter your password again': 'Add meg újra a jelszavad',
'Complete Signup': 'Regisztráció befejezése',
Recommended: 'Ajánlott'
}
}

View File

@@ -599,6 +599,56 @@ export default {
'Create Highlight': 'Crea Evidenziazione',
'Write your thoughts about this highlight...':
'Scrivi i tuoi pensieri su questa evidenziazione...',
'Publish Highlight': 'Pubblica Evidenziazione'
'Publish Highlight': 'Pubblica Evidenziazione',
'Show replies': 'Mostra risposte',
'Hide replies': 'Nascondi risposte',
'Welcome to Jumble!': 'Benvenuto su Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Il tuo feed è vuoto perché non stai ancora seguendo nessuno. Inizia esplorando contenuti interessanti e seguendo gli utenti che ti piacciono!',
'Search Users': 'Cerca Utenti',
'Create New Account': 'Crea nuovo account',
Important: 'Importante',
'Generate Your Account': 'Genera il tuo account',
'Your private key IS your account. Keep it safe!':
'La tua chiave privata È il tuo account. Tienila al sicuro!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'In Nostr, la tua chiave privata È il tuo account. Se perdi la tua chiave privata, perdi il tuo account per sempre.',
'Your Private Key': 'La tua chiave privata',
'Generate new key': 'Genera nuova chiave',
'Download Backup File': 'Scarica file di backup',
'Copied to Clipboard': 'Copiato negli appunti',
'Copy to Clipboard': 'Copia negli appunti',
'I already saved my private key securely.':
'Ho già salvato la mia chiave privata in modo sicuro.',
'Almost Done!': 'Quasi fatto!',
'Set a password to encrypt your key, or skip to finish':
'Imposta una password per crittografare la tua chiave, o salta per finire',
'Password Protection (Optional)': 'Protezione con password (facoltativo)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Impostare una password crittografa la tua chiave privata in questo browser. Puoi saltare questo passaggio, ma ti consigliamo di impostarne una per maggiore sicurezza.',
'Password (Optional)': 'Password (facoltativo)',
'Enter password or leave empty to skip': 'Inserisci la password o lascia vuoto per saltare',
'Confirm Password': 'Conferma password',
'Re-enter password': 'Reinserisci la password',
'Passwords do not match': 'Le password non corrispondono',
'Finish Signup': 'Completa registrazione',
// New improved signup copy
'Create Your Nostr Account': 'Crea il tuo account Nostr',
'Generate your unique private key. This is your digital identity.':
'Genera la tua chiave privata unica. Questa è la tua identità digitale.',
'Critical: Save Your Private Key': 'Critico: Salva la tua chiave privata',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
"La tua chiave privata È il tuo account. Non c'è recupero password. Se la perdi, perderai il tuo account per sempre. Per favore salvala in un luogo sicuro.",
'I have safely backed up my private key': 'Ho salvato in modo sicuro la mia chiave privata',
'Secure Your Account': 'Proteggi il tuo account',
'Add an extra layer of protection with a password':
'Aggiungi un ulteriore livello di protezione con una password',
'Password Protection (Recommended)': 'Protezione con password (consigliato)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Aggiungi una password per crittografare la tua chiave privata in questo browser. È facoltativo ma fortemente consigliato per una migliore sicurezza.',
'Create a password (or skip)': 'Crea una password (o salta)',
'Enter your password again': 'Inserisci di nuovo la tua password',
'Complete Signup': 'Completa registrazione',
Recommended: 'Consigliato'
}
}

View File

@@ -594,6 +594,55 @@ export default {
'Create Highlight': 'ハイライトを作成',
'Write your thoughts about this highlight...':
'このハイライトについての考えを書いてください...',
'Publish Highlight': 'ハイライトを公開'
'Publish Highlight': 'ハイライトを公開',
'Show replies': '返信を表示',
'Hide replies': '返信を非表示',
'Welcome to Jumble!': 'Jumbleへようこそ',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'まだ誰もフォローしていないため、フィードが空です。興味深いコンテンツを探索して、好きなユーザーをフォローしてみましょう!',
'Search Users': 'ユーザーを検索',
'Create New Account': '新しいアカウントを作成',
Important: '重要',
'Generate Your Account': 'アカウントを生成',
'Your private key IS your account. Keep it safe!':
'秘密鍵があなたのアカウントです。安全に保管してください!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Nostrでは、秘密鍵があなたのアカウントです。秘密鍵を紛失すると、アカウントを永久に失います。',
'Your Private Key': 'あなたの秘密鍵',
'Generate new key': '新しい鍵を生成',
'Download Backup File': 'バックアップファイルをダウンロード',
'Copied to Clipboard': 'クリップボードにコピーしました',
'Copy to Clipboard': 'クリップボードにコピー',
'I already saved my private key securely.': '秘密鍵を安全に保存しました。',
'Almost Done!': 'もう少しで完了です!',
'Set a password to encrypt your key, or skip to finish':
'鍵を暗号化するためのパスワードを設定するか、スキップして完了してください',
'Password Protection (Optional)': 'パスワード保護(オプション)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'パスワードを設定すると、このブラウザで秘密鍵が暗号化されます。この手順はスキップできますが、セキュリティ強化のために設定することをお勧めします。',
'Password (Optional)': 'パスワード(オプション)',
'Enter password or leave empty to skip':
'パスワードを入力するか、空のままにしてスキップしてください',
'Confirm Password': 'パスワードを確認',
'Re-enter password': 'パスワードを再入力',
'Passwords do not match': 'パスワードが一致しません',
'Finish Signup': '登録を完了',
// New improved signup copy
'Create Your Nostr Account': 'Nostrアカウントを作成',
'Generate your unique private key. This is your digital identity.':
'あなた専用の秘密鍵を生成します。これがあなたのデジタルアイデンティティです。',
'Critical: Save Your Private Key': '重要:秘密鍵を保存してください',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'あなたの秘密鍵があなたのアカウントそのものです。パスワード復旧機能はありません。紛失すると、アカウントを永久に失います。安全な場所に保存してください。',
'I have safely backed up my private key': '秘密鍵を安全にバックアップしました',
'Secure Your Account': 'アカウントを保護',
'Add an extra layer of protection with a password': 'パスワードで追加の保護層を追加',
'Password Protection (Recommended)': 'パスワード保護(推奨)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'このブラウザで秘密鍵を暗号化するパスワードを追加します。オプションですが、より良いセキュリティのために強くお勧めします。',
'Create a password (or skip)': 'パスワードを作成(またはスキップ)',
'Enter your password again': 'パスワードをもう一度入力',
'Complete Signup': '登録を完了',
Recommended: 'おすすめ'
}
}

View File

@@ -592,6 +592,54 @@ export default {
'Relay Feeds': '릴레이 피드',
'Create Highlight': '하이라이트 만들기',
'Write your thoughts about this highlight...': '이 하이라이트에 대한 생각을 작성하세요...',
'Publish Highlight': '하이라이트 게시'
'Publish Highlight': '하이라이트 게시',
'Show replies': '답글 표시',
'Hide replies': '답글 숨기기',
'Welcome to Jumble!': 'Jumble에 오신 것을 환영합니다!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'아직 아무도 팔로우하지 않아서 피드가 비어 있습니다. 흥미로운 콘텐츠를 탐색하고 마음에 드는 사용자를 팔로우해보세요!',
'Search Users': '사용자 검색',
'Create New Account': '새 계정 만들기',
Important: '중요',
'Generate Your Account': '계정 생성',
'Your private key IS your account. Keep it safe!':
'개인 키가 바로 당신의 계정입니다. 안전하게 보관하세요!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Nostr에서는 개인 키가 바로 당신의 계정입니다. 개인 키를 잃으면 계정을 영구적으로 잃게 됩니다.',
'Your Private Key': '개인 키',
'Generate new key': '새 키 생성',
'Download Backup File': '백업 파일 다운로드',
'Copied to Clipboard': '클립보드에 복사됨',
'Copy to Clipboard': '클립보드에 복사',
'I already saved my private key securely.': '이미 개인 키를 안전하게 저장했습니다.',
'Almost Done!': '거의 완료되었습니다!',
'Set a password to encrypt your key, or skip to finish':
'키를 암호화할 비밀번호를 설정하거나 건너뛰어 완료하세요',
'Password Protection (Optional)': '비밀번호 보호 (선택사항)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'비밀번호를 설정하면 이 브라우저에서 개인 키가 암호화됩니다. 이 단계를 건너뛸 수 있지만 보안 강화를 위해 설정하는 것을 권장합니다.',
'Password (Optional)': '비밀번호 (선택사항)',
'Enter password or leave empty to skip': '비밀번호를 입력하거나 비워두어 건너뛰세요',
'Confirm Password': '비밀번호 확인',
'Re-enter password': '비밀번호 재입력',
'Passwords do not match': '비밀번호가 일치하지 않습니다',
'Finish Signup': '가입 완료',
// New improved signup copy
'Create Your Nostr Account': 'Nostr 계정 만들기',
'Generate your unique private key. This is your digital identity.':
'고유한 개인 키를 생성합니다. 이것이 당신의 디지털 신원입니다.',
'Critical: Save Your Private Key': '중요: 개인 키를 저장하세요',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'개인 키가 곧 계정 그 자체입니다. 비밀번호 복구 기능이 없습니다. 분실하면 계정을 영구적으로 잃게 됩니다. 안전한 곳에 저장하세요.',
'I have safely backed up my private key': '개인 키를 안전하게 백업했습니다',
'Secure Your Account': '계정 보호하기',
'Add an extra layer of protection with a password': '비밀번호로 추가 보호 계층 추가',
'Password Protection (Recommended)': '비밀번호 보호 (권장)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'이 브라우저에서 개인 키를 암호화할 비밀번호를 추가합니다. 선택사항이지만 더 나은 보안을 위해 강력히 권장합니다.',
'Create a password (or skip)': '비밀번호 생성(또는 건너뛰기)',
'Enter your password again': '비밀번호를 다시 입력하세요',
'Complete Signup': '가입 완료',
Recommended: '추천'
}
}

View File

@@ -243,7 +243,7 @@ export default {
'Hide content from untrusted users': 'Ukryj treści od nieznanych użytkowników',
'Only show content from your followed users and the users they follow':
'Pokaż tylko treści od użytkowników, których obserwujesz i ich obserwowanych',
'Followed by': 'Obserwowany przez',
'Followed by': 'Obserwujący',
'Mute user privately': 'Zablokuj użytkownika prywatnie',
'Mute user publicly': 'Zablokuj użytkownika publicznie',
Quotes: 'Cytaty',
@@ -554,7 +554,7 @@ export default {
'Search for notes': 'Szukaj wpisów',
'Search for hashtag': 'Szukaj hashtaga',
'Go to note': 'Przejdź do wpisu',
'Go to relay': 'Przejdź do przekaźnika',
'Go to relay': 'Przejdź do transmitera',
'View discussions about this': 'Zobacz dyskusje o tej treści',
'Open link': 'Otwórz link',
'View Nostr discussions': 'Zobacz dyskusje Nostr',
@@ -593,13 +593,63 @@ export default {
'Show but hide content': 'Pokaż, ale ukryj treść',
'Show directly': 'Pokaż bezpośrednio',
'Click to view': 'Wyświetl',
'Special Follow': 'Specjalne Śledzenie',
'Unfollow Special': 'Cofnij Specjalne Śledzenie',
'Personal Feeds': 'Osobiste Kanały',
'Relay Feeds': 'Kanały Przekaźników',
'Create Highlight': 'Utwórz Podświetlenie',
'Special Follow': 'Specjalna obserwacja',
'Unfollow Special': 'Cofnij obserwację specjalną',
'Personal Feeds': 'Kanały osobiste',
'Relay Feeds': 'Kanały Transmiterów',
'Create Highlight': 'Utwórz wyróżnienie',
'Write your thoughts about this highlight...':
'Napisz swoje przemyślenia na temat tego podświetlenia...',
'Publish Highlight': 'Opublikuj Podświetlenie'
'Napisz swoje przemyślenia na temat tego wyróżnienienia...',
'Publish Highlight': 'Opublikuj wyróżnienie',
'Show replies': 'Pokaż odpowiedzi',
'Hide replies': 'Ukryj odpowiedzi',
'Welcome to Jumble!': 'Witamy w Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Twój kanał jest pusty, ponieważ jeszcze nikogo nie obserwujesz. Zacznij od odkrywania ciekawych treści i obserwowania użytkowników, którzy Ci się podobają!',
'Search Users': 'Szukaj użytkowników',
'Create New Account': 'Utwórz nowe konto',
Important: 'Ważne',
'Generate Your Account': 'Wygeneruj swoje konto',
'Your private key IS your account. Keep it safe!':
'Twój klucz prywatny TO twoje konto. Przechowuj go bezpiecznie!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'W Nostr twój klucz prywatny TO twoje konto. Jeśli stracisz swój klucz prywatny, stracisz swoje konto na zawsze.',
'Your Private Key': 'Twój klucz prywatny',
'Generate new key': 'Wygeneruj nowy klucz',
'Download Backup File': 'Pobierz plik kopii zapasowej',
'Copied to Clipboard': 'Skopiowano do schowka',
'Copy to Clipboard': 'Kopiuj do schowka',
'I already saved my private key securely.': 'Już bezpiecznie zapisałem mój klucz prywatny.',
'Almost Done!': 'Prawie gotowe!',
'Set a password to encrypt your key, or skip to finish':
'Ustaw hasło, aby zaszyfrować swój klucz, lub pomiń, aby zakończyć',
'Password Protection (Optional)': 'Ochrona hasłem (opcjonalnie)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Ustawienie hasła szyfruje twój klucz prywatny w tej przeglądarce. Możesz pominąć ten krok, ale zalecamy ustawienie hasła dla dodatkowego bezpieczeństwa.',
'Password (Optional)': 'Hasło (opcjonalnie)',
'Enter password or leave empty to skip': 'Wprowadź hasło lub pozostaw puste, aby pominąć',
'Confirm Password': 'Potwierdź hasło',
'Re-enter password': 'Wprowadź hasło ponownie',
'Passwords do not match': 'Hasła nie pasują do siebie',
'Finish Signup': 'Zakończ rejestrację',
// New improved signup copy
'Create Your Nostr Account': 'Utwórz swoje konto Nostr',
'Generate your unique private key. This is your digital identity.':
'Wygeneruj swój unikalny klucz prywatny. To jest twoja cyfrowa tożsamość.',
'Critical: Save Your Private Key': 'Krytyczne: Zapisz swój klucz prywatny',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Twój klucz prywatny TO JEST twoje konto. Nie ma odzyskiwania hasła. Jeśli go stracisz, na zawsze stracisz swoje konto. Proszę zapisać go w bezpiecznym miejscu.',
'I have safely backed up my private key':
'Bezpiecznie wykonałem kopię zapasową mojego klucza prywatnego',
'Secure Your Account': 'Zabezpiecz swoje konto',
'Add an extra layer of protection with a password':
'Dodaj dodatkową warstwę ochrony za pomocą hasła',
'Password Protection (Recommended)': 'Ochrona hasłem (zalecane)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Dodaj hasło, aby zaszyfrować swój klucz prywatny w tej przeglądarce. Jest to opcjonalne, ale zdecydowanie zalecane dla lepszego bezpieczeństwa.',
'Create a password (or skip)': 'Utwórz hasło (lub pomiń)',
'Enter your password again': 'Wprowadź hasło ponownie',
'Complete Signup': 'Zakończ rejestrację',
Recommended: 'Polecane'
}
}

View File

@@ -161,7 +161,8 @@ export default {
'calculating...': 'Calculando...',
'Calculate optimal read relays': 'Calcular relays de leitura ideais',
'Login to set': 'Entrar no conjunto',
'Please login to view following feed': 'Por favor, faça login para ver o feed de seguidores',
'Please login to view following feed':
'Por favor, faça login para ver o conteúdo das pessoas que você segue',
'Send only to r': 'Enviar apenas para {{r}}',
'Send only to these relays': 'Enviar apenas para estes relays',
Explore: 'Explorar',
@@ -496,7 +497,7 @@ export default {
'Private Key': 'Chave Privada',
'Welcome to Jumble': 'Bem-vindo ao Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble é um cliente focado em navegar relays. Comece explorando relays interessantes ou faça login para ver seu feed de seguidos.',
'Jumble é um cliente focado em navegar relays. Comece explorando relays interessantes ou faça login para ver o conteúdo das pessoas que você segue.',
'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros',
@@ -595,6 +596,55 @@ export default {
'Create Highlight': 'Criar Destaque',
'Write your thoughts about this highlight...':
'Escreva seus pensamentos sobre este destaque...',
'Publish Highlight': 'Publicar Destaque'
'Publish Highlight': 'Publicar Destaque',
'Show replies': 'Mostrar respostas',
'Hide replies': 'Ocultar respostas',
'Welcome to Jumble!': 'Bem-vindo ao Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Seu feed está vazio porque você ainda não está seguindo ninguém. Comece explorando conteúdo interessante e seguindo usuários que você gosta!',
'Search Users': 'Buscar Usuários',
'Create New Account': 'Criar nova conta',
Important: 'Importante',
'Generate Your Account': 'Gerar sua conta',
'Your private key IS your account. Keep it safe!':
'Sua chave privada É sua conta. Mantenha-a segura!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'No Nostr, sua chave privada É sua conta. Se você perder sua chave privada, perderá sua conta para sempre.',
'Your Private Key': 'Sua chave privada',
'Generate new key': 'Gerar nova chave',
'Download Backup File': 'Baixar backup',
'Copied to Clipboard': 'Copiado para a área de transferência',
'Copy to Clipboard': 'Copiar texto',
'I already saved my private key securely.': 'Já salvei minha chave privada com segurança.',
'Almost Done!': 'Quase pronto!',
'Set a password to encrypt your key, or skip to finish':
'Defina uma senha para criptografar sua chave ou pule para finalizar',
'Password Protection (Optional)': 'Proteção por senha (opcional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Definir uma senha criptografa sua chave privada neste navegador. Você pode pular esta etapa, mas recomendamos definir uma para maior segurança.',
'Password (Optional)': 'Senha (opcional)',
'Enter password or leave empty to skip': 'Digite a senha ou deixe em branco para pular',
'Confirm Password': 'Confirmar senha',
'Re-enter password': 'Digite a senha novamente',
'Passwords do not match': 'As senhas não coincidem',
'Finish Signup': 'Concluir cadastro',
// New improved signup copy
'Create Your Nostr Account': 'Criando sua conta Nostr',
'Generate your unique private key. This is your digital identity.':
'Sua chave privada única foi gerada. Ela é sua identidade digital.',
'Critical: Save Your Private Key': 'Importante: Salve a sua chave privada.',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Sua chave privada é a sua conta. Não há recuperação de senha, se você perdê-la, perderá sua conta para sempre. Por favor, salve-a em um local seguro.',
'I have safely backed up my private key': 'Fiz backup seguro da minha chave privada',
'Secure Your Account': 'Proteja sua conta',
'Add an extra layer of protection with a password':
'Adicione uma camada extra de proteção com uma senha',
'Password Protection (Recommended)': 'Proteção por senha (recomendado)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Adicione uma senha para criptografar sua chave privada neste navegador. Isso é opcional, mas fortemente recomendado para melhor segurança.',
'Create a password (or skip)': 'Crie uma senha (ou pule)',
'Enter your password again': 'Digite sua senha novamente',
'Complete Signup': 'Concluir cadastro',
Recommended: 'Recomendado'
}
}

View File

@@ -598,6 +598,56 @@ export default {
'Create Highlight': 'Criar Destaque',
'Write your thoughts about this highlight...':
'Escreva os seus pensamentos sobre este destaque...',
'Publish Highlight': 'Publicar Destaque'
'Publish Highlight': 'Publicar Destaque',
'Show replies': 'Mostrar respostas',
'Hide replies': 'Ocultar respostas',
'Welcome to Jumble!': 'Bem-vindo ao Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'O seu feed está vazio porque ainda não está a seguir ninguém. Comece por explorar conteúdo interessante e siga utilizadores de que gosta!',
'Search Users': 'Procurar Utilizadores',
'Create New Account': 'Criar nova conta',
Important: 'Importante',
'Generate Your Account': 'Gerar a sua conta',
'Your private key IS your account. Keep it safe!':
'A sua chave privada É a sua conta. Mantenha-a segura!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'No Nostr, a sua chave privada É a sua conta. Se perder a sua chave privada, perde a sua conta para sempre.',
'Your Private Key': 'A sua chave privada',
'Generate new key': 'Gerar nova chave',
'Download Backup File': 'Transferir ficheiro de cópia de segurança',
'Copied to Clipboard': 'Copiado para a área de transferência',
'Copy to Clipboard': 'Copiar para a área de transferência',
'I already saved my private key securely.': 'Já guardei a minha chave privada de forma segura.',
'Almost Done!': 'Quase pronto!',
'Set a password to encrypt your key, or skip to finish':
'Defina uma palavra-passe para encriptar a sua chave ou ignore para finalizar',
'Password Protection (Optional)': 'Proteção por palavra-passe (opcional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Definir uma palavra-passe encripta a sua chave privada neste navegador. Pode ignorar este passo, mas recomendamos que defina uma para maior segurança.',
'Password (Optional)': 'Palavra-passe (opcional)',
'Enter password or leave empty to skip':
'Introduza a palavra-passe ou deixe vazio para ignorar',
'Confirm Password': 'Confirmar palavra-passe',
'Re-enter password': 'Introduza novamente a palavra-passe',
'Passwords do not match': 'As palavras-passe não coincidem',
'Finish Signup': 'Concluir registo',
// New improved signup copy
'Create Your Nostr Account': 'Crie a sua conta Nostr',
'Generate your unique private key. This is your digital identity.':
'Gere a sua chave privada única. Esta é a sua identidade digital.',
'Critical: Save Your Private Key': 'Crítico: Guarde a sua chave privada',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'A sua chave privada É a sua conta. Não há recuperação de palavra-passe. Se a perder, perderá a sua conta para sempre. Por favor, guarde-a num local seguro.',
'I have safely backed up my private key': 'Fiz uma cópia de segurança da minha chave privada',
'Secure Your Account': 'Proteja a sua conta',
'Add an extra layer of protection with a password':
'Adicione uma camada extra de proteção com uma palavra-passe',
'Password Protection (Recommended)': 'Proteção por palavra-passe (recomendado)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Adicione uma palavra-passe para encriptar a sua chave privada neste navegador. Isto é opcional, mas fortemente recomendado para melhor segurança.',
'Create a password (or skip)': 'Crie uma palavra-passe (ou ignore)',
'Enter your password again': 'Introduza novamente a sua palavra-passe',
'Complete Signup': 'Concluir registo',
Recommended: 'Recomendado'
}
}

View File

@@ -599,6 +599,56 @@ export default {
'Relay Feeds': 'Ленты Релеев',
'Create Highlight': 'Создать Выделение',
'Write your thoughts about this highlight...': 'Напишите свои мысли об этом выделении...',
'Publish Highlight': 'Опубликовать Выделение'
'Publish Highlight': 'Опубликовать Выделение',
'Show replies': 'Показать ответы',
'Hide replies': 'Скрыть ответы',
'Welcome to Jumble!': 'Добро пожаловать в Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Ваша лента пуста, потому что вы еще ни на кого не подписаны. Начните с изучения интересного контента и подписки на понравившихся пользователей!',
'Search Users': 'Поиск пользователей',
'Create New Account': 'Создать новый аккаунт',
Important: 'Важно',
'Generate Your Account': 'Создать аккаунт',
'Your private key IS your account. Keep it safe!':
'Ваш приватный ключ — это ваш аккаунт. Храните его в безопасности!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'В Nostr ваш приватный ключ — это ваш аккаунт. Если вы потеряете приватный ключ, вы навсегда потеряете свой аккаунт.',
'Your Private Key': 'Ваш приватный ключ',
'Generate new key': 'Создать новый ключ',
'Download Backup File': 'Скачать файл резервной копии',
'Copied to Clipboard': 'Скопировано в буфер обмена',
'Copy to Clipboard': 'Копировать в буфер обмена',
'I already saved my private key securely.':
'Я уже сохранил свой приватный ключ в безопасном месте.',
'Almost Done!': 'Почти готово!',
'Set a password to encrypt your key, or skip to finish':
'Установите пароль для шифрования ключа или пропустите, чтобы завершить',
'Password Protection (Optional)': 'Защита паролем (необязательно)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Установка пароля шифрует ваш приватный ключ в этом браузере. Вы можете пропустить этот шаг, но мы рекомендуем установить пароль для дополнительной безопасности.',
'Password (Optional)': 'Пароль (необязательно)',
'Enter password or leave empty to skip': 'Введите пароль или оставьте пустым, чтобы пропустить',
'Confirm Password': 'Подтвердите пароль',
'Re-enter password': 'Введите пароль повторно',
'Passwords do not match': 'Пароли не совпадают',
'Finish Signup': 'Завершить регистрацию',
// New improved signup copy
'Create Your Nostr Account': 'Создайте свой аккаунт Nostr',
'Generate your unique private key. This is your digital identity.':
'Сгенерируйте ваш уникальный приватный ключ. Это ваша цифровая личность.',
'Critical: Save Your Private Key': 'Критично: Сохраните ваш приватный ключ',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Ваш приватный ключ И ЕСТЬ ваш аккаунт. Восстановление пароля невозможно. Если вы его потеряете, вы навсегда потеряете свой аккаунт. Пожалуйста, сохраните его в безопасном месте.',
'I have safely backed up my private key': 'Я безопасно сохранил свой приватный ключ',
'Secure Your Account': 'Защитите ваш аккаунт',
'Add an extra layer of protection with a password':
'Добавьте дополнительный уровень защиты с помощью пароля',
'Password Protection (Recommended)': 'Защита паролем (рекомендуется)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Добавьте пароль для шифрования вашего приватного ключа в этом браузере. Это необязательно, но настоятельно рекомендуется для лучшей безопасности.',
'Create a password (or skip)': 'Создайте пароль (или пропустите)',
'Enter your password again': 'Введите пароль еще раз',
'Complete Signup': 'Завершить регистрацию',
Recommended: 'Рекомендуемые'
}
}

View File

@@ -586,6 +586,54 @@ export default {
'Relay Feeds': 'ฟีดรีเลย์',
'Create Highlight': 'สร้างไฮไลท์',
'Write your thoughts about this highlight...': 'เขียนความคิดของคุณเกี่ยวกับไฮไลท์นี้...',
'Publish Highlight': 'เผยแพร่ไฮไลท์'
'Publish Highlight': 'เผยแพร่ไฮไลท์',
'Show replies': 'แสดงการตอบกลับ',
'Hide replies': 'ซ่อนการตอบกลับ',
'Welcome to Jumble!': 'ยินดีต้อนรับสู่ Jumble!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'ฟีดของคุณว่างเปล่าเพราะคุณยังไม่ได้ติดตามใครเลย เริ่มต้นด้วยการสำรวจเนื้อหาที่น่าสนใจและติดตามผู้ใช้ที่คุณชอบ!',
'Search Users': 'ค้นหาผู้ใช้',
'Create New Account': 'สร้างบัญชีใหม่',
Important: 'สำคัญ',
'Generate Your Account': 'สร้างบัญชีของคุณ',
'Your private key IS your account. Keep it safe!':
'คีย์ส่วนตัวของคุณคือบัญชีของคุณ เก็บไว้ให้ปลอดภัย!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'ใน Nostr คีย์ส่วนตัวของคุณคือบัญชีของคุณ หากคุณสูญเสียคีย์ส่วนตัว คุณจะสูญเสียบัญชีของคุณตลอดไป',
'Your Private Key': 'คีย์ส่วนตัวของคุณ',
'Generate new key': 'สร้างคีย์ใหม่',
'Download Backup File': 'ดาวน์โหลดไฟล์สำรอง',
'Copied to Clipboard': 'คัดลอกไปยังคลิปบอร์ดแล้ว',
'Copy to Clipboard': 'คัดลอกไปยังคลิปบอร์ด',
'I already saved my private key securely.': 'ฉันได้บันทึกคีย์ส่วนตัวของฉันอย่างปลอดภัยแล้ว',
'Almost Done!': 'เกือบเสร็จแล้ว!',
'Set a password to encrypt your key, or skip to finish':
'ตั้งรหัสผ่านเพื่อเข้ารหัสคีย์ของคุณ หรือข้ามเพื่อเสร็จสิ้น',
'Password Protection (Optional)': 'การป้องกันด้วยรหัสผ่าน (ไม่บังคับ)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'การตั้งรหัสผ่านจะเข้ารหัสคีย์ส่วนตัวของคุณในเบราว์เซอร์นี้ คุณสามารถข้ามขั้นตอนนี้ได้ แต่เราแนะนำให้ตั้งรหัสผ่านเพื่อความปลอดภัยเพิ่มเติม',
'Password (Optional)': 'รหัสผ่าน (ไม่บังคับ)',
'Enter password or leave empty to skip': 'ป้อนรหัสผ่านหรือเว้นว่างเพื่อข้าม',
'Confirm Password': 'ยืนยันรหัสผ่าน',
'Re-enter password': 'ป้อนรหัสผ่านอีกครั้ง',
'Passwords do not match': 'รหัสผ่านไม่ตรงกัน',
'Finish Signup': 'เสร็จสิ้นการลงทะเบียน',
// New improved signup copy
'Create Your Nostr Account': 'สร้างบัญชี Nostr ของคุณ',
'Generate your unique private key. This is your digital identity.':
'สร้างคีย์ส่วนตัวที่ไม่ซ้ำของคุณ นี่คือตัวตนดิจิทัลของคุณ',
'Critical: Save Your Private Key': 'สำคัญมาก: บันทึกคีย์ส่วนตัวของคุณ',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'คีย์ส่วนตัวของคุณคือบัญชีของคุณ ไม่มีการกู้คืนรหัสผ่าน หากคุณทำหาย คุณจะสูญเสียบัญชีของคุณตลอดไป โปรดบันทึกไว้ในที่ปลอดภัย',
'I have safely backed up my private key': 'ฉันได้สำรองคีย์ส่วนตัวของฉันอย่างปลอดภัยแล้ว',
'Secure Your Account': 'รักษาความปลอดภัยบัญชีของคุณ',
'Add an extra layer of protection with a password': 'เพิ่มชั้นความปลอดภัยเพิ่มเติมด้วยรหัสผ่าน',
'Password Protection (Recommended)': 'การป้องกันด้วยรหัสผ่าน (แนะนำ)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'เพิ่มรหัสผ่านเพื่อเข้ารหัสคีย์ส่วนตัวของคุณในเบราว์เซอร์นี้ เป็นตัวเลือก แต่แนะนำอย่างยิ่งเพื่อความปลอดภัยที่ดีขึ้น',
'Create a password (or skip)': 'สร้างรหัสผ่าน (หรือข้าม)',
'Enter your password again': 'ป้อนรหัสผ่านของคุณอีกครั้ง',
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน',
Recommended: 'แนะนำ'
}
}

625
src/i18n/locales/zh-TW.ts Normal file
View File

@@ -0,0 +1,625 @@
export default {
translation: {
'Welcome! 🥳': '來都來了',
About: '關於',
'New Note': '發布新筆記',
Post: '發布筆記',
Home: '主頁',
'Relay settings': '伺服器設定',
Settings: '設定',
SidebarRelays: '伺服器',
Refresh: '刷新列表',
Profile: '個人資料',
Logout: '登出',
Following: '關注',
followings: '關注',
reposted: '轉發',
'just now': '剛剛',
'minute ago_one': '{{count}} 分鐘前',
'minute ago_other': '{{count}} 分鐘前',
'n m': '{{n}}分',
'hour ago_one': '{{count}} 小時前',
'hour ago_other': '{{count}} 小時前',
'n h': '{{n}}時',
'day ago_one': '{{count}} 天前',
'day ago_other': '{{count}} 天前',
'n d': '{{n}}天',
date: '{{timestamp, date}}',
Follow: '關注',
Unfollow: '取消關注',
'Follow failed': '關注失敗',
'Unfollow failed': '取消關注失敗',
'show new notes': '顯示新筆記',
'loading...': '載入中...',
'Loading...': '載入中...',
'no more notes': '到底了',
'reply to': '回覆',
reply: '回覆',
Reply: '回覆',
'load more older replies': '載入更多早期回覆',
'Write something...': '寫點什麼...',
Cancel: '取消',
Mentions: '提及',
'Failed to post': '發布失敗',
'Post successful': '發布成功',
'Your post has been published': '您的筆記已發布',
Repost: '轉發',
Quote: '引用',
'Copy event ID': '複製事件 ID',
'Copy user ID': '複製使用者 ID',
'View raw event': '檢視原始事件',
Like: '按讚',
'switch to light theme': '切換到淺色主題',
'switch to dark theme': '切換到深色主題',
'switch to system theme': '切換到系統主題',
Note: '筆記',
note: '筆記',
"username's following": '{{username}} 的關注',
"username's used relays": '{{username}} 使用的伺服器',
"username's muted": '{{username}} 封鎖的使用者',
Login: '登入',
'Follows you': '關注了你',
'Relay Settings': '伺服器設定',
'Relay set name': '伺服器組名',
'Add a new relay set': '新增新的伺服器組',
Add: '新增',
'n relays': '{{n}} 個伺服器',
Rename: '重新命名',
'Copy share link': '複製分享連結',
Delete: '刪除',
'Relay already exists': '伺服器已存在',
'invalid relay URL': '無效的伺服器位址',
'Add a new relay': '新增新的伺服器',
back: '返回',
'Lost in the void': '迷失在虛空中',
'Carry me home': '帶我回家',
'no replies': '暫無回覆',
'Reply to': '回覆',
Search: '搜尋',
'The relays you are connected to do not support search': '您連接的伺服器不支援搜尋',
'Show more...': '檢視更多...',
'All users': '所有使用者',
'Display replies': '顯示回覆',
Notes: '筆記',
Replies: '回覆',
Notifications: '通知',
'no more notifications': '到底了',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':
'使用私鑰登入不安全。建議使用瀏覽器擴充功能登入,如 alby、nostr-keyx 或 nos2x。如果必須使用私鑰請至少設定一個密碼進行加密。',
'Login with Browser Extension': '瀏覽器擴充功能登入',
'Login with Bunker': 'Bunker 登入',
'Login with Private Key': '私鑰登入',
'reload notes': '重新載入筆記',
'Logged in Accounts': '已登入帳戶',
'Add an Account': '新增帳戶',
'More options': '更多選項',
'Add client tag': '新增客戶端標籤',
'Show others this was sent via Jumble': '告訴別人這是透過 Jumble 發送的',
'Are you sure you want to logout?': '確定要登出嗎?',
'relay sets': '伺服器組',
edit: '編輯',
Languages: '語言',
Theme: '主題',
System: '系統',
Light: '淺色',
Dark: '深色',
Temporary: '臨時',
'Choose a relay set': '選擇一個伺服器組',
'Switch account': '切換帳戶',
Pictures: '圖片',
'Picture note': '圖片筆記',
'A special note for picture-first clients like Olas':
'一種可以在圖片優先客戶端(如 Olas中顯示的特殊筆記',
'Picture note requires images': '圖片筆記需要有圖片',
Relays: '伺服器',
Image: '圖片',
Normal: '普通',
'R & W': '讀寫',
Read: '唯讀',
Write: '只寫',
'Pull relay sets': '拉取伺服器組',
'Select the relay sets you want to pull': '選擇要拉取的伺服器組',
'No relay sets found': '未找到伺服器組',
'Pull n relay sets': '拉取 {{n}} 個伺服器組',
Pull: '拉取',
'Select all': '全選',
'Relay Sets': '伺服器組',
Mailbox: '信箱',
'Read & Write Relays': '讀寫伺服器',
'read relays description':
'讀取伺服器用於尋找與您有關的事件。其他使用者會將想要您看到的事件發布到您的讀取伺服器,例如回覆、按讚轉發……',
'write relays description':
'寫入伺服器用於發布您的事件。其他使用者會從您的寫入伺服器尋找您發布的事件。',
'read & write relays notice': '讀取伺服器和寫入伺服器的數量都應盡量保持在 2 到 4 個之間。',
"Don't have an account yet?": '還沒有帳戶?',
'or simply generate a private key': '或者簡單產生一個私鑰',
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.':
'這是私鑰,請勿與他人分享。請妥善保管,否則將無法找回。',
Edit: '編輯',
Save: '儲存',
'Display Name': '暱稱',
Bio: '簡介',
'Nostr Address (NIP-05)': 'Nostr 位址 (NIP-05)',
'Invalid NIP-05 address': '無效的 NIP-05 位址',
'Copy private key': '複製私鑰',
'Enter the password to decrypt your ncryptsec': '輸入密碼以解密您的 ncryptsec',
Back: '返回',
'password (optional): encrypt nsec': '密碼(可選):加密 nsec',
'optional: encrypt nsec': '可選:加密 nsec',
password: '密碼',
'Sign up': '註冊',
'Save to': '儲存到',
'Enter a name for the new relay set': '輸入新伺服器組的名稱',
'Save to a new relay set': '儲存到新伺服器組',
Mute: '封鎖',
Muted: '已封鎖',
Unmute: '取消封鎖',
'Unmute user': '取消封鎖使用者',
'Append n relays': '追加 {{n}} 個伺服器',
Append: '追加',
'Select relays to append': '選擇要追加的伺服器',
'calculating...': '計算中...',
'Calculate optimal read relays': '計算最佳讀取伺服器',
'Login to set': '登入後設定',
'Please login to view following feed': '請登入以檢視關注動態',
'Send only to r': '只發送到 {{r}}',
'Send only to these relays': '只發送到這些伺服器',
Explore: '探索',
'Search relays': '搜尋伺服器',
relayInfoBadgeAuth: '需登入',
relayInfoBadgeSearch: '支援搜尋',
relayInfoBadgePayment: '需付費',
Operator: '管理員',
Contact: '聯絡方式',
Software: '軟體',
Version: '版本',
'Random Relays': '隨機伺服器',
randomRelaysRefresh: '換一批',
'Explore more': '探索更多',
'Payment page': '付款頁面',
'Supported NIPs': '支援的 NIP',
'Open in a': '在 {{a}} 中開啟',
'Cannot handle event of kind k': '無法處理類型為 {{k}} 的事件',
'Sorry! The note cannot be found 😔': '抱歉!找不到該筆記 😔',
'This user has been muted': '該使用者已被封鎖',
Wallet: '錢包',
Sats: '聰',
sats: '聰',
'Zap to': '打閃給',
'Zap n sats': '打閃 {{n}} 聰',
zapComment: '附言',
'Default zap amount': '預設打閃金額',
'Default zap comment': '預設打閃附言',
'Lightning Address (or LNURL)': '閃電位址(或 LNURL',
'Quick zap': '快速打閃',
'If enabled, you can zap with a single click. Click and hold for custom amounts':
'如果啟用,您單擊即可打閃。長按以設定自訂金額',
All: '全部',
Reactions: '互動',
Zaps: '打閃',
'Enjoying Jumble?': '喜歡 Jumble 嗎?',
'Your donation helps me maintain Jumble and make it better! 😊':
'您的捐贈幫助我維護 Jumble 並使其更好!😊',
'Earlier notifications': '更早的通知',
'Temporarily display this note': '臨時顯示此筆記',
buttonFollowing: '已關注',
'Are you sure you want to unfollow this user?': '確定要取消關注此使用者嗎?',
'Recent Supporters': '最近的支持者',
'Seen on': '來自',
'Temporarily display this reply': '臨時顯示此回覆',
'Note not found': '未找到該筆記',
'no more replies': '沒有更多回覆了',
'Relay sets': '伺服器組',
'Favorite Relays': '收藏的伺服器',
"Following's Favorites": '關注人的收藏',
'no more relays': '沒有更多伺服器了',
'Favorited by': '收藏自',
'Post settings': '發布設定',
'Media upload service': '媒體上傳服務',
'Choose a relay': '選擇一個伺服器',
'no relays found': '未找到伺服器',
video: '影片',
'Show n new notes': '顯示 {{n}} 條新筆記',
YouTabName: '與你',
Bookmark: '收藏',
'Remove bookmark': '取消收藏',
'no bookmarks found': '暫無收藏',
'no more bookmarks': '到底了',
Bookmarks: '收藏',
'Show more': '顯示更多',
General: '常規',
Autoplay: '自動播放',
'Enable video autoplay on this device': '在此裝置上啟用影片自動播放',
'Paste or drop media files to upload': '支援貼上或拖放媒體檔案進行上傳',
Preview: '預覽',
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':
'您即將發布一條由 [{{eventAuthorName}}] 簽名的事件。您目前以 [{{currentUsername}}] 登入。確定嗎?',
'Platinum Sponsors': '白金贊助商',
From: '來自',
'Comment on': '評論於',
'View on njump.me': '在 njump.me 上檢視',
'Hide content from untrusted users': '隱藏不受信任使用者的內容',
'Only show content from your followed users and the users they follow':
'僅顯示您關注的使用者及其關注的使用者的內容',
'Followed by': '關注者',
'Mute user privately': '悄悄封鎖',
'Mute user publicly': '公開封鎖',
Quotes: '引用',
'Lightning Invoice': '閃電發票',
'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': '服務位址',
Pay: '支付',
interactions: '互動',
notifications: '通知',
'Show untrusted {type}': '顯示不受信任的{{type}}',
'Hide untrusted {type}': '隱藏不受信任的{{type}}',
'Currently hiding {type} from untrusted users.': '目前隱藏來自不受信任使用者的{{type}}。',
'Currently showing all {type}.': '目前顯示所有{{type}}。',
'Click continue to show all {type}.': '點擊繼續顯示所有{{type}}。',
'Click continue to hide {type} from untrusted users.':
'點擊繼續隱藏來自不受信任使用者的{{type}}。',
'Trusted users include people you follow and people they follow.':
'受信任的使用者包括您關注的人和他們關注的人。',
Continue: '繼續',
'Successfully updated mute list': '成功更新封鎖列表',
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys',
'Translating...': '翻譯中...',
Translate: '翻譯',
'Show original': '顯示原文',
Website: '網站',
'Hide untrusted notes': '隱藏不受信任的筆記',
'Open in another client': '在其他客戶端開啟',
Community: '社群',
Group: '群組',
'Live event': '直播',
Article: '文章',
Unfavorite: '取消收藏',
'Recommended relays': '推薦伺服器',
'Blossom server URLs': 'Blossom 伺服器位址',
'You need to add at least one blossom server in order to upload media files.':
'您需要新增至少一個 Blossom 伺服器才能上傳媒體檔案。',
'Recommended blossom servers': '推薦的 Blossom 伺服器',
'Enter Blossom server URL': '輸入 Blossom 伺服器 URL',
Preferred: '首選',
'Multiple choice (select one or more)': '多選(選擇一個或多個)',
Vote: '投票',
'{{number}} votes': '{{number}} 次投票',
'Total votes': '總票數',
'Poll has ended': '投票已結束',
'Poll ends at {{time}}': '投票結束時間:{{time}}',
'Load results': '載入結果',
'This is a poll note.': '這是一個投票帖子。',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'與普通帖子不同,投票功能暫時沒有得到廣泛的支援,可能無法在其他客戶端中顯示。',
'Option {{number}}': '選項 {{number}}',
'Add Option': '新增選項',
'Allow multiple choices': '允許多選',
'End Date (optional)': '結束日期(可選)',
'Clear end date': '清除結束日期',
'Relay URLs (optional, comma-separated)': '中繼伺服器 URL可選逗號分隔',
'Remove poll': '移除投票',
'Refresh results': '刷新結果',
Poll: '投票',
Media: '媒體',
'Republish to ...': '重新發布到 ...',
'Successfully republish to your write relays': '成功重新發布到您的寫入伺服器',
'Failed to republish to your write relays: {{error}}':
'重新發布到您的寫入伺服器失敗:{{error}}',
'Successfully republish to relay set: {{name}}': '成功重新發布到伺服器組:{{name}}',
'Failed to republish to relay set: {{name}}. Error: {{error}}':
'重新發布到伺服器組:{{name}} 失敗。錯誤:{{error}}',
'Successfully republish to relay: {{url}}': '成功重新發布到伺服器:{{url}}',
'Failed to republish to relay: {{url}}. Error: {{error}}':
'重新發布到伺服器:{{url}} 失敗。錯誤:{{error}}',
'Write relays': '寫入伺服器',
'No more reactions': '沒有更多互動了',
'No reactions yet': '暫無互動',
'No more zaps': '沒有更多打閃了',
'No zaps yet': '暫無打閃',
'No more reposts': '沒有更多轉發了',
'No reposts yet': '暫無轉發',
Reposts: '轉發',
FollowListNotFoundConfirmation:
'未找到關注列表。你想建立一個新的嗎?如果你之前已經關注了使用者,請不要確認,因為此操作會導致你遺失之前的關注列表。',
MuteListNotFoundConfirmation:
'未找到封鎖列表。你想建立一個新的嗎?如果你之前已經封鎖了使用者,請不要確認,因為此操作會導致你遺失之前的封鎖列表。',
'Show NSFW content by default': '預設顯示 NSFW 內容',
'Custom emoji management': '自訂表情符號管理',
'After changing emojis, you may need to refresh the page': '更改表情符號後,您可能需要刷新頁面',
'Too many read relays': '讀取中繼過多',
'Too many write relays': '寫入中繼過多',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
'您有 {{count}} 個讀取中繼。大多數客戶端只使用 2-4 個中繼,設定更多是不必要的。',
'You have {{count}} write relays. Most clients only use 2-4 relays, setting more is unnecessary.':
'您有 {{count}} 個寫入中繼。大多數客戶端只使用 2-4 個中繼,設定更多是不必要的。',
'Optimize Relay Settings': '最佳化中繼設定',
'Your current relay configuration may not be optimal. This could make it difficult for others to find your posts and may result in incomplete notifications.':
'您目前的中繼配置可能不是最佳的。這可能導致其他人難以找到您發布的內容,也可能導致您無法獲取完整的通知列表。',
'Optimize Now': '立即最佳化',
'Maybe Later': '稍後處理',
"Don't remind me again": '不再提醒',
Posts: '帖子',
Articles: '文章',
Highlights: '精選',
Polls: '投票',
'Voice Posts': '語音帖子',
'Photo Posts': '圖片帖子',
'Video Posts': '影片帖子',
'Select All': '全選',
'Clear All': '清空',
'Set as default filter': '設為預設過濾器',
Apply: '套用',
Reset: '重設',
'Share something on this Relay': '在此伺服器上分享點什麼',
'Try deleting this note': '嘗試刪除此筆記',
'Deletion request sent to {{count}} relays': '刪除請求已發送到 {{count}} 個伺服器',
'Suitable Relays': '適合的伺服器',
'People, keywords, or relays': '使用者、關鍵詞或伺服器',
'Hide content mentioning muted users': '隱藏提及已封鎖使用者的內容',
'This note mentions a user you muted': '此筆記提及了您已封鎖的使用者',
Filter: '過濾器',
'mentioned you in a note': '在筆記中提及了您',
'quoted your note': '引用了您的筆記',
'voted in your poll': '在您的投票中投票',
'reacted to your note': '對您的筆記做出了反應',
'reposted your note': '轉發了您的筆記',
'zapped your note': '打閃了您的筆記',
'zapped you': '給您打閃',
'Mark as read': '標記為已讀',
Report: '檢舉',
'Successfully report': '檢舉成功',
'Failed to report': '檢舉失敗',
nudity: '色情內容',
malware: '惡意軟體',
profanity: '褻瀆言論',
illegal: '違法內容',
spam: '垃圾訊息',
other: '其他',
'Notification list style': '通知列表樣式',
'See extra info for each notification': '檢視每條通知的詳細資訊',
'See more notifications at a glance': '一目了然地檢視更多通知',
Detailed: '詳細',
Compact: '緊湊',
'Submit Relay': '提交伺服器',
Homepage: '主頁',
'Proof of Work (difficulty {{minPow}})': '工作量證明(難度 {{minPow}}',
'via {{client}}': '來自 {{client}}',
'Auto-load media': '自動載入媒體檔案',
Always: '始終',
'Wi-Fi only': '僅WiFi',
Never: '從不',
'Click to load image': '點擊載入圖片',
'Click to load media': '點擊載入音視訊',
'Click to load YouTube video': '點擊載入 YouTube 影片',
'{{count}} reviews': '{{count}} 條評價',
'Write a review': '寫評價',
'No reviews yet. Be the first to write one!': '還沒有評價,成為第一個評價的人吧!',
'View more reviews': '檢視更多評價',
'Failed to review': '評價失敗',
'Write a review and pick a star rating': '寫下評價並選擇星級評分',
Submit: '提交',
'Reviews for {{relay}}': '關於 {{relay}} 的評價',
'No relays selected': '未選擇伺服器',
'Post to': '發布到',
'Write relays and {{count}} other relays': '寫入伺服器和其他 {{count}} 個伺服器',
'{{count}} relays': '{{count}} 個伺服器',
'Republishing...': '正在重新發布...',
'Trending Notes': '熱門筆記',
'Connected to': '已連接到',
'Disconnect Wallet': '中斷錢包連接',
'Are you absolutely sure?': '您確定嗎?',
'You will not be able to send zaps to others.': '您將無法向他人發送打閃。',
Disconnect: '中斷連接',
'Start with a Rizful Vault': '從 Rizful 錢包開始',
'or other wallets': '或其他錢包',
'Rizful Vault': 'Rizful 錢包',
'Rizful Vault connected!': 'Rizful 錢包已連接!',
'You can now use your Rizful Vault to zap your favorite notes and creators.':
'您現在可以使用您的 Rizful 錢包為您喜歡的筆記和創作者打閃。',
'Your Lightning Address': '您的閃電位址',
'New to Rizful?': '第一次使用 Rizful',
'Sign up for Rizful': '註冊 Rizful',
'If you already have a Rizful account, you can skip this step.':
'如果您已經有一個 Rizful 帳戶,可以跳過此步驟。',
'Get your one-time code': '獲取一次性代碼',
'Get code': '獲取代碼',
'Connect to your Rizful Vault': '連接到您的 Rizful 錢包',
'Paste your one-time code here': '將您的一次性代碼貼上到此處',
Connect: '連接',
'Set up your wallet to send and receive sats!': '設定你的錢包以發送和接收 sats',
'Set up': '去設定',
Pinned: '已置頂',
Unpin: '取消置頂',
Unpinning: '取消置頂中',
'Pinning...': '置頂中...',
'Pinned!': '已置頂!',
'Failed to pin: {{error}}': '置頂失敗:{{error}}',
'Unpinning...': '取消置頂中...',
'Unpinned!': '已取消置頂!',
'Failed to unpin: {{error}}': '取消置頂失敗:{{error}}',
'Unpin from profile': '從個人資料取消置頂',
'Pin to profile': '置頂到個人資料',
Appearance: '外觀',
'Pure Black': '純黑',
Default: '預設',
Red: '紅色',
Orange: '橙色',
Amber: '琥珀色',
Yellow: '黃色',
Lime: '青檸色',
Green: '綠色',
Emerald: '翡翠色',
Teal: '藍綠色',
Cyan: '青色',
Sky: '天空色',
Blue: '藍色',
Indigo: '靛藍色',
Violet: '紫羅蘭色',
Purple: '紫色',
Fuchsia: '紫紅色',
Pink: '粉色',
Rose: '玫瑰色',
'Primary color': '主色調',
Layout: '版面配置',
'Two-column': '雙欄',
'Single-column': '單欄',
Reviews: '評價',
Extension: '擴充功能',
Remote: '遠端',
'Encrypted Key': '加密私鑰',
'Private Key': '私鑰',
'Welcome to Jumble': '歡迎來到 Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble 是一個專注於瀏覽伺服器的客戶端。從探索有趣的伺服器開始,或者登入檢視你的關注動態。',
'Explore Relays': '探索伺服器',
'Choose a feed': '選擇一個動態',
'and {{x}} others': '和其他 {{x}} 人',
selfZapWarning: 'Jumble 對您給自己打賞所發生的事情概不負責。風險自負。😉⚡',
'Emoji Pack': '表情包',
'Emoji pack added': '表情包已新增',
'Add emoji pack failed': '新增表情包失敗',
'Emoji pack removed': '表情包已移除',
'Remove emoji pack failed': '移除表情包失敗',
Added: '已新增',
'Emoji Packs': '表情包',
'My Packs': '我的表情包',
'Adding...': '新增中...',
'Removing...': '移除中...',
Reload: '重新載入',
'Request to Join Relay': '申請加入中繼器',
'Leave Relay': '離開中繼器',
Leave: '離開',
'Are you sure you want to leave this relay?': '您確定要離開此中繼器嗎?',
'Join request sent successfully': '加入請求已成功發送',
'Failed to send join request': '發送加入請求失敗',
'Leave request sent successfully': '離開請求已成功發送',
'Failed to send leave request': '發送離開請求失敗',
'Enter an invite code if you have one. Otherwise, leave it blank to send a request.':
'如果您有邀請碼,請輸入。否則,留空以發送請求。',
'Invite Code (Optional)': '邀請碼(可選)',
'Enter invite code': '輸入邀請碼',
'Sending...': '發送中...',
'Send Request': '發送請求',
'You can get an invite code from a relay member.': '您可以從中繼器成員獲取邀請碼。',
'Enter the invite code you received from a relay member.': '輸入您從中繼器成員處獲得的邀請碼。',
'Get Invite Code': '獲取邀請碼',
'Share this invite code with others to invite them to join this relay.':
'將此邀請碼分享給他人以邀請他們加入此中繼器。',
'Invite Code': '邀請碼',
Copy: '複製',
'This invite code can be used by others to join the relay.': '此邀請碼可供他人用於加入中繼器。',
'No invite code available from this relay.': '此中繼器沒有可用的邀請碼。',
Close: '關閉',
'Failed to get invite code from relay': '從中繼器獲取邀請碼失敗',
'Failed to get invite code': '獲取邀請碼失敗',
'Invite code copied to clipboard': '邀請碼已複製到剪貼簿',
'Favicon URL': '網站圖示 URL',
'Filter out onion relays': '過濾洋蔥中繼',
'Click to load X post': '點擊載入 X 帖子',
'View Nostr comments': '檢視 Nostr 評論',
'Search for notes': '搜尋筆記',
'Search for hashtag': '搜尋話題標籤',
'Go to note': '跳轉到筆記',
'Go to relay': '跳轉到中繼器',
'View discussions about this': '檢視關於此內容的討論',
'Open link': '開啟連結',
'View Nostr discussions': '檢視 Nostr 討論',
'Optimal relays': '最佳中繼器',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'成功重新發布到最佳中繼器(你的寫入中繼器和被提及使用者的讀取中繼器)',
'Failed to republish to optimal relays: {{error}}': '重新發布到最佳中繼器失敗:{{error}}',
'External Content': '外部內容',
Highlight: '精選',
'Optimal relays and {{count}} other relays': '最佳中繼器和其他 {{count}} 個中繼器',
'Likely spam account (Trust score: {{percentile}}%)':
'疑似垃圾帳號(信任分數:{{percentile}}%',
'Suspicious account (Trust score: {{percentile}}%)': '可疑帳號(信任分數:{{percentile}}%',
'n users': '{{count}} 位使用者',
'View Details': '檢視詳情',
'Follow Pack Not Found': '未找到關注包',
'Follow pack not found': '未找到關注包',
Users: '使用者',
Feed: '動態',
'Follow Pack': '關注包',
'24h Pulse': '24h 動態',
'Load earlier': '載入更早',
'Last 24 hours': '最近 24 小時',
'Last {{count}} days': '最近 {{count}} 天',
notes: '筆記',
'Quick reaction': '快速按讚',
'If enabled, you can react with a single click. Click and hold for more options':
'啟用後,您可以透過單擊進行按讚。長按以獲取更多選項',
'Quick reaction emoji': '快速按讚表情',
'Select emoji': '選擇表情',
'NSFW content display': 'NSFW 內容顯示',
'Hide completely': '完全隱藏',
'Show but hide content': '顯示但隱藏內容',
'Show directly': '直接顯示',
'Click to view': '點擊檢視',
'Special Follow': '特別關注',
'Unfollow Special': '取消特別關注',
'Personal Feeds': '個人訂閱',
'Relay Feeds': '中繼訂閱',
'Welcome to Jumble!': '歡迎來到 Jumble',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'你的動態是空的,因為你還沒有關注任何人。開始探索有趣的內容並關注你喜歡的用戶吧!',
'Search Users': '搜尋用戶',
'Create New Account': '建立新帳戶',
Important: '重要',
'Generate Your Account': '生成你的帳戶',
'Your private key IS your account. Keep it safe!': '你的私鑰就是你的帳戶。請妥善保管!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'在 Nostr 中,你的私鑰就是你的帳戶。如果你遺失了私鑰,你將永遠失去你的帳戶。',
'Your Private Key': '你的私鑰',
'Generate new key': '生成新金鑰',
'Download Backup File': '下載備份檔案',
'Copied to Clipboard': '已複製到剪貼簿',
'Copy to Clipboard': '複製到剪貼簿',
'I already saved my private key securely.': '我已經安全地儲存了我的私鑰。',
'Almost Done!': '即將完成!',
'Set a password to encrypt your key, or skip to finish': '設定密碼來加密你的金鑰,或跳過以完成',
'Password Protection (Optional)': '密碼保護(可選)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'設定密碼會在此瀏覽器中加密你的私鑰。你可以跳過此步驟,但我們建議設定密碼以增強安全性。',
'Password (Optional)': '密碼(可選)',
'Enter password or leave empty to skip': '輸入密碼或留空以跳過',
'Confirm Password': '確認密碼',
'Re-enter password': '重新輸入密碼',
'Passwords do not match': '密碼不符合',
'Finish Signup': '完成註冊',
// New improved signup copy
'Create Your Nostr Account': '建立你的 Nostr 帳戶',
'Generate your unique private key. This is your digital identity.':
'生成你的專屬私鑰。這是你的數位身份。',
'Critical: Save Your Private Key': '重要:儲存你的私鑰',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'你的私鑰就是你的帳戶。沒有密碼找回功能。如果遺失,你將永遠失去你的帳戶。請將其儲存在安全的地方。',
'I have safely backed up my private key': '我已安全備份我的私鑰',
'Secure Your Account': '保護你的帳戶',
'Add an extra layer of protection with a password': '使用密碼新增額外的保護層',
'Password Protection (Recommended)': '密碼保護(推薦)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'新增密碼以在此瀏覽器中加密你的私鑰。這是可選的,但強烈建議設定以獲得更好的安全性。',
'Create a password (or skip)': '建立密碼(或跳過)',
'Enter your password again': '再次輸入你的密碼',
'Complete Signup': '完成註冊',
Recommended: '推薦'
}
}

View File

@@ -579,6 +579,52 @@ export default {
'Relay Feeds': '中继订阅',
'Create Highlight': '创建高亮',
'Write your thoughts about this highlight...': '写下你对这段高亮的想法...',
'Publish Highlight': '发布高亮'
'Publish Highlight': '发布高亮',
'Show replies': '显示回复',
'Hide replies': '隐藏回复',
'Welcome to Jumble!': '欢迎来到 Jumble',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'你的动态是空的,因为你还没有关注任何人。开始探索有趣的内容并关注你喜欢的用户吧!',
'Search Users': '搜索用户',
'Create New Account': '创建新账户',
Important: '重要',
'Generate Your Account': '生成你的账户',
'Your private key IS your account. Keep it safe!': '你的私钥就是你的账户。请妥善保管!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'在 Nostr 中,你的私钥就是你的账户。如果你丢失了私钥,你将永远失去你的账户。',
'Your Private Key': '你的私钥',
'Generate new key': '生成新密钥',
'Download Backup File': '下载备份文件',
'Copied to Clipboard': '已复制到剪贴板',
'Copy to Clipboard': '复制到剪贴板',
'I already saved my private key securely.': '我已经安全地保存了我的私钥。',
'Almost Done!': '即将完成!',
'Set a password to encrypt your key, or skip to finish': '设置密码来加密你的密钥,或跳过以完成',
'Password Protection (Optional)': '密码保护(可选)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'设置密码会在此浏览器中加密你的私钥。你可以跳过此步骤,但我们建议设置密码以增强安全性。',
'Password (Optional)': '密码(可选)',
'Enter password or leave empty to skip': '输入密码或留空以跳过',
'Confirm Password': '确认密码',
'Re-enter password': '重新输入密码',
'Passwords do not match': '密码不匹配',
'Finish Signup': '完成注册',
// New improved signup copy
'Create Your Nostr Account': '创建你的 Nostr 账户',
'Generate your unique private key. This is your digital identity.':
'生成你的专属私钥。这是你的数字身份。',
'Critical: Save Your Private Key': '重要:保存你的私钥',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'你的私钥就是你的账户。没有密码找回功能。如果丢失,你将永远失去你的账户。请将其保存在安全的地方。',
'I have safely backed up my private key': '我已安全备份我的私钥',
'Secure Your Account': '保护你的账户',
'Add an extra layer of protection with a password': '使用密码添加额外的保护层',
'Password Protection (Recommended)': '密码保护(推荐)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'添加密码以在此浏览器中加密你的私钥。这是可选的,但强烈建议设置以获得更好的安全性。',
'Create a password (or skip)': '创建密码(或跳过)',
'Enter your password again': '再次输入你的密码',
'Complete Signup': '完成注册',
Recommended: '推荐'
}
}

View File

@@ -16,7 +16,7 @@ import { Event, kinds, nip19 } from 'nostr-tools'
import {
getReplaceableCoordinate,
getReplaceableCoordinateFromEvent,
getRootETag,
getRootTag,
isProtectedEvent,
isReplaceableEvent
} from './event'
@@ -153,7 +153,7 @@ export async function createShortTextNoteDraftEvent(
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const { quoteTags, rootETag, parentETag } = await extractRelatedEventIds(
const { quoteTags, rootTag, parentTag } = await extractRelatedEventIds(
transformedEmojisContent,
options.parentEvent
)
@@ -170,13 +170,13 @@ export async function createShortTextNoteDraftEvent(
// q tags
tags.push(...quoteTags)
// e tags
if (rootETag.length) {
tags.push(rootETag)
// thread tags
if (rootTag) {
tags.push(rootTag)
}
if (parentETag.length) {
tags.push(parentETag)
if (parentTag) {
tags.push(parentTag)
}
// p tags
@@ -640,36 +640,41 @@ function generateImetaTags(imageUrls: string[]) {
}
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
let rootETag: string[] = []
let parentETag: string[] = []
let rootTag: string[] | null = null
let parentTag: string[] | null = null
const quoteTags = extractQuoteTags(content)
if (parentEvent) {
const _rootETag = getRootETag(parentEvent)
if (_rootETag) {
parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const _rootTag = getRootTag(parentEvent)
if (_rootTag?.type === 'e') {
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag
const [, rootEventHexId, hint, , rootEventPubkey] = _rootTag.tag
if (rootEventPubkey) {
rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
rootTag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
} else {
const rootEventId = generateBech32IdFromETag(_rootETag)
const rootEventId = generateBech32IdFromETag(_rootTag.tag)
const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
rootETag = rootEvent
rootTag = rootEvent
? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
: buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
}
} else if (_rootTag?.type === 'a') {
// Legacy
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const [, coordinate, hint] = _rootTag.tag
rootTag = buildLegacyRootATag(coordinate, hint)
} else {
// reply to root event
rootETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
rootTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
}
}
return {
quoteTags,
rootETag,
parentETag
rootTag,
parentTag
}
}
@@ -823,6 +828,16 @@ function buildETagWithMarker(
return trimTagEnd(['e', eventHexId, hint, marker, pubkey])
}
function buildLegacyRootATag(coordinate: string, hint: string = '') {
if (!hint) {
const evt = client.getReplaeableEventFromCache(coordinate)
if (evt) {
hint = client.getEventHint(evt.id)
}
}
return trimTagEnd(['a', coordinate, hint, 'root'])
}
function buildITag(url: string, upperCase: boolean = false) {
return [upperCase ? 'I' : 'i', url]
}

View File

@@ -83,6 +83,14 @@ export function getParentETag(event?: Event) {
return tag
}
function getLegacyParentATag(event?: Event) {
if (!event || event.kind !== kinds.ShortTextNote) {
return undefined
}
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'reply')
}
export function getParentATag(event?: Event) {
if (
!event ||
@@ -114,8 +122,9 @@ export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: strin
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
const tag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined
const tag = getLegacyParentATag(event) ?? getParentETag(event) ?? getLegacyRootATag(event)
if (!tag) return undefined
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
}
// NIP-22
@@ -164,6 +173,14 @@ export function getRootETag(event?: Event) {
return tag
}
function getLegacyRootATag(event?: Event) {
if (!event || event.kind !== kinds.ShortTextNote) {
return undefined
}
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'root')
}
export function getRootATag(event?: Event) {
if (
!event ||
@@ -195,8 +212,9 @@ export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
const tag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined
const tag = getLegacyRootATag(event) ?? getRootETag(event)
if (!tag) return undefined
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
}
// NIP-22

View File

@@ -16,3 +16,27 @@ export function checkNip43Support(relayInfo: TRelayInfo | undefined) {
export function filterOutBigRelays(relayUrls: string[]) {
return relayUrls.filter((url) => !BIG_RELAY_URLS.includes(url))
}
export function recommendRelaysByLanguage(i18nLanguage: string) {
if (i18nLanguage.startsWith('zh')) {
return [
'wss://relay.nostrzh.org/',
'wss://relay.nostr.moe/',
'wss://lang.relays.land/zh',
'wss://relay.stream/'
]
}
if (i18nLanguage.startsWith('ja')) {
return ['wss://yabu.me/', 'wss://lang.relays.land/ja']
}
if (i18nLanguage.startsWith('es')) {
return ['wss://lang.relays.land/es']
}
if (i18nLanguage.startsWith('it')) {
return ['wss://lang.relays.land/it']
}
if (i18nLanguage.startsWith('pt')) {
return ['wss://lang.relays.land/pt']
}
return []
}

View File

@@ -1,28 +1,79 @@
import NormalFeed from '@/components/NormalFeed'
import { useFeed } from '@/providers/FeedProvider'
import { Button } from '@/components/ui/button'
import { usePrimaryPage } from '@/PageManager'
import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types'
import { useEffect, useState } from 'react'
import { Compass, Search, UserPlus } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function FollowingFeed() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { feedInfo } = useFeed()
const { followingSet } = useFollowList()
const { navigate } = usePrimaryPage()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [hasFollowings, setHasFollowings] = useState<boolean | null>(null)
const [refreshCount, setRefreshCount] = useState(0)
const initializedRef = useRef(false)
useEffect(() => {
if (initializedRef.current) return
async function init() {
if (feedInfo?.feedType !== 'following' || !pubkey) {
if (!pubkey) {
setSubRequests([])
setHasFollowings(null)
return
}
const followings = await client.fetchFollowings(pubkey)
setHasFollowings(followings.length > 0)
setSubRequests(await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey))
if (followings.length) {
initializedRef.current = true
}
}
init()
}, [feedInfo?.feedType, pubkey])
}, [pubkey, followingSet, refreshCount])
return <NormalFeed subRequests={subRequests} isMainFeed />
// Show empty state when user has no followings
if (hasFollowings === false && subRequests.length > 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-6 text-center">
<UserPlus size={64} className="text-muted-foreground mb-4" strokeWidth={1.5} />
<h2 className="text-2xl font-semibold mb-2">{t('Welcome to Jumble!')}</h2>
<p className="text-muted-foreground mb-6 max-w-md">
{t(
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!'
)}
</p>
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-md">
<Button size="lg" onClick={() => navigate('explore')} className="w-full">
<Compass className="size-5" />
{t('Explore')}
</Button>
<Button size="lg" variant="outline" onClick={() => navigate('search')} className="w-full">
<Search className="size-5" />
{t('Search Users')}
</Button>
</div>
</div>
)
}
return (
<NormalFeed
subRequests={subRequests}
onRefresh={() => {
initializedRef.current = false
setRefreshCount((count) => count + 1)
}}
isMainFeed
/>
)
}

View File

@@ -1,5 +1,4 @@
import NormalFeed from '@/components/NormalFeed'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
import client from '@/services/client.service'
@@ -8,7 +7,6 @@ import { useEffect, useRef, useState } from 'react'
export default function PinnedFeed() {
const { pubkey } = useNostr()
const { feedInfo } = useFeed()
const { pinnedPubkeySet } = usePinnedUsers()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const initializedRef = useRef(false)
@@ -17,7 +15,7 @@ export default function PinnedFeed() {
if (initializedRef.current) return
async function init() {
if (feedInfo?.feedType !== 'pinned' || !pubkey || pinnedPubkeySet.size === 0) {
if (!pubkey || pinnedPubkeySet.size === 0) {
setSubRequests([])
return
}
@@ -28,7 +26,7 @@ export default function PinnedFeed() {
}
init()
}, [feedInfo?.feedType, pubkey, pinnedPubkeySet])
}, [pubkey, pinnedPubkeySet])
return <NormalFeed subRequests={subRequests} isMainFeed />
}

View File

@@ -5,7 +5,7 @@ import relayInfoService from '@/services/relay-info.service'
import { useEffect, useState } from 'react'
export default function RelaysFeed() {
const { feedInfo, relayUrls } = useFeed()
const { relayUrls } = useFeed()
const [isReady, setIsReady] = useState(false)
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
@@ -22,10 +22,6 @@ export default function RelaysFeed() {
return null
}
if (!feedInfo || (feedInfo.feedType !== 'relay' && feedInfo.feedType !== 'relays')) {
return null
}
return (
<NormalFeed
subRequests={[{ urls: relayUrls, filter: {} }]}

View File

@@ -199,7 +199,7 @@ function WelcomeGuide() {
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-md">
<Button size="lg" className="w-full" onClick={() => navigate('explore')}>
<Compass className="size-5" />
{t('Explore Relays')}
{t('Explore')}
</Button>
<Button size="lg" className="w-full" variant="outline" onClick={() => checkLogin()}>

View File

@@ -33,7 +33,7 @@ const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<ExternalContentInteractions pageIndex={index} externalContent={id} />
<ExternalContentInteractions externalContent={id} />
</SecondaryPageLayout>
)
})

View File

@@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
<NoteInteractions key={`note-interactions-${event.id}`} event={event} />
</SecondaryPageLayout>
)
})

View File

@@ -1,5 +1,5 @@
import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, NEW_USER_RELAY_LIST } from '@/constants'
import {
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
@@ -614,14 +614,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const setupNewUser = async (signer: ISigner) => {
const relays = NEW_USER_RELAY_LIST.map((item) => item.url)
await Promise.allSettled([
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(relays, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(relays, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(
BIG_RELAY_URLS,
await signer.signEvent(
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
relays.concat(BIG_RELAY_URLS),
await signer.signEvent(createRelayListDraftEvent(NEW_USER_RELAY_LIST))
)
])
}

View File

@@ -1,71 +0,0 @@
import { getEventKey, getKeyFromTag, getParentTag, isReplyNoteEvent } from '@/lib/event'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = {
repliesMap: Map<string, { events: Event[]; eventKeySet: Set<string> }>
addReplies: (replies: Event[]) => void
}
const ReplyContext = createContext<TReplyContext | undefined>(undefined)
export const useReply = () => {
const context = useContext(ReplyContext)
if (!context) {
throw new Error('useReply must be used within a ReplyProvider')
}
return context
}
export function ReplyProvider({ children }: { children: React.ReactNode }) {
const [repliesMap, setRepliesMap] = useState<
Map<string, { events: Event[]; eventKeySet: Set<string> }>
>(new Map())
const addReplies = useCallback((replies: Event[]) => {
const newReplyKeySet = new Set<string>()
const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => {
if (!isReplyNoteEvent(reply)) return
const key = getEventKey(reply)
if (newReplyKeySet.has(key)) return
newReplyKeySet.add(key)
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) {
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
}
}
})
if (newReplyEventMap.size === 0) return
setRepliesMap((prev) => {
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const replies = prev.get(key) || { events: [], eventKeySet: new Set() }
newReplyEvents.forEach((reply) => {
const key = getEventKey(reply)
if (!replies.eventKeySet.has(key)) {
replies.events.push(reply)
replies.eventKeySet.add(key)
}
})
prev.set(key, replies)
}
return new Map(prev)
})
}, [])
return (
<ReplyContext.Provider
value={{
repliesMap,
addReplies
}}
>
{children}
</ReplyContext.Provider>
)
}

View File

@@ -118,7 +118,7 @@ class ClientService extends EventTarget {
if (mentions.length > 0) {
const relayLists = await this.fetchRelayLists(mentions)
relayLists.forEach((relayList) => {
relayList.read.slice(0, 4).forEach((url) => relaySet.add(url))
relayList.read.slice(0, 5).forEach((url) => relaySet.add(url))
})
}
}
@@ -842,6 +842,10 @@ class ClientService extends EventTarget {
}
}
getReplaeableEventFromCache(coordinate: string): NEvent | undefined {
return this.replaceableEventCacheMap.get(coordinate)
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
if (event) {

View File

@@ -3,28 +3,27 @@ import DataLoader from 'dataloader'
class FayanService {
static instance: FayanService
private userPercentileDataLoader = new DataLoader<string, number | null>(async (userIds) => {
return await Promise.all(
userIds.map(async (userId) => {
try {
const res = await fetch(`https://fayan.jumble.social/${userId}`)
if (!res.ok) {
if (res.status === 404) {
return 0
}
return null
}
const data = await res.json()
if (typeof data.percentile === 'number') {
return data.percentile
}
return null
} catch {
return null
private userPercentileDataLoader = new DataLoader<string, number | null>(
async (pubkeys) => {
try {
const res = await fetch(`https://fayan.jumble.social/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pubkeys })
})
if (!res.ok) {
return new Array(pubkeys.length).fill(null)
}
})
)
})
const data = await res.json()
return pubkeys.map((pubkey) => data[pubkey] ?? 0)
} catch {
return new Array(pubkeys.length).fill(null)
}
},
{ maxBatchSize: 50 }
)
constructor() {
if (!FayanService.instance) {

View File

@@ -0,0 +1,379 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
getEventKey,
getKeyFromTag,
getParentTag,
getReplaceableCoordinateFromEvent,
getRootTag,
isProtectedEvent,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { generateBech32IdFromETag } from '@/lib/tag'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Filter, kinds, NostrEvent } from 'nostr-tools'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
class ThreadService {
static instance: ThreadService
private rootInfoCache = new Map<string, Promise<TRootInfo | undefined>>()
private subscriptions = new Map<
string,
{
promise: Promise<{
closer: () => void
timelineKey: string
}>
count: number
until?: number
}
>()
private threadMap = new Map<string, NostrEvent[]>()
private processedReplyKeys = new Set<string>()
private parentKeyMap = new Map<string, string>()
private descendantCache = new Map<string, Map<string, NostrEvent[]>>()
private threadListeners = new Map<string, Set<() => void>>()
private allDescendantThreadsListeners = new Map<string, Set<() => void>>()
private readonly EMPTY_ARRAY: NostrEvent[] = []
private readonly EMPTY_MAP: Map<string, NostrEvent[]> = new Map()
constructor() {
if (!ThreadService.instance) {
ThreadService.instance = this
}
return ThreadService.instance
}
async subscribe(stuff: NostrEvent | string, limit = 100) {
const { event } = this.resolveStuff(stuff)
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) {
subscription.count += 1
return
}
const _subscribe = async () => {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
}
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
// If current event is protected, we can assume its replies are also protected and stored on the same relays
if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
} else if (rootInfo.type === 'A') {
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
}
)
if (rootInfo.relay) {
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
let resolve: () => void
const _promise = new Promise<void>((res) => {
resolve = res
})
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{
onEvents: (events, eosed) => {
if (events.length > 0) {
this.addRepliesToThread(events)
}
if (eosed) {
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) {
subscription.until =
events.length >= limit ? events[events.length - 1].created_at - 1 : undefined
}
resolve()
}
},
onNew: (evt) => {
this.addRepliesToThread([evt])
}
}
)
await _promise
return { closer, timelineKey }
}
const promise = _subscribe()
this.subscriptions.set(rootInfo.id, {
promise,
count: 1,
until: dayjs().unix()
})
await promise
}
async unsubscribe(stuff: NostrEvent | string) {
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return
const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return
setTimeout(() => {
subscription.count -= 1
if (subscription.count <= 0) {
this.subscriptions.delete(rootInfo.id)
subscription.promise.then(({ closer }) => {
closer()
})
}
}, 2000)
}
async loadMore(stuff: NostrEvent | string, limit = 100): Promise<boolean> {
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return false
const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return false
const { timelineKey } = await subscription.promise
if (!timelineKey) return false
if (!subscription.until) return false
const events = await client.loadMoreTimeline(timelineKey, subscription.until, limit)
this.addRepliesToThread(events)
const { event } = this.resolveStuff(stuff)
let newUntil = events.length ? events[events.length - 1].created_at - 1 : undefined
if (newUntil && event && newUntil < event.created_at) {
newUntil = undefined
}
subscription.until = newUntil
return !!newUntil
}
addRepliesToThread(replies: NostrEvent[]) {
const newReplyEventMap = new Map<string, NostrEvent[]>()
replies.forEach((reply) => {
const key = getEventKey(reply)
if (this.processedReplyKeys.has(key)) return
this.processedReplyKeys.add(key)
if (!isReplyNoteEvent(reply)) return
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) {
const thread = newReplyEventMap.get(parentKey) ?? []
thread.push(reply)
newReplyEventMap.set(parentKey, thread)
this.parentKeyMap.set(key, parentKey)
}
}
})
if (newReplyEventMap.size === 0) return
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const thread = this.threadMap.get(key) ?? []
thread.push(...newReplyEvents)
this.threadMap.set(key, thread)
}
this.descendantCache.clear()
for (const key of newReplyEventMap.keys()) {
this.notifyThreadUpdate(key)
this.notifyAllDescendantThreadsUpdate(key)
}
}
getThread(stuffKey: string): NostrEvent[] {
return this.threadMap.get(stuffKey) ?? this.EMPTY_ARRAY
}
getAllDescendantThreads(stuffKey: string): Map<string, NostrEvent[]> {
const cached = this.descendantCache.get(stuffKey)
if (cached) return cached
const build = () => {
const thread = this.threadMap.get(stuffKey)
if (!thread || thread.length === 0) {
return this.EMPTY_MAP
}
const result = new Map<string, NostrEvent[]>()
const keys: string[] = [stuffKey]
while (keys.length > 0) {
const key = keys.pop()!
const thread = this.threadMap.get(key) ?? []
if (thread.length > 0) {
result.set(key, thread)
thread.forEach((reply) => {
const replyKey = getEventKey(reply)
keys.push(replyKey)
})
}
}
return result
}
const allThreads = build()
this.descendantCache.set(stuffKey, allThreads)
return allThreads
}
listenThread(key: string, callback: () => void) {
let set = this.threadListeners.get(key)
if (!set) {
set = new Set()
this.threadListeners.set(key, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.threadListeners.delete(key)
}
}
private notifyThreadUpdate(key: string) {
const set = this.threadListeners.get(key)
if (set) {
set.forEach((cb) => cb())
}
}
listenAllDescendantThreads(key: string, callback: () => void) {
let set = this.allDescendantThreadsListeners.get(key)
if (!set) {
set = new Set()
this.allDescendantThreadsListeners.set(key, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.allDescendantThreadsListeners.delete(key)
}
}
private notifyAllDescendantThreadsUpdate(key: string) {
const notify = (_key: string) => {
const set = this.allDescendantThreadsListeners.get(_key)
if (set) {
set.forEach((cb) => cb())
}
}
notify(key)
let parentKey = this.parentKeyMap.get(key)
while (parentKey) {
notify(parentKey)
parentKey = this.parentKeyMap.get(parentKey)
}
}
private async parseRootInfo(stuff: NostrEvent | string): Promise<TRootInfo | undefined> {
const { event, externalContent } = this.resolveStuff(stuff)
if (!event && !externalContent) return
const cacheKey = event ? getEventKey(event) : externalContent!
const cache = this.rootInfoCache.get(cacheKey)
if (cache) return cache
const _parseRootInfo = async (): Promise<TRootInfo | undefined> => {
let root: TRootInfo = event
? isReplaceableEvent(event.kind)
? {
type: 'A',
id: getReplaceableCoordinateFromEvent(event),
pubkey: event.pubkey,
relay: client.getEventHint(event.id)
}
: { type: 'E', id: event.id, pubkey: event.pubkey }
: { type: 'I', id: externalContent! }
const rootTag = getRootTag(event)
if (rootTag?.type === 'e') {
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
if (rootEventHexId && rootEventPubkey) {
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
} else {
const rootEventId = generateBech32IdFromETag(rootTag.tag)
if (rootEventId) {
const rootEvent = await client.fetchEvent(rootEventId)
if (rootEvent) {
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
}
}
}
} else if (rootTag?.type === 'a') {
const [, coordinate, relay] = rootTag.tag
const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, pubkey, relay }
} else if (rootTag?.type === 'i') {
root = { type: 'I', id: rootTag.tag[1] }
}
return root
}
const promise = _parseRootInfo()
this.rootInfoCache.set(cacheKey, promise)
return promise
}
private resolveStuff(stuff: NostrEvent | string) {
return typeof stuff === 'string'
? { event: undefined, externalContent: stuff, stuffKey: stuff }
: { event: stuff, externalContent: undefined, stuffKey: getEventKey(stuff) }
}
}
const instance = new ThreadService()
export default instance

11
src/types/index.d.ts vendored
View File

@@ -1,5 +1,10 @@
import { Event, Filter, VerifiedEvent } from 'nostr-tools'
import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, NSFW_DISPLAY_POLICY, POLL_TYPE } from '../constants'
import {
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
NSFW_DISPLAY_POLICY,
POLL_TYPE
} from '../constants'
export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: number }
@@ -200,12 +205,10 @@ export type TNotificationStyle =
export type TAwesomeRelayCollection = {
id: string
name: string
description: string
relays: string[]
}
export type TMediaAutoLoadPolicy =
(typeof MEDIA_AUTO_LOAD_POLICY)[keyof typeof MEDIA_AUTO_LOAD_POLICY]
export type TNsfwDisplayPolicy =
(typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]
export type TNsfwDisplayPolicy = (typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]