Compare commits
18 Commits
ca532b07f4
...
87adf1638e
| Author | SHA1 | Date |
|---|---|---|
|
|
87adf1638e | 2 weeks ago |
|
|
69cf5a5031 | 2 weeks ago |
|
|
bdfb4fb96a | 2 weeks ago |
|
|
48bfdec578 | 2 weeks ago |
|
|
db3dc48846 | 2 weeks ago |
|
|
e50b27359b | 2 weeks ago |
|
|
4d5bc73cc4 | 2 weeks ago |
|
|
c394749a8a | 2 weeks ago |
|
|
e3ca23a2b3 | 2 weeks ago |
|
|
ae19f2d237 | 2 weeks ago |
|
|
8100c3d71c | 2 weeks ago |
|
|
ffa90a1614 | 2 weeks ago |
|
|
ae0882240a | 2 weeks ago |
|
|
c431c6eed5 | 2 weeks ago |
|
|
82b5354ed7 | 2 weeks ago |
|
|
8bbb9ccb1b | 2 weeks ago |
|
|
dff5eca0d5 | 2 weeks ago |
|
|
6e3b7cb55e | 2 weeks ago |
137 changed files with 4518 additions and 1804 deletions
@ -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) |
|
||||||
} |
|
||||||
@ -1,83 +0,0 @@ |
|||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' |
|
||||||
import { NostrContext } from '@/providers/nostr-context' |
|
||||||
import { useBookmarksOptional } from '@/providers/bookmarks-context' |
|
||||||
import { BookmarkIcon } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useContext, useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
|
|
||||||
export default function BookmarkButton({ event }: { event: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const nostrContext = useContext(NostrContext) |
|
||||||
const bookmarksContext = useBookmarksOptional() |
|
||||||
const accountPubkey = nostrContext?.pubkey ?? null |
|
||||||
const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null |
|
||||||
const checkLogin = nostrContext?.checkLogin ?? (async () => {}) |
|
||||||
const { addBookmark, removeBookmark } = bookmarksContext ?? { |
|
||||||
addBookmark: async () => {}, |
|
||||||
removeBookmark: async () => false, |
|
||||||
removeBookmarkByBech32: async () => false |
|
||||||
} |
|
||||||
const [updating, setUpdating] = useState(false) |
|
||||||
const isBookmarked = useMemo(() => { |
|
||||||
const isReplaceable = isReplaceableEvent(event.kind) |
|
||||||
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id |
|
||||||
|
|
||||||
return bookmarkListEvent?.tags.some((tag) => |
|
||||||
isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey |
|
||||||
) |
|
||||||
}, [bookmarkListEvent, event]) |
|
||||||
|
|
||||||
if (!bookmarksContext || !accountPubkey) return null |
|
||||||
|
|
||||||
const handleBookmark = async (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
checkLogin(async () => { |
|
||||||
if (isBookmarked) return |
|
||||||
|
|
||||||
setUpdating(true) |
|
||||||
try { |
|
||||||
await addBookmark(event) |
|
||||||
} catch (error) { |
|
||||||
toast.error(t('Bookmark failed') + ': ' + (error as Error).message) |
|
||||||
} finally { |
|
||||||
setUpdating(false) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const handleRemoveBookmark = async (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
checkLogin(async () => { |
|
||||||
if (!isBookmarked) return |
|
||||||
|
|
||||||
setUpdating(true) |
|
||||||
try { |
|
||||||
await removeBookmark(event) |
|
||||||
} catch (error) { |
|
||||||
toast.error(t('Remove bookmark failed') + ': ' + (error as Error).message) |
|
||||||
} finally { |
|
||||||
setUpdating(false) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<button |
|
||||||
className={`flex items-center gap-1 ${ |
|
||||||
isBookmarked ? 'text-rose-400' : 'text-muted-foreground' |
|
||||||
} enabled:hover:text-rose-400 px-1.5 h-full`}
|
|
||||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark} |
|
||||||
disabled={updating} |
|
||||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} |
|
||||||
> |
|
||||||
{updating ? ( |
|
||||||
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> |
|
||||||
) : ( |
|
||||||
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} /> |
|
||||||
)} |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,106 @@ |
|||||||
|
import { useSmartRelayNavigation } from '@/PageManager' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
DropdownMenu, |
||||||
|
DropdownMenuContent, |
||||||
|
DropdownMenuLabel, |
||||||
|
DropdownMenuSeparator, |
||||||
|
DropdownMenuTrigger |
||||||
|
} from '@/components/ui/dropdown-menu' |
||||||
|
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' |
||||||
|
import { toRelay } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import RelayIcon from '../RelayIcon' |
||||||
|
import { |
||||||
|
ACTIVE_RELAYS_MAX_ICONS, |
||||||
|
activeRelayRowMuted, |
||||||
|
activeRelayRowTitle |
||||||
|
} from './active-relays-display' |
||||||
|
|
||||||
|
/** |
||||||
|
* Compact relay status: icon buttons only (no hostname labels). |
||||||
|
*/ |
||||||
|
export function ActiveRelaysIconGrid({ className }: { className?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigateToRelay } = useSmartRelayNavigation() |
||||||
|
const { rows } = useRelayConnectionRows() |
||||||
|
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS) |
||||||
|
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS) |
||||||
|
const overflow = overflowRows.length |
||||||
|
|
||||||
|
if (rows.length === 0) { |
||||||
|
return ( |
||||||
|
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}> |
||||||
|
— |
||||||
|
</p> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}> |
||||||
|
{shown.map(({ url, connected }) => ( |
||||||
|
<Button |
||||||
|
key={url} |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className={cn( |
||||||
|
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80', |
||||||
|
activeRelayRowMuted(connected) && 'opacity-40 grayscale' |
||||||
|
)} |
||||||
|
title={activeRelayRowTitle(url, connected, t)} |
||||||
|
aria-label={activeRelayRowTitle(url, connected, t)} |
||||||
|
onClick={() => navigateToRelay(toRelay(url))} |
||||||
|
> |
||||||
|
<RelayIcon url={url} className="h-6 w-6" iconSize={12} /> |
||||||
|
</Button> |
||||||
|
))} |
||||||
|
{overflow > 0 ? ( |
||||||
|
<DropdownMenu> |
||||||
|
<DropdownMenuTrigger asChild> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground" |
||||||
|
title={t('More relays', { count: overflow })} |
||||||
|
aria-label={t('More relays', { count: overflow })} |
||||||
|
> |
||||||
|
+{overflow} |
||||||
|
</Button> |
||||||
|
</DropdownMenuTrigger> |
||||||
|
<DropdownMenuContent |
||||||
|
align="start" |
||||||
|
side="right" |
||||||
|
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2" |
||||||
|
> |
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1"> |
||||||
|
{t('More relays', { count: overflow })} |
||||||
|
</DropdownMenuLabel> |
||||||
|
<DropdownMenuSeparator /> |
||||||
|
<div className="flex flex-wrap gap-1 max-w-[16rem]"> |
||||||
|
{overflowRows.map(({ url, connected }) => ( |
||||||
|
<Button |
||||||
|
key={url} |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className={cn( |
||||||
|
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80', |
||||||
|
activeRelayRowMuted(connected) && 'opacity-40 grayscale' |
||||||
|
)} |
||||||
|
title={activeRelayRowTitle(url, connected, t)} |
||||||
|
aria-label={activeRelayRowTitle(url, connected, t)} |
||||||
|
onClick={() => navigateToRelay(toRelay(url))} |
||||||
|
> |
||||||
|
<RelayIcon url={url} className="h-6 w-6" iconSize={12} /> |
||||||
|
</Button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,113 +0,0 @@ |
|||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
DropdownMenu, |
|
||||||
DropdownMenuContent, |
|
||||||
DropdownMenuItem, |
|
||||||
DropdownMenuLabel, |
|
||||||
DropdownMenuSeparator, |
|
||||||
DropdownMenuTrigger |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { simplifyUrl } from '@/lib/url' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import RelayIcon from '../RelayIcon' |
|
||||||
|
|
||||||
const MAX_ICONS = 14 |
|
||||||
|
|
||||||
function rowMuted(connected: boolean) { |
|
||||||
return !connected |
|
||||||
} |
|
||||||
|
|
||||||
function rowMenuClass(connected: boolean) { |
|
||||||
return cn(rowMuted(connected) && 'opacity-50 text-muted-foreground') |
|
||||||
} |
|
||||||
|
|
||||||
function rowTitle(url: string, connected: boolean, t: (k: string) => string) { |
|
||||||
const base = simplifyUrl(url) |
|
||||||
if (!connected) return `${base} — ${t('Not connected')}` |
|
||||||
return base |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Desktop sidebar: relay avatars for relays with an open WebSocket in the pool. |
|
||||||
*/ |
|
||||||
export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { rows } = useRelayConnectionRows() |
|
||||||
const shown = rows.slice(0, MAX_ICONS) |
|
||||||
const overflowRows = rows.slice(MAX_ICONS) |
|
||||||
const overflow = overflowRows.length |
|
||||||
|
|
||||||
if (rows.length === 0) { |
|
||||||
return ( |
|
||||||
<div className={cn('px-1 py-1.5 xl:px-0', className)} title={t('Active relays')}> |
|
||||||
<p className="text-center text-[0.6rem] font-medium text-muted-foreground xl:text-left">{t('Active relays')}</p> |
|
||||||
<p className="mt-0.5 text-center text-[0.55rem] text-muted-foreground/80 xl:text-left">—</p> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('px-1 py-2 xl:px-0', className)} title={t('Active relays')}> |
|
||||||
<p className="mb-1.5 text-center text-[0.65rem] font-medium leading-snug text-foreground xl:text-left"> |
|
||||||
{t('Active relays')} |
|
||||||
</p> |
|
||||||
<div className="flex flex-wrap justify-center gap-1 xl:justify-start"> |
|
||||||
{shown.map(({ url, connected }) => ( |
|
||||||
<Button |
|
||||||
key={url} |
|
||||||
type="button" |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className={cn( |
|
||||||
'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80', |
|
||||||
rowMuted(connected) && 'opacity-40 grayscale' |
|
||||||
)} |
|
||||||
title={rowTitle(url, connected, t)} |
|
||||||
aria-label={rowTitle(url, connected, t)} |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<RelayIcon url={url} className="h-5 w-5" iconSize={11} /> |
|
||||||
</Button> |
|
||||||
))} |
|
||||||
{overflow > 0 ? ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className="h-5 min-h-5 min-w-5 shrink-0 rounded-full bg-muted px-1 py-0 text-[0.6rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground" |
|
||||||
title={t('More relays', { count: overflow })} |
|
||||||
aria-label={t('More relays', { count: overflow })} |
|
||||||
> |
|
||||||
+{overflow} |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent align="start" side="right" className="max-h-[min(70vh,24rem)] w-72 overflow-y-auto"> |
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> |
|
||||||
{t('More relays', { count: overflow })} |
|
||||||
</DropdownMenuLabel> |
|
||||||
<DropdownMenuSeparator /> |
|
||||||
{overflowRows.map(({ url, connected }) => ( |
|
||||||
<DropdownMenuItem |
|
||||||
key={url} |
|
||||||
className={cn('min-w-0 gap-2', rowMenuClass(connected))} |
|
||||||
title={rowTitle(url, connected, t)} |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} /> |
|
||||||
<span className="truncate">{simplifyUrl(url)}</span> |
|
||||||
</DropdownMenuItem> |
|
||||||
))} |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
|
||||||
|
export const ACTIVE_RELAYS_MAX_ICONS = 14 |
||||||
|
|
||||||
|
export function activeRelayRowMuted(connected: boolean) { |
||||||
|
return !connected |
||||||
|
} |
||||||
|
|
||||||
|
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) { |
||||||
|
const base = simplifyUrl(url) |
||||||
|
if (!connected) return `${base} — ${t('Not connected')}` |
||||||
|
return base |
||||||
|
} |
||||||
@ -1,31 +0,0 @@ |
|||||||
import storage from '@/services/local-storage.service' |
|
||||||
import { toWallet } from '@/lib/link' |
|
||||||
import { useSecondaryPage } from '@/contexts/secondary-page-context' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useEffect } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
|
|
||||||
export default function CreateWalletGuideToast() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const { profile } = useNostr() |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if ( |
|
||||||
profile && |
|
||||||
!profile.lightningAddress && |
|
||||||
!storage.hasShownCreateWalletGuideToast(profile.pubkey) |
|
||||||
) { |
|
||||||
toast(t('Set up your wallet to send and receive sats!'), { |
|
||||||
action: { |
|
||||||
label: t('Set up'), |
|
||||||
onClick: () => push(toWallet()) |
|
||||||
} |
|
||||||
}) |
|
||||||
storage.markCreateWalletGuideToastAsShown(profile.pubkey) |
|
||||||
} |
|
||||||
}, [profile]) |
|
||||||
|
|
||||||
return null |
|
||||||
} |
|
||||||
@ -0,0 +1,45 @@ |
|||||||
|
import RelayIcon from '@/components/RelayIcon' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { toRelay } from '@/lib/link' |
||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSmartRelayNavigation } from '@/PageManager' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export function FeedRelaysIconRow({ |
||||||
|
urls, |
||||||
|
className |
||||||
|
}: { |
||||||
|
urls: readonly string[] |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigateToRelay } = useSmartRelayNavigation() |
||||||
|
if (urls.length === 0) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn('flex min-w-0 flex-wrap items-center gap-1', className)} |
||||||
|
role="group" |
||||||
|
aria-label={t('Feed relays', { defaultValue: 'Relays in this feed' })} |
||||||
|
> |
||||||
|
{urls.map((url) => { |
||||||
|
const label = simplifyUrl(url) |
||||||
|
return ( |
||||||
|
<Button |
||||||
|
key={url} |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className="h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80" |
||||||
|
title={label} |
||||||
|
aria-label={t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })} |
||||||
|
onClick={() => navigateToRelay(toRelay(url))} |
||||||
|
> |
||||||
|
<RelayIcon url={url} className="h-6 w-6" iconSize={12} /> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
})} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,40 +0,0 @@ |
|||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { Switch } from '@/components/ui/switch' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import storage from '@/services/local-storage.service' |
|
||||||
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' |
|
||||||
import { useEffect, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function MetadataRelaysOnlySetting() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const [enabled, setEnabled] = useState(false) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const on = storage.getRestrictRelaysToMetadataLists() |
|
||||||
setEnabled(on) |
|
||||||
setRestrictConnectionsToMetadataRelaysOnly(on) |
|
||||||
}, []) |
|
||||||
|
|
||||||
const onChange = (checked: boolean) => { |
|
||||||
setEnabled(checked) |
|
||||||
storage.setRestrictRelaysToMetadataLists(checked) |
|
||||||
setRestrictConnectionsToMetadataRelaysOnly(checked) |
|
||||||
client.interruptBackgroundQueries({ closePooledRelayConnections: true }) |
|
||||||
client.closeMetadataPolicyDisallowedRelayConnections() |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="space-y-2 rounded-lg border border-border p-4"> |
|
||||||
<div className="flex items-center space-x-2"> |
|
||||||
<Label htmlFor="metadata-relays-only">{t('Only my relay lists')}</Label> |
|
||||||
<Switch id="metadata-relays-only" checked={enabled} onCheckedChange={onChange} /> |
|
||||||
</div> |
|
||||||
<div className="text-muted-foreground text-xs max-w-xl"> |
|
||||||
{t( |
|
||||||
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.' |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,54 @@ |
|||||||
|
import { useFetchProfile } from '@/hooks' |
||||||
|
import { useVerifiedNip05Affiliations } from '@/hooks/useVerifiedNip05Affiliations' |
||||||
|
import { userIdToPubkey } from '@/lib/pubkey' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function Nip05AffiliationBadges({ |
||||||
|
userId, |
||||||
|
pubkey: pubkeyProp, |
||||||
|
nip05: nip05Prop, |
||||||
|
nip05List: nip05ListProp, |
||||||
|
className |
||||||
|
}: { |
||||||
|
/** Hex or npub — loads kind 0 for NIP-05 when `nip05` / `nip05List` omitted. */ |
||||||
|
userId?: string |
||||||
|
pubkey?: string |
||||||
|
nip05?: string |
||||||
|
nip05List?: string[] |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const pubkey = pubkeyProp ?? (userId ? userIdToPubkey(userId) : '') |
||||||
|
const { profile } = useFetchProfile( |
||||||
|
nip05Prop === undefined && nip05ListProp === undefined && pubkey ? pubkey : undefined |
||||||
|
) |
||||||
|
const nip05 = nip05Prop ?? profile?.nip05 |
||||||
|
const nip05List = nip05ListProp ?? profile?.nip05List |
||||||
|
const affiliations = useVerifiedNip05Affiliations(pubkey, nip05, nip05List) |
||||||
|
|
||||||
|
if (affiliations.length === 0) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<span |
||||||
|
className={cn('inline-flex shrink-0 items-center gap-0.5', className)} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
onPointerDown={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
{affiliations.map((aff) => { |
||||||
|
const label = aff.label ?? aff.domain |
||||||
|
return ( |
||||||
|
<span |
||||||
|
key={aff.domain} |
||||||
|
role="img" |
||||||
|
aria-label={t('Verified NIP-05 affiliation', { domain: label })} |
||||||
|
title={t('Verified NIP-05 affiliation', { domain: label })} |
||||||
|
className="inline-flex size-[1.05em] items-center justify-center text-sm leading-none select-none grayscale contrast-125 opacity-90" |
||||||
|
> |
||||||
|
{aff.emoji} |
||||||
|
</span> |
||||||
|
) |
||||||
|
})} |
||||||
|
</span> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
import Nip05AffiliationBadges from '@/components/Nip05AffiliationBadges' |
||||||
|
import { FormattedTimestamp } from '@/components/FormattedTimestamp' |
||||||
|
import EventPowLabel from '@/components/EventPowLabel' |
||||||
|
import Username from '@/components/Username' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
/** Username, relative time, verified NIP-05 affiliation badges, optional PoW — one header row. */ |
||||||
|
export default function NoteAuthorMetaLine({ |
||||||
|
userId, |
||||||
|
timestamp, |
||||||
|
powEvent, |
||||||
|
usernameClassName, |
||||||
|
skeletonClassName, |
||||||
|
timestampShort = false |
||||||
|
}: { |
||||||
|
userId: string |
||||||
|
timestamp: number |
||||||
|
powEvent?: Event |
||||||
|
usernameClassName?: string |
||||||
|
skeletonClassName?: string |
||||||
|
timestampShort?: boolean |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden"> |
||||||
|
<Username |
||||||
|
userId={userId} |
||||||
|
className={cn('shrink font-semibold truncate', usernameClassName)} |
||||||
|
skeletonClassName={skeletonClassName} |
||||||
|
/> |
||||||
|
<span className="inline-flex min-w-0 shrink-0 items-center gap-x-1.5 text-sm text-muted-foreground"> |
||||||
|
<FormattedTimestamp timestamp={timestamp} className="shrink-0" short={timestampShort} /> |
||||||
|
<Nip05AffiliationBadges userId={userId} /> |
||||||
|
{powEvent ? <EventPowLabel event={powEvent} /> : null} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,189 @@ |
|||||||
|
import { MAX_PUBLISH_RELAYS } from '@/constants' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Slider } from '@/components/ui/slider' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import storage from '@/services/local-storage.service' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { Dispatch, SetStateAction, useEffect } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Mentions from './Mentions' |
||||||
|
import PostRelaySelector from './PostRelaySelector' |
||||||
|
|
||||||
|
export type PostEditorAdvancedPanelProps = { |
||||||
|
show: boolean |
||||||
|
posting: boolean |
||||||
|
addClientTag: boolean |
||||||
|
setAddClientTag: Dispatch<SetStateAction<boolean>> |
||||||
|
isNsfw: boolean |
||||||
|
setIsNsfw: Dispatch<SetStateAction<boolean>> |
||||||
|
minPow: number |
||||||
|
setMinPow: Dispatch<SetStateAction<number>> |
||||||
|
/** Relay picker + cap hints (hidden for modes that do not pick relays). */ |
||||||
|
showRelayPicker?: boolean |
||||||
|
setAdditionalRelayUrls?: Dispatch<SetStateAction<string[]>> |
||||||
|
onRelayPublishCapChange?: (preview: TPrePublishRelayCapPreview) => void |
||||||
|
relayParentEvent?: Event |
||||||
|
relayOpenFrom?: string[] |
||||||
|
relayContent?: string |
||||||
|
relayIsPublicMessage?: boolean |
||||||
|
relayMentions?: string[] |
||||||
|
relayCapBlockInfo?: { |
||||||
|
outboxSlotsInPublish: number |
||||||
|
selectedTotal: number |
||||||
|
selectedContacted: number |
||||||
|
} | null |
||||||
|
discussionThreadRelayError?: string | null |
||||||
|
isDiscussionThread?: boolean |
||||||
|
/** Reply / PM mention recipient picker. */ |
||||||
|
showMentionsPicker?: boolean |
||||||
|
mentionsContent?: string |
||||||
|
mentionsParentEvent?: Event |
||||||
|
mentions?: string[] |
||||||
|
setMentions?: Dispatch<SetStateAction<string[]>> |
||||||
|
} |
||||||
|
|
||||||
|
export default function PostEditorAdvancedPanel({ |
||||||
|
show, |
||||||
|
posting, |
||||||
|
addClientTag, |
||||||
|
setAddClientTag, |
||||||
|
isNsfw, |
||||||
|
setIsNsfw, |
||||||
|
minPow, |
||||||
|
setMinPow, |
||||||
|
showRelayPicker = false, |
||||||
|
setAdditionalRelayUrls, |
||||||
|
onRelayPublishCapChange, |
||||||
|
relayParentEvent, |
||||||
|
relayOpenFrom, |
||||||
|
relayContent = '', |
||||||
|
relayIsPublicMessage = false, |
||||||
|
relayMentions = [], |
||||||
|
relayCapBlockInfo = null, |
||||||
|
discussionThreadRelayError = null, |
||||||
|
isDiscussionThread = false, |
||||||
|
showMentionsPicker = false, |
||||||
|
mentionsContent = '', |
||||||
|
mentionsParentEvent, |
||||||
|
mentions = [], |
||||||
|
setMentions |
||||||
|
}: PostEditorAdvancedPanelProps) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setAddClientTag(storage.getAddClientTag()) |
||||||
|
}, [setAddClientTag]) |
||||||
|
|
||||||
|
if (!show) return null |
||||||
|
|
||||||
|
const onAddClientTagChange = (checked: boolean) => { |
||||||
|
storage.setAddClientTag(checked) |
||||||
|
setAddClientTag(checked) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> |
||||||
|
<div> |
||||||
|
<p className="text-sm font-medium">{t('Advanced')}</p> |
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{showMentionsPicker && setMentions ? ( |
||||||
|
<div className="space-y-2"> |
||||||
|
<Label className="text-sm font-normal">{t('Mentions')}</Label> |
||||||
|
<Mentions |
||||||
|
content={mentionsContent} |
||||||
|
parentEvent={mentionsParentEvent} |
||||||
|
mentions={mentions} |
||||||
|
setMentions={setMentions} |
||||||
|
compactTrigger |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{showRelayPicker && setAdditionalRelayUrls ? ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'space-y-2', |
||||||
|
isDiscussionThread && discussionThreadRelayError && 'rounded-md ring-1 ring-destructive p-2' |
||||||
|
)} |
||||||
|
> |
||||||
|
<Label className="text-sm font-normal">{t('Post to')}</Label> |
||||||
|
<PostRelaySelector |
||||||
|
setAdditionalRelayUrls={setAdditionalRelayUrls} |
||||||
|
onRelayPublishCapChange={onRelayPublishCapChange} |
||||||
|
parentEvent={relayParentEvent} |
||||||
|
openFrom={relayOpenFrom} |
||||||
|
content={relayContent} |
||||||
|
isPublicMessage={relayIsPublicMessage} |
||||||
|
mentions={relayMentions} |
||||||
|
/> |
||||||
|
{relayCapBlockInfo ? ( |
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-500" role="alert"> |
||||||
|
{relayCapBlockInfo.outboxSlotsInPublish > 0 |
||||||
|
? t('Publish relay cap hint with outbox first', { |
||||||
|
max: MAX_PUBLISH_RELAYS, |
||||||
|
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish, |
||||||
|
selected: relayCapBlockInfo.selectedTotal, |
||||||
|
selectedContacted: relayCapBlockInfo.selectedContacted |
||||||
|
}) |
||||||
|
: t('Publish relay cap hint', { |
||||||
|
max: MAX_PUBLISH_RELAYS, |
||||||
|
selected: relayCapBlockInfo.selectedTotal, |
||||||
|
selectedContacted: relayCapBlockInfo.selectedContacted |
||||||
|
})} |
||||||
|
</p> |
||||||
|
) : null} |
||||||
|
{isDiscussionThread && discussionThreadRelayError ? ( |
||||||
|
<p className="text-sm text-destructive">{discussionThreadRelayError}</p> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
<div className="space-y-4 pt-1 border-t border-border"> |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<Label htmlFor="add-client-tag" className="text-sm font-normal"> |
||||||
|
{t('Add client tag')} |
||||||
|
</Label> |
||||||
|
<Switch |
||||||
|
id="add-client-tag" |
||||||
|
checked={addClientTag} |
||||||
|
onCheckedChange={onAddClientTagChange} |
||||||
|
disabled={posting} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<p className="text-muted-foreground text-xs">{t('Show others this was sent via Imwald')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<Label htmlFor="add-nsfw-tag" className="text-sm font-normal"> |
||||||
|
{t('NSFW')} |
||||||
|
</Label> |
||||||
|
<Switch |
||||||
|
id="add-nsfw-tag" |
||||||
|
checked={isNsfw} |
||||||
|
onCheckedChange={setIsNsfw} |
||||||
|
disabled={posting} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label className="text-sm font-normal"> |
||||||
|
{t('Proof of Work (difficulty {{minPow}})', { minPow })} |
||||||
|
</Label> |
||||||
|
<Slider |
||||||
|
defaultValue={[0]} |
||||||
|
value={[minPow]} |
||||||
|
onValueChange={([pow]) => setMinPow(pow)} |
||||||
|
max={28} |
||||||
|
step={1} |
||||||
|
disabled={posting} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,84 +0,0 @@ |
|||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { Slider } from '@/components/ui/slider' |
|
||||||
import { Switch } from '@/components/ui/switch' |
|
||||||
import storage from '@/services/local-storage.service' |
|
||||||
import { Dispatch, SetStateAction, useEffect } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function PostOptions({ |
|
||||||
posting, |
|
||||||
show, |
|
||||||
addClientTag, |
|
||||||
setAddClientTag, |
|
||||||
isNsfw, |
|
||||||
setIsNsfw, |
|
||||||
minPow, |
|
||||||
setMinPow |
|
||||||
}: { |
|
||||||
posting: boolean |
|
||||||
show: boolean |
|
||||||
addClientTag: boolean |
|
||||||
setAddClientTag: Dispatch<SetStateAction<boolean>> |
|
||||||
isNsfw: boolean |
|
||||||
setIsNsfw: Dispatch<SetStateAction<boolean>> |
|
||||||
minPow: number |
|
||||||
setMinPow: Dispatch<SetStateAction<number>> |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setAddClientTag(storage.getAddClientTag()) |
|
||||||
}, []) |
|
||||||
|
|
||||||
if (!show) return null |
|
||||||
|
|
||||||
const onAddClientTagChange = (checked: boolean) => { |
|
||||||
storage.setAddClientTag(checked) |
|
||||||
setAddClientTag(checked) |
|
||||||
} |
|
||||||
|
|
||||||
const onNsfwChange = (checked: boolean) => { |
|
||||||
setIsNsfw(checked) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="space-y-4"> |
|
||||||
<div className="space-y-2"> |
|
||||||
<div className="flex items-center space-x-2"> |
|
||||||
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label> |
|
||||||
<Switch |
|
||||||
id="add-client-tag" |
|
||||||
checked={addClientTag} |
|
||||||
onCheckedChange={onAddClientTagChange} |
|
||||||
disabled={posting} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div className="text-muted-foreground text-xs"> |
|
||||||
{t('Show others this was sent via Imwald')} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div className="flex items-center space-x-2"> |
|
||||||
<Label htmlFor="add-nsfw-tag">{t('NSFW')}</Label> |
|
||||||
<Switch |
|
||||||
id="add-nsfw-tag" |
|
||||||
checked={isNsfw} |
|
||||||
onCheckedChange={onNsfwChange} |
|
||||||
disabled={posting} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div className="grid gap-4 pb-4"> |
|
||||||
<Label>{t('Proof of Work (difficulty {{minPow}})', { minPow })}</Label> |
|
||||||
<Slider |
|
||||||
defaultValue={[0]} |
|
||||||
value={[minPow]} |
|
||||||
onValueChange={([pow]) => setMinPow(pow)} |
|
||||||
max={28} |
|
||||||
step={1} |
|
||||||
disabled={posting} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,49 @@ |
|||||||
|
import { toCacheSettings } from '@/lib/link' |
||||||
|
import { |
||||||
|
consumePostSignupBackupPrompt, |
||||||
|
showNewUserBackupBanner |
||||||
|
} from '@/lib/post-signup-backup-prompt' |
||||||
|
import { useSecondaryPage } from '@/contexts/secondary-page-context' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useEffect, useRef } from 'react' |
||||||
|
|
||||||
|
/** After one-click sign up, open Cache settings so the user can back up their private key. */ |
||||||
|
export default function PostSignupBackupRedirect() { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pubkey) return |
||||||
|
|
||||||
|
const tryRedirect = () => { |
||||||
|
if (!consumePostSignupBackupPrompt(pubkey)) return false |
||||||
|
showNewUserBackupBanner() |
||||||
|
push(toCacheSettings()) |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
if (tryRedirect()) return |
||||||
|
|
||||||
|
// Prompt is scheduled at login; brief poll covers pubkey/login race.
|
||||||
|
let attempts = 0 |
||||||
|
pollRef.current = setInterval(() => { |
||||||
|
attempts += 1 |
||||||
|
if (tryRedirect() || attempts >= 15) { |
||||||
|
if (pollRef.current) { |
||||||
|
clearInterval(pollRef.current) |
||||||
|
pollRef.current = null |
||||||
|
} |
||||||
|
} |
||||||
|
}, 200) |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (pollRef.current) { |
||||||
|
clearInterval(pollRef.current) |
||||||
|
pollRef.current = null |
||||||
|
} |
||||||
|
} |
||||||
|
}, [pubkey, push]) |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
@ -0,0 +1,226 @@ |
|||||||
|
import { |
||||||
|
AlertDialog, |
||||||
|
AlertDialogAction, |
||||||
|
AlertDialogCancel, |
||||||
|
AlertDialogContent, |
||||||
|
AlertDialogDescription, |
||||||
|
AlertDialogFooter, |
||||||
|
AlertDialogHeader, |
||||||
|
AlertDialogTitle |
||||||
|
} from '@/components/ui/alert-dialog' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { |
||||||
|
dismissNewUserBackupBanner, |
||||||
|
isNewUserBackupBannerVisible |
||||||
|
} from '@/lib/post-signup-backup-prompt' |
||||||
|
import { requestNewUserTemplateBroadcast } from '@/lib/new-user-template-broadcast' |
||||||
|
import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' |
||||||
|
import { pubkeyToNpub } from '@/lib/pubkey' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import storage from '@/services/local-storage.service' |
||||||
|
import { Check, Copy, Eye, EyeOff, KeyRound, Trash2, X } from 'lucide-react' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import * as nip19 from 'nostr-tools/nip19' |
||||||
|
import * as nip49 from 'nostr-tools/nip49' |
||||||
|
import { toast } from 'sonner' |
||||||
|
|
||||||
|
export default function PrivateKeyRecoverySetting() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, account, nsec, ncryptsec, discardLocalPrivateKey } = useNostr() |
||||||
|
const [showKey, setShowKey] = useState(false) |
||||||
|
const [revealedNsec, setRevealedNsec] = useState<string | null>(null) |
||||||
|
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false) |
||||||
|
const [copiedNpub, setCopiedNpub] = useState(false) |
||||||
|
const [copiedKey, setCopiedKey] = useState(false) |
||||||
|
const [showBackupBanner, setShowBackupBanner] = useState(false) |
||||||
|
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setShowBackupBanner(isNewUserBackupBannerVisible()) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const npub = useMemo(() => (pubkey ? pubkeyToNpub(pubkey) : null), [pubkey]) |
||||||
|
|
||||||
|
const storedNsec = pubkey ? storage.getAccountNsec(pubkey) : undefined |
||||||
|
const storedNcryptsec = pubkey ? storage.getAccountNcryptsec(pubkey) : undefined |
||||||
|
const plainNsec = nsec ?? storedNsec |
||||||
|
const encryptedBlob = ncryptsec ?? storedNcryptsec |
||||||
|
const usesEncryption = !!encryptedBlob && !plainNsec |
||||||
|
const recoverableKey = plainNsec ?? encryptedBlob |
||||||
|
const displayedKey = revealedNsec ?? (showKey && !usesEncryption ? recoverableKey : null) |
||||||
|
const copyKeyValue = revealedNsec ?? recoverableKey |
||||||
|
const keyLabel = revealedNsec || plainNsec ? 'nsec' : 'ncryptsec' |
||||||
|
const hasLocalKey = |
||||||
|
account?.signerType === 'nsec' || |
||||||
|
account?.signerType === 'ncryptsec' || |
||||||
|
!!storedNsec || |
||||||
|
!!storedNcryptsec |
||||||
|
|
||||||
|
if (!pubkey || !hasLocalKey || !recoverableKey) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, which: 'npub' | 'key') => { |
||||||
|
await navigator.clipboard.writeText(text) |
||||||
|
if (which === 'npub') { |
||||||
|
setCopiedNpub(true) |
||||||
|
setTimeout(() => setCopiedNpub(false), 2000) |
||||||
|
} else { |
||||||
|
setCopiedKey(true) |
||||||
|
setTimeout(() => setCopiedKey(false), 2000) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const dismissBanner = () => { |
||||||
|
dismissNewUserBackupBanner() |
||||||
|
setShowBackupBanner(false) |
||||||
|
requestNewUserTemplateBroadcast(pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
const handleToggleShowKey = () => { |
||||||
|
if (showKey) { |
||||||
|
setShowKey(false) |
||||||
|
setRevealedNsec(null) |
||||||
|
return |
||||||
|
} |
||||||
|
if (usesEncryption) { |
||||||
|
setPasswordPromptOpen(true) |
||||||
|
return |
||||||
|
} |
||||||
|
setShowKey(true) |
||||||
|
} |
||||||
|
|
||||||
|
const handleDecryptPassword = (password: string | null) => { |
||||||
|
setPasswordPromptOpen(false) |
||||||
|
if (!password || !encryptedBlob) return |
||||||
|
try { |
||||||
|
const privkey = nip49.decrypt(encryptedBlob, password) |
||||||
|
setRevealedNsec(nip19.nsecEncode(privkey)) |
||||||
|
setShowKey(true) |
||||||
|
} catch { |
||||||
|
toast.error(t('Could not decrypt — check your password and try again.')) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleRemoveLocalKey = () => { |
||||||
|
try { |
||||||
|
discardLocalPrivateKey() |
||||||
|
dismissBanner() |
||||||
|
setRemoveConfirmOpen(false) |
||||||
|
toast.success( |
||||||
|
t( |
||||||
|
'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.' |
||||||
|
) |
||||||
|
) |
||||||
|
} catch (error) { |
||||||
|
toast.error((error as Error).message) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="space-y-4"> |
||||||
|
{showBackupBanner && ( |
||||||
|
<div className="rounded-lg border border-orange-500/50 bg-orange-500/10 p-4 space-y-2"> |
||||||
|
<div className="flex items-start justify-between gap-2"> |
||||||
|
<p className="text-sm font-medium text-orange-600 dark:text-orange-400"> |
||||||
|
{t('Back up your private key now')} |
||||||
|
</p> |
||||||
|
<Button type="button" variant="ghost" size="icon" className="shrink-0 h-7 w-7" onClick={dismissBanner}> |
||||||
|
<X className="size-4" /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t( |
||||||
|
'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.' |
||||||
|
)} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<KeyRound className="size-4 shrink-0 text-muted-foreground" /> |
||||||
|
<h2 className="text-sm font-semibold">{t('Private key recovery')}</h2> |
||||||
|
</div> |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t( |
||||||
|
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.' |
||||||
|
)} |
||||||
|
</p> |
||||||
|
{usesEncryption && ( |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t( |
||||||
|
'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.' |
||||||
|
)} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('npub')}</Label> |
||||||
|
<div className="flex gap-2"> |
||||||
|
<Input readOnly value={npub ?? ''} className="font-mono text-xs" /> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="secondary" |
||||||
|
size="icon" |
||||||
|
aria-label={t('Copy npub')} |
||||||
|
onClick={() => npub && copyToClipboard(npub, 'npub')} |
||||||
|
> |
||||||
|
{copiedNpub ? <Check /> : <Copy />} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label> |
||||||
|
{t('Copy private key')} ({keyLabel}) |
||||||
|
</Label> |
||||||
|
<div className="flex flex-wrap gap-2"> |
||||||
|
<Button type="button" variant="secondary" onClick={() => copyKeyValue && copyToClipboard(copyKeyValue, 'key')}> |
||||||
|
{copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />} |
||||||
|
{t('Copy private key')} |
||||||
|
</Button> |
||||||
|
<Button type="button" variant="outline" onClick={handleToggleShowKey}> |
||||||
|
{showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />} |
||||||
|
{showKey ? t('Hide key') : t('Show key')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
{showKey && displayedKey && ( |
||||||
|
<div className="rounded-md border bg-muted/40 p-3"> |
||||||
|
<p className="text-xs text-orange-500 mb-2"> |
||||||
|
{t('Do not share this with anyone. Anyone with this key can control your account.')} |
||||||
|
</p> |
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{displayedKey}</pre> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<NcryptsecPasswordPrompt open={passwordPromptOpen} onResult={handleDecryptPassword} /> |
||||||
|
<div className="pt-2 border-t space-y-2"> |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t( |
||||||
|
'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.' |
||||||
|
)} |
||||||
|
</p> |
||||||
|
<Button type="button" variant="destructive" onClick={() => setRemoveConfirmOpen(true)}> |
||||||
|
<Trash2 className="mr-2 size-4" /> |
||||||
|
{t('Remove local private key')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
<AlertDialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}> |
||||||
|
<AlertDialogContent> |
||||||
|
<AlertDialogHeader> |
||||||
|
<AlertDialogTitle>{t('Remove local private key?')}</AlertDialogTitle> |
||||||
|
<AlertDialogDescription> |
||||||
|
{t( |
||||||
|
'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.' |
||||||
|
)} |
||||||
|
</AlertDialogDescription> |
||||||
|
</AlertDialogHeader> |
||||||
|
<AlertDialogFooter> |
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel> |
||||||
|
<AlertDialogAction onClick={handleRemoveLocalKey}>{t('Remove local private key')}</AlertDialogAction> |
||||||
|
</AlertDialogFooter> |
||||||
|
</AlertDialogContent> |
||||||
|
</AlertDialog> |
||||||
|
</section> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,13 +0,0 @@ |
|||||||
import { |
|
||||||
enterMetadataRelaysOnlyBypass, |
|
||||||
leaveMetadataRelaysOnlyBypass |
|
||||||
} from '@/lib/read-only-relay-personal' |
|
||||||
import { useEffect } from 'react' |
|
||||||
|
|
||||||
/** Disable “only my relay lists” while mounted (relay explore, search, relay directory). */ |
|
||||||
export function useBypassMetadataRelaysOnlyPolicy(): void { |
|
||||||
useEffect(() => { |
|
||||||
enterMetadataRelaysOnlyBypass() |
|
||||||
return () => leaveMetadataRelaysOnlyBypass() |
|
||||||
}, []) |
|
||||||
} |
|
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import { enterSingleRelayExplicitBrowse, leaveSingleRelayExplicitBrowse } from '@/lib/read-only-relay-personal' |
||||||
|
import { useEffect } from 'react' |
||||||
|
|
||||||
|
/** Relay detail feed: connect to the page relay even if it is not on the viewer's personal lists. */ |
||||||
|
export function useRelayPageFeedPolicy(): void { |
||||||
|
useEffect(() => { |
||||||
|
enterSingleRelayExplicitBrowse() |
||||||
|
return () => leaveSingleRelayExplicitBrowse() |
||||||
|
}, []) |
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
eventHasExactNotificationThreadWatchRef, |
||||||
|
parseThreadWatchListRefs |
||||||
|
} from '@/lib/notification-thread-watch' |
||||||
|
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { useCallback, useEffect, useState } from 'react' |
||||||
|
|
||||||
|
/** Local kind 19130 / 19132 lists — thread follow/mute menu state (not the open note’s kind). */ |
||||||
|
export function useThreadNotificationMenuState(event: Event) { |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const threadWatch = useNotificationThreadWatchOptional() |
||||||
|
const [idbFollowed, setIdbFollowed] = useState(false) |
||||||
|
const [idbMuted, setIdbMuted] = useState(false) |
||||||
|
|
||||||
|
const refreshFromIdb = useCallback(async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setIdbFollowed(false) |
||||||
|
setIdbMuted(false) |
||||||
|
return |
||||||
|
} |
||||||
|
const pk = pubkey.trim().toLowerCase() |
||||||
|
try { |
||||||
|
const [followEv, muteEv] = await Promise.all([ |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST), |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST) |
||||||
|
]) |
||||||
|
const followRefs = parseThreadWatchListRefs(followEv ?? undefined) |
||||||
|
const muteRefs = parseThreadWatchListRefs(muteEv ?? undefined) |
||||||
|
setIdbFollowed(eventHasExactNotificationThreadWatchRef(event, followRefs)) |
||||||
|
setIdbMuted(eventHasExactNotificationThreadWatchRef(event, muteRefs)) |
||||||
|
} catch { |
||||||
|
setIdbFollowed(false) |
||||||
|
setIdbMuted(false) |
||||||
|
} |
||||||
|
}, [pubkey, event.id, event.kind, event.created_at]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
void refreshFromIdb() |
||||||
|
}, [ |
||||||
|
refreshFromIdb, |
||||||
|
threadWatch?.eventsIFollowListEvent?.id, |
||||||
|
threadWatch?.eventsIMutedListEvent?.id |
||||||
|
]) |
||||||
|
|
||||||
|
const threadFollowed = threadWatch |
||||||
|
? threadWatch.isFollowedForNotifications(event) |
||||||
|
: idbFollowed |
||||||
|
const threadMuted = threadWatch |
||||||
|
? threadWatch.isMutedForNotifications(event) |
||||||
|
: idbMuted |
||||||
|
|
||||||
|
return { threadFollowed, threadMuted, threadWatch } |
||||||
|
} |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
import { NIP05_AFFILIATION_DOMAINS, type TNip05AffiliationDomain } from '@/constants' |
||||||
|
import { affiliationNip05CandidatesFromProfile } from '@/lib/nip05-affiliation' |
||||||
|
import { verifyNip05 } from '@/lib/nip05' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
|
||||||
|
export function useVerifiedNip05Affiliations( |
||||||
|
pubkey: string | undefined, |
||||||
|
nip05?: string, |
||||||
|
nip05List?: string[] |
||||||
|
): readonly TNip05AffiliationDomain[] { |
||||||
|
const candidates = useMemo( |
||||||
|
() => affiliationNip05CandidatesFromProfile(nip05, nip05List), |
||||||
|
[nip05, nip05List] |
||||||
|
) |
||||||
|
const candidatesKey = useMemo( |
||||||
|
() => candidates.map((c) => c.nip05).join('\u0001'), |
||||||
|
[candidates] |
||||||
|
) |
||||||
|
const [verified, setVerified] = useState<readonly TNip05AffiliationDomain[]>([]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pubkey || candidates.length === 0) { |
||||||
|
setVerified([]) |
||||||
|
return |
||||||
|
} |
||||||
|
let cancelled = false |
||||||
|
void (async () => { |
||||||
|
const confirmed = new Set<string>() |
||||||
|
await Promise.all( |
||||||
|
candidates.map(async ({ nip05: nip05Id, affiliation }) => { |
||||||
|
const result = await verifyNip05(nip05Id, pubkey) |
||||||
|
if ( |
||||||
|
result.isVerified && |
||||||
|
result.nip05Domain.toLowerCase() === affiliation.domain |
||||||
|
) { |
||||||
|
confirmed.add(affiliation.domain) |
||||||
|
} |
||||||
|
}) |
||||||
|
) |
||||||
|
if (cancelled) return |
||||||
|
setVerified( |
||||||
|
NIP05_AFFILIATION_DOMAINS.filter((entry) => confirmed.has(entry.domain)) |
||||||
|
) |
||||||
|
})() |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [pubkey, candidatesKey]) |
||||||
|
|
||||||
|
return verified |
||||||
|
} |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const DRIFT_GITS_SPAM: Event = { |
||||||
|
kind: 1, |
||||||
|
content: |
||||||
|
'sp_4c43bd1d.949ac75f.06.OHCFKDGO2J6TV4KYHAB2JBLIMXHR6RQWVYAGRVBPBUKCH6CPR7JJU3PMG4SBCQA.drift.gits.net', |
||||||
|
created_at: 1780215168, |
||||||
|
id: '00f077ecb154545e5a5ae98b1fe28db5e30661e2cad5c714c6b2b8d9a81c774a', |
||||||
|
pubkey: '53ce12f561b8ecf9e20ae19acb0201bdc661d9e36801b47a642d9f8fdb01a245', |
||||||
|
sig: '72bb0acfe6174a51ab176b3bf178ebcaf648e4427fb5b5500341af1603be420509d833e56e4e4315b7f6a30f6223ea85475f0f2946dbad23dc6cf95959ce9646', |
||||||
|
tags: [ |
||||||
|
['t', 'sp_4c43bd1d'], |
||||||
|
['nonce', '3559b6bd', '8'] |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
const BASE64_BLOB_SPAM: Event = { |
||||||
|
kind: 1, |
||||||
|
content: |
||||||
|
'yBH9z+dFkrjXwdWnT43WlzguqTlaMEaeVr2+2A5cJKpbgnSxuU/rstTbQzkb1ormLJOt6ary5iWeBVul1xHFgMzVFlnDeIrUyOGeMIBu18gwTlOyJ4NY4RsmegRYivAoej1Hik+ifi5DmXYQN3dsIiz2xYqMiks+uegscL71yY2QZOA=', |
||||||
|
created_at: 1780215178, |
||||||
|
id: '6b5451748d2aa66c699b99d343275d161708a0692b3edd95dcc162409bd8e0c6', |
||||||
|
pubkey: '3ccf8522563127b37aaf0cafd0545851d9d1f6a62033ce373636b2fb72a2ffdf', |
||||||
|
sig: '960d9c5fe890de907e2ebe922d4a3101670add71306d3f55f066b11c301f457b3635e305a5487a3c8bdad3c5405bab3bca26623ccbd56753c13c5e68b400c20e', |
||||||
|
tags: [] |
||||||
|
} |
||||||
|
|
||||||
|
describe('shouldDropEventOnIngest', () => { |
||||||
|
it('drops drift.gits.net kind-1 spam', () => { |
||||||
|
expect(shouldDropEventOnIngest(DRIFT_GITS_SPAM)).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('allows drift.gits.net spam on explicit note lookup', () => { |
||||||
|
expect( |
||||||
|
shouldDropEventOnIngest(DRIFT_GITS_SPAM, { |
||||||
|
explicitNoteLookupHexId: DRIFT_GITS_SPAM.id |
||||||
|
}) |
||||||
|
).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('drops long base64-like kind-1 blobs ending with =', () => { |
||||||
|
expect(shouldDropEventOnIngest(BASE64_BLOB_SPAM)).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('allows long base64 blob on explicit note lookup', () => { |
||||||
|
expect( |
||||||
|
shouldDropEventOnIngest(BASE64_BLOB_SPAM, { |
||||||
|
explicitNoteLookupHexId: BASE64_BLOB_SPAM.id |
||||||
|
}) |
||||||
|
).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('does not drop short kind-1 text ending with =', () => { |
||||||
|
expect( |
||||||
|
shouldDropEventOnIngest({ |
||||||
|
...BASE64_BLOB_SPAM, |
||||||
|
content: 'x=3', |
||||||
|
tags: [] |
||||||
|
}) |
||||||
|
).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('does not drop normal kind-1 text', () => { |
||||||
|
expect( |
||||||
|
shouldDropEventOnIngest({ |
||||||
|
...DRIFT_GITS_SPAM, |
||||||
|
content: 'Hello nostr', |
||||||
|
tags: [] |
||||||
|
}) |
||||||
|
).toBe(false) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { pinHttpIndexRelaysInRelayCap, uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' |
||||||
|
|
||||||
|
describe('feed-relay-urls', () => { |
||||||
|
it('collects deduped relay URLs from subrequests', () => { |
||||||
|
expect( |
||||||
|
uniqueRelayUrlsFromSubRequests([ |
||||||
|
{ urls: ['wss://a.example/', 'wss://b.example/'], filter: { limit: 1 } }, |
||||||
|
{ urls: ['wss://a.example/', 'wss://c.example/'], filter: { limit: 1 } } |
||||||
|
]) |
||||||
|
).toEqual(['wss://a.example/', 'wss://b.example/', 'wss://c.example/']) |
||||||
|
}) |
||||||
|
|
||||||
|
it('pins kind-10243 HTTP read relays into a capped faux spell stack', () => { |
||||||
|
const ws = Array.from({ length: 10 }, (_, i) => `wss://relay-${i}.example/`) |
||||||
|
const http = 'https://index.example.com/' |
||||||
|
const capped = pinHttpIndexRelaysInRelayCap(ws, [...ws, http], 10) |
||||||
|
expect(capped.some((u) => u.includes('index.example.com'))).toBe(true) |
||||||
|
expect(capped.length).toBe(10) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,67 @@ |
|||||||
|
import { normalizeHttpRelayUrl, normalizeRelayUrlByScheme, isHttpOrHttpsScheme } from '@/lib/url' |
||||||
|
import type { TFeedSubRequest } from '@/types' |
||||||
|
|
||||||
|
function relayDedupeKey(url: string): string { |
||||||
|
return (normalizeRelayUrlByScheme(url) || url.trim()).toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
/** Deduped relay URLs from all timeline subrequests (REQ order preserved). */ |
||||||
|
export function uniqueRelayUrlsFromSubRequests(requests: readonly TFeedSubRequest[]): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
for (const req of requests) { |
||||||
|
for (const raw of req.urls) { |
||||||
|
const n = normalizeRelayUrlByScheme(raw) || raw.trim() |
||||||
|
if (!n) continue |
||||||
|
const key = relayDedupeKey(n) |
||||||
|
if (seen.has(key)) continue |
||||||
|
seen.add(key) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Keep viewer kind-10243 HTTP index relays in a capped feed stack (they are easy to drop when |
||||||
|
* favorites + NIP-65 WS fill {@link FAUX_SPELL_MAX_RELAYS}). |
||||||
|
*/ |
||||||
|
export function pinHttpIndexRelaysInRelayCap( |
||||||
|
capped: readonly string[], |
||||||
|
sourceUrls: readonly string[], |
||||||
|
maxRelays: number |
||||||
|
): string[] { |
||||||
|
const httpSources = sourceUrls |
||||||
|
.map((u) => normalizeHttpRelayUrl(u) || (isHttpOrHttpsScheme(u.trim()) ? u.trim() : '')) |
||||||
|
.filter(Boolean) |
||||||
|
if (httpSources.length === 0) return [...capped] |
||||||
|
|
||||||
|
const httpKeySet = new Set(httpSources.map((u) => u.toLowerCase())) |
||||||
|
const out = [...capped] |
||||||
|
const outKeys = new Set(out.map(relayDedupeKey)) |
||||||
|
|
||||||
|
for (const http of httpSources) { |
||||||
|
const key = http.toLowerCase() |
||||||
|
if (outKeys.has(key)) continue |
||||||
|
|
||||||
|
while (out.length >= maxRelays) { |
||||||
|
let dropped = false |
||||||
|
for (let i = out.length - 1; i >= 0; i--) { |
||||||
|
const candidate = out[i]! |
||||||
|
const ck = relayDedupeKey(candidate) |
||||||
|
if (httpKeySet.has(ck) || isHttpOrHttpsScheme(candidate.trim())) continue |
||||||
|
out.splice(i, 1) |
||||||
|
outKeys.delete(ck) |
||||||
|
dropped = true |
||||||
|
break |
||||||
|
} |
||||||
|
if (!dropped) break |
||||||
|
} |
||||||
|
|
||||||
|
if (out.length >= maxRelays) continue |
||||||
|
out.push(http) |
||||||
|
outKeys.add(key) |
||||||
|
} |
||||||
|
|
||||||
|
return out.slice(0, maxRelays) |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
/** |
||||||
|
* Shared Tailwind classes for menus, popovers, and selects. |
||||||
|
* Uses Radix collision CSS variables so lists fit the viewport (mobile + large font). |
||||||
|
*/ |
||||||
|
|
||||||
|
/** Dropdown / menu list vertical bound */ |
||||||
|
export const dropdownMenuMaxHeightClass = |
||||||
|
'max-h-[min(85dvh,var(--radix-dropdown-menu-content-available-height,100dvh))]' |
||||||
|
|
||||||
|
/** Popover panel vertical bound */ |
||||||
|
export const popoverMaxHeightClass = |
||||||
|
'max-h-[min(85dvh,var(--radix-popover-content-available-height,100dvh))]' |
||||||
|
|
||||||
|
/** Select viewport vertical bound */ |
||||||
|
export const selectViewportMaxHeightClass = |
||||||
|
'max-h-[min(85dvh,var(--radix-select-content-available-height,80dvh))]' |
||||||
|
|
||||||
|
/** Hover card vertical bound */ |
||||||
|
export const hoverCardMaxHeightClass = |
||||||
|
'max-h-[min(85dvh,var(--radix-hover-card-content-available-height,100dvh))]' |
||||||
|
|
||||||
|
/** Keep panels inside the screen horizontally */ |
||||||
|
export const floatingPanelMaxWidthClass = 'max-w-[min(calc(100vw-1.5rem),28rem)]' |
||||||
|
|
||||||
|
export const floatingPanelScrollClass = |
||||||
|
'popover-scroll-y min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain' |
||||||
|
|
||||||
|
/** Menu rows: wrap when root font-size is large */ |
||||||
|
export const menuItemLargeTextClass = 'min-w-0 whitespace-normal' |
||||||
@ -1,10 +1,27 @@ |
|||||||
import { PROFILE_RELAY_URLS } from '@/constants' |
import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' |
||||||
import { describe, expect, it } from 'vitest' |
import { describe, expect, it } from 'vitest' |
||||||
import { isMetadataPolicyCuratedRelay } from './metadata-policy-curated-relays' |
import { |
||||||
|
isMetadataPolicyActiveReadGrantRelay, |
||||||
|
isMetadataPolicyCuratedRelay, |
||||||
|
isMetadataPolicyOperationScopedRelay |
||||||
|
} from './metadata-policy-curated-relays' |
||||||
|
|
||||||
describe('metadata-policy-curated-relays', () => { |
describe('metadata-policy-curated-relays', () => { |
||||||
it('recognizes profile relay constants', () => { |
it('recognizes profile relay constants', () => { |
||||||
expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) |
expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) |
||||||
expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false) |
expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false) |
||||||
}) |
}) |
||||||
|
|
||||||
|
it('operation scope excludes FAST_READ widening', () => { |
||||||
|
expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true) |
||||||
|
expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) |
||||||
|
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) |
||||||
|
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('active read grant includes search and discovery stacks', () => { |
||||||
|
expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true) |
||||||
|
expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) |
||||||
|
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false) |
||||||
|
}) |
||||||
}) |
}) |
||||||
|
|||||||
@ -0,0 +1,186 @@ |
|||||||
|
import { ExtendedKind, PROFILE_RELAY_URLS } from '@/constants' |
||||||
|
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' |
||||||
|
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' |
||||||
|
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' |
||||||
|
import { collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { NEW_USER_HTTP_RELAY_URL } from '@/lib/new-user-template' |
||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import type { TRelayList } from '@/types' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
|
||||||
|
const BROADCAST_PENDING_KEY = 'imwaldNewUserTemplateBroadcastPending' |
||||||
|
|
||||||
|
/** Space between replaceable template events — keeps relay publish rate limits from tripping. */ |
||||||
|
export const NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS = 20_000 |
||||||
|
|
||||||
|
/** Replaceable kinds created during one-click signup, in publish order. */ |
||||||
|
export const NEW_USER_TEMPLATE_BROADCAST_KINDS = [ |
||||||
|
kinds.RelayList, |
||||||
|
ExtendedKind.HTTP_RELAY_LIST, |
||||||
|
ExtendedKind.FAVORITE_RELAYS, |
||||||
|
ExtendedKind.BLOCKED_RELAYS, |
||||||
|
kinds.Metadata, |
||||||
|
10015, |
||||||
|
kinds.Contacts, |
||||||
|
kinds.Mutelist |
||||||
|
] as const |
||||||
|
|
||||||
|
/** Relays that reject bursts or return HTTP 429 on connect during signup publish. */ |
||||||
|
const NEW_USER_TEMPLATE_PUBLISH_EXCLUDED = [ |
||||||
|
'wss://relay.layer.systems', |
||||||
|
'wss://profiles.nostrver.se/' |
||||||
|
] as const |
||||||
|
|
||||||
|
/** Profile mirrors that only mirror kind 10002 (not kind 0 or other lists). */ |
||||||
|
const RELAY_LIST_ONLY_PROFILE_MIRRORS = ['wss://indexer.coracle.social/'] as const |
||||||
|
|
||||||
|
const broadcastScheduledOrRunning = new Set<string>() |
||||||
|
|
||||||
|
function templateRelayKey(url: string): string { |
||||||
|
return (normalizeAnyRelayUrl(url) || url).toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function isExcludedFromTemplateBroadcast(url: string): boolean { |
||||||
|
const key = templateRelayKey(url) |
||||||
|
return NEW_USER_TEMPLATE_PUBLISH_EXCLUDED.some((u) => templateRelayKey(u) === key) |
||||||
|
} |
||||||
|
|
||||||
|
function relayAllowsTemplateKind(url: string, kind: number): boolean { |
||||||
|
if (kind === kinds.RelayList) return true |
||||||
|
const key = templateRelayKey(url) |
||||||
|
return !RELAY_LIST_ONLY_PROFILE_MIRRORS.some((u) => templateRelayKey(u) === key) |
||||||
|
} |
||||||
|
|
||||||
|
function maxTemplatePublishRelays(kind: number): number { |
||||||
|
return kind === kinds.Metadata || kind === kinds.RelayList ? 4 : 3 |
||||||
|
} |
||||||
|
|
||||||
|
/** Prefer mercury + stable write relays; profile index when kind allows. */ |
||||||
|
function prioritizeNewUserTemplateRelays(urls: string[]): string[] { |
||||||
|
const preferredOrder = [ |
||||||
|
NEW_USER_HTTP_RELAY_URL, |
||||||
|
'wss://profiles.nostr1.com', |
||||||
|
'wss://nos.lol', |
||||||
|
'wss://relay.primal.net', |
||||||
|
'wss://relay.damus.io', |
||||||
|
'wss://thecitadel.nostr1.com' |
||||||
|
] |
||||||
|
const byKey = new Map(urls.map((u) => [templateRelayKey(u), u])) |
||||||
|
const ordered: string[] = [] |
||||||
|
for (const pref of preferredOrder) { |
||||||
|
const u = byKey.get(templateRelayKey(pref)) |
||||||
|
if (u) { |
||||||
|
ordered.push(u) |
||||||
|
byKey.delete(templateRelayKey(u)) |
||||||
|
} |
||||||
|
} |
||||||
|
for (const u of urls) { |
||||||
|
const k = templateRelayKey(u) |
||||||
|
if (byKey.has(k)) { |
||||||
|
ordered.push(u) |
||||||
|
byKey.delete(k) |
||||||
|
} |
||||||
|
} |
||||||
|
return ordered |
||||||
|
} |
||||||
|
|
||||||
|
export function markNewUserTemplateBroadcastPending(pubkey: string): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
sessionStorage.setItem(BROADCAST_PENDING_KEY, pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
function consumeBroadcastPending(pubkey: string): boolean { |
||||||
|
if (typeof sessionStorage === 'undefined') return false |
||||||
|
if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return false |
||||||
|
sessionStorage.removeItem(BROADCAST_PENDING_KEY) |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** Write outboxes from the stored template plus profile index relays where the kind allows it. */ |
||||||
|
export function newUserTemplatePublishRelays(kind: number, relayList: TRelayList): string[] { |
||||||
|
const write = collectWriteOutboxUrlsFromRelayList(relayList) |
||||||
|
const merged = |
||||||
|
kind === kinds.Metadata || kind === kinds.RelayList |
||||||
|
? dedupeNormalizeRelayUrlsOrdered([...write, ...PROFILE_RELAY_URLS]) |
||||||
|
: write |
||||||
|
const filtered = filterRelaysForEventPublish(merged, kind) |
||||||
|
.filter((u) => !isExcludedFromTemplateBroadcast(u)) |
||||||
|
.filter((u) => relayAllowsTemplateKind(u, kind)) |
||||||
|
return prioritizeNewUserTemplateRelays(filtered).slice(0, maxTemplatePublishRelays(kind)) |
||||||
|
} |
||||||
|
|
||||||
|
async function loadRelayListForPublish(pubkey: string): Promise<TRelayList> { |
||||||
|
const peeked = await client.peekRelayListFromStorage(pubkey) |
||||||
|
if (peeked.write.length > 0 || peeked.httpWrite.length > 0) { |
||||||
|
return peeked |
||||||
|
} |
||||||
|
const [relayListEvent, httpRelayListEvent] = await Promise.all([ |
||||||
|
indexedDb.getReplaceableEvent(pubkey, kinds.RelayList), |
||||||
|
indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST) |
||||||
|
]) |
||||||
|
const emptyHttp = { |
||||||
|
httpRead: [] as string[], |
||||||
|
httpWrite: [] as string[], |
||||||
|
httpOriginalRelays: [] as TRelayList['httpOriginalRelays'] |
||||||
|
} |
||||||
|
let base: TRelayList = relayListEvent |
||||||
|
? getRelayListFromEvent(relayListEvent, []) |
||||||
|
: { write: [], read: [], originalRelays: [], ...emptyHttp } |
||||||
|
if (httpRelayListEvent) { |
||||||
|
const http = getHttpRelayListFromEvent(httpRelayListEvent, []) |
||||||
|
base = { |
||||||
|
...base, |
||||||
|
httpRead: http.httpRead, |
||||||
|
httpWrite: http.httpWrite, |
||||||
|
httpOriginalRelays: http.httpOriginalRelays |
||||||
|
} |
||||||
|
} |
||||||
|
return base |
||||||
|
} |
||||||
|
|
||||||
|
async function broadcastNewUserTemplateFromStorage(pubkey: string): Promise<void> { |
||||||
|
const relayList = await loadRelayListForPublish(pubkey) |
||||||
|
for (let i = 0; i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length; i++) { |
||||||
|
const kind = NEW_USER_TEMPLATE_BROADCAST_KINDS[i] |
||||||
|
const event = (await indexedDb.getReplaceableEvent(pubkey, kind)) as Event | undefined |
||||||
|
if (!event) continue |
||||||
|
const relays = newUserTemplatePublishRelays(kind, relayList) |
||||||
|
if (relays.length === 0) continue |
||||||
|
try { |
||||||
|
await client.publishEvent(relays, event, { |
||||||
|
skipOutboxRetry: true, |
||||||
|
publishBatchLabel: 'new user template broadcast' |
||||||
|
}) |
||||||
|
} catch (error) { |
||||||
|
logger.warn('[newUserTemplateBroadcast] publish failed', { kind, error }) |
||||||
|
} |
||||||
|
if (i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length - 1) { |
||||||
|
await new Promise((resolve) => setTimeout(resolve, NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* After the user dismisses the backup banner or leaves cache settings, broadcast locally stored |
||||||
|
* template events to their write outboxes and profile relays (spaced to avoid relay rate limits). |
||||||
|
*/ |
||||||
|
export function requestNewUserTemplateBroadcast(pubkey: string): void { |
||||||
|
if (!pubkey || broadcastScheduledOrRunning.has(pubkey)) return |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return |
||||||
|
|
||||||
|
broadcastScheduledOrRunning.add(pubkey) |
||||||
|
void (async () => { |
||||||
|
try { |
||||||
|
if (!consumeBroadcastPending(pubkey)) return |
||||||
|
await broadcastNewUserTemplateFromStorage(pubkey) |
||||||
|
} catch (error) { |
||||||
|
logger.error('[newUserTemplateBroadcast] failed', { error }) |
||||||
|
} finally { |
||||||
|
broadcastScheduledOrRunning.delete(pubkey) |
||||||
|
} |
||||||
|
})() |
||||||
|
} |
||||||
@ -0,0 +1,124 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' |
||||||
|
import { NEW_USER_BLOCKED_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, buildNewUserTemplateDrafts, newUserProfileDisplayName, newUserProfileName, newUserProfileSuffix } from '@/lib/new-user-template' |
||||||
|
import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast' |
||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import type { TRelayList } from '@/types' |
||||||
|
|
||||||
|
const TEST_PUBKEY = 'a'.repeat(63) + 'b' |
||||||
|
|
||||||
|
function relayKey(url: string): string { |
||||||
|
return (normalizeAnyRelayUrl(url) || url).toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function expectRelayKeys(actual: string[], expected: string[]) { |
||||||
|
const actualKeys = new Set(actual.map(relayKey)) |
||||||
|
for (const url of expected) { |
||||||
|
expect(actualKeys.has(relayKey(url))).toBe(true) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const templateRelayList = (): TRelayList => ({ |
||||||
|
write: [...FAST_WRITE_RELAY_URLS], |
||||||
|
read: [...FAST_READ_RELAY_URLS], |
||||||
|
originalRelays: [], |
||||||
|
httpRead: [], |
||||||
|
httpWrite: [NEW_USER_HTTP_RELAY_URL], |
||||||
|
httpOriginalRelays: [] |
||||||
|
}) |
||||||
|
|
||||||
|
describe('newUserProfileSuffix', () => { |
||||||
|
it('returns a number between 1000 and 9999', () => { |
||||||
|
const suffix = newUserProfileSuffix(TEST_PUBKEY) |
||||||
|
expect(suffix).toBeGreaterThanOrEqual(1000) |
||||||
|
expect(suffix).toBeLessThanOrEqual(9999) |
||||||
|
}) |
||||||
|
|
||||||
|
it('formats profile names with the suffix', () => { |
||||||
|
const suffix = newUserProfileSuffix(TEST_PUBKEY) |
||||||
|
expect(newUserProfileName(TEST_PUBKEY)).toBe(`ImwaldUser${suffix}`) |
||||||
|
expect(newUserProfileDisplayName(TEST_PUBKEY)).toBe(`Imwald User ${suffix}`) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('buildNewUserTemplateDrafts', () => { |
||||||
|
const drafts = buildNewUserTemplateDrafts(TEST_PUBKEY) |
||||||
|
|
||||||
|
it('builds profile kind 0 with unique names', () => { |
||||||
|
expect(drafts.profile.kind).toBe(kinds.Metadata) |
||||||
|
const profile = JSON.parse(drafts.profile.content) |
||||||
|
expect(profile.name).toBe(newUserProfileName(TEST_PUBKEY)) |
||||||
|
expect(profile.display_name).toBe(newUserProfileDisplayName(TEST_PUBKEY)) |
||||||
|
expect(profile.about).toContain('Imwald') |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds favorite relays kind 10012', () => { |
||||||
|
expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS) |
||||||
|
expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2) |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds blocked relays kind 10006 with dead relays', () => { |
||||||
|
expect(drafts.blockedRelays.kind).toBe(ExtendedKind.BLOCKED_RELAYS) |
||||||
|
const blocked = drafts.blockedRelays.tags.filter((t) => t[0] === 'relay').map((t) => t[1]) |
||||||
|
expect(blocked).toEqual([...NEW_USER_BLOCKED_RELAY_URLS]) |
||||||
|
}) |
||||||
|
|
||||||
|
it('splits mailbox read and write relays', () => { |
||||||
|
expect(drafts.relayList.kind).toBe(kinds.RelayList) |
||||||
|
const readTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'read') |
||||||
|
const writeTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'write') |
||||||
|
expect(readTags).toHaveLength(FAST_READ_RELAY_URLS.length) |
||||||
|
expect(writeTags).toHaveLength(FAST_WRITE_RELAY_URLS.length) |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds HTTP relay list kind 10243 with mercury', () => { |
||||||
|
expect(drafts.httpRelayList.kind).toBe(ExtendedKind.HTTP_RELAY_LIST) |
||||||
|
expect(drafts.httpRelayList.tags.some((t) => t[1]?.includes('mercury-relay.imwald.eu'))).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds interest list with expected topics', () => { |
||||||
|
expect(drafts.interestList.kind).toBe(10015) |
||||||
|
const topics = drafts.interestList.tags.filter((t) => t[0] === 't').map((t) => t[1]) |
||||||
|
expect(topics).toEqual([ |
||||||
|
'art', |
||||||
|
'music', |
||||||
|
'news', |
||||||
|
'foodstr', |
||||||
|
'coffeechain', |
||||||
|
'travel', |
||||||
|
'grownostr', |
||||||
|
'plebchain' |
||||||
|
]) |
||||||
|
}) |
||||||
|
|
||||||
|
it('builds empty follow and mute lists', () => { |
||||||
|
expect(drafts.followList.kind).toBe(kinds.Contacts) |
||||||
|
expect(drafts.followList.tags).toHaveLength(0) |
||||||
|
expect(drafts.muteList.kind).toBe(10000) |
||||||
|
expect(drafts.muteList.tags).toHaveLength(0) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('newUserTemplatePublishRelays', () => { |
||||||
|
const relayList = templateRelayList() |
||||||
|
|
||||||
|
it('caps list kinds to three stable write relays and skips flaky mirrors', () => { |
||||||
|
const targets = newUserTemplatePublishRelays(10015, relayList) |
||||||
|
expect(targets.length).toBeLessThanOrEqual(3) |
||||||
|
expect(targets.map(relayKey)).not.toContain(relayKey('wss://relay.layer.systems')) |
||||||
|
expect(targets.map(relayKey)).not.toContain(relayKey('wss://profiles.nostrver.se/')) |
||||||
|
expect(targets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/')) |
||||||
|
expectRelayKeys(targets, [NEW_USER_HTTP_RELAY_URL]) |
||||||
|
}) |
||||||
|
|
||||||
|
it('adds profile relays for kind 0 and 10002 up to four targets', () => { |
||||||
|
const profileTargets = newUserTemplatePublishRelays(kinds.Metadata, relayList) |
||||||
|
expect(profileTargets.length).toBeLessThanOrEqual(4) |
||||||
|
expectRelayKeys(profileTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com']) |
||||||
|
expect(profileTargets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/')) |
||||||
|
const relayListTargets = newUserTemplatePublishRelays(kinds.RelayList, relayList) |
||||||
|
expect(relayListTargets.length).toBeLessThanOrEqual(4) |
||||||
|
expectRelayKeys(relayListTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com']) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
import { |
||||||
|
DEFAULT_FAVORITE_RELAYS, |
||||||
|
FAST_READ_RELAY_URLS, |
||||||
|
FAST_WRITE_RELAY_URLS |
||||||
|
} from '@/constants' |
||||||
|
import { |
||||||
|
createBlockedRelaysDraftEvent, |
||||||
|
createFavoriteRelaysDraftEvent, |
||||||
|
createFollowListDraftEvent, |
||||||
|
createHttpRelayListDraftEvent, |
||||||
|
createInterestListDraftEvent, |
||||||
|
createMuteListDraftEvent, |
||||||
|
createProfileDraftEvent, |
||||||
|
createRelayListDraftEvent |
||||||
|
} from '@/lib/draft-event' |
||||||
|
import { TDraftEvent, TMailboxRelay } from '@/types' |
||||||
|
|
||||||
|
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' |
||||||
|
|
||||||
|
/** Dead relays seeded into kind 10006 for new accounts. */ |
||||||
|
export const NEW_USER_BLOCKED_RELAY_URLS = [ |
||||||
|
'wss://orly-relay.imwald.eu', |
||||||
|
'wss://relay.nostr.band' |
||||||
|
] as const |
||||||
|
|
||||||
|
export const NEW_USER_INTEREST_TOPICS = [ |
||||||
|
'art', |
||||||
|
'music', |
||||||
|
'news', |
||||||
|
'foodstr', |
||||||
|
'coffeechain', |
||||||
|
'travel', |
||||||
|
'grownostr', |
||||||
|
'plebchain' |
||||||
|
] as const |
||||||
|
|
||||||
|
export const NEW_USER_PROFILE_ABOUT = 'New on Nostr via Imwald. Edit your profile in Settings.' |
||||||
|
|
||||||
|
/** Stable 4-digit suffix (1000–9999) from pubkey hex. */ |
||||||
|
export function newUserProfileSuffix(pubkey: string): number { |
||||||
|
const hex = pubkey.trim().toLowerCase() |
||||||
|
if (!/^[0-9a-f]{64}$/.test(hex)) { |
||||||
|
return 1000 |
||||||
|
} |
||||||
|
return (parseInt(hex.slice(-4), 16) % 9000) + 1000 |
||||||
|
} |
||||||
|
|
||||||
|
export function newUserProfileName(pubkey: string): string { |
||||||
|
return `ImwaldUser${newUserProfileSuffix(pubkey)}` |
||||||
|
} |
||||||
|
|
||||||
|
export function newUserProfileDisplayName(pubkey: string): string { |
||||||
|
return `Imwald User ${newUserProfileSuffix(pubkey)}` |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserMailboxRelays(): TMailboxRelay[] { |
||||||
|
return [ |
||||||
|
...FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'read' as const })), |
||||||
|
...FAST_WRITE_RELAY_URLS.map((url) => ({ url, scope: 'write' as const })) |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserProfileDraft(pubkey: string): TDraftEvent { |
||||||
|
const content = JSON.stringify({ |
||||||
|
name: newUserProfileName(pubkey), |
||||||
|
display_name: newUserProfileDisplayName(pubkey), |
||||||
|
about: NEW_USER_PROFILE_ABOUT |
||||||
|
}) |
||||||
|
return createProfileDraftEvent(content) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { |
||||||
|
return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], []) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserBlockedRelaysDraft(): TDraftEvent { |
||||||
|
return createBlockedRelaysDraftEvent([...NEW_USER_BLOCKED_RELAY_URLS]) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserRelayListDraft(): TDraftEvent { |
||||||
|
return createRelayListDraftEvent(buildNewUserMailboxRelays()) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserHttpRelayListDraft(): TDraftEvent { |
||||||
|
return createHttpRelayListDraftEvent([{ url: NEW_USER_HTTP_RELAY_URL, scope: 'both' }]) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserInterestListDraft(): TDraftEvent { |
||||||
|
return createInterestListDraftEvent([...NEW_USER_INTEREST_TOPICS]) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserFollowListDraft(): TDraftEvent { |
||||||
|
return createFollowListDraftEvent([]) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserMuteListDraft(): TDraftEvent { |
||||||
|
return createMuteListDraftEvent([]) |
||||||
|
} |
||||||
|
|
||||||
|
export type TNewUserTemplateDrafts = { |
||||||
|
profile: TDraftEvent |
||||||
|
favoriteRelays: TDraftEvent |
||||||
|
blockedRelays: TDraftEvent |
||||||
|
relayList: TDraftEvent |
||||||
|
httpRelayList: TDraftEvent |
||||||
|
interestList: TDraftEvent |
||||||
|
followList: TDraftEvent |
||||||
|
muteList: TDraftEvent |
||||||
|
} |
||||||
|
|
||||||
|
export function buildNewUserTemplateDrafts(pubkey: string): TNewUserTemplateDrafts { |
||||||
|
return { |
||||||
|
profile: buildNewUserProfileDraft(pubkey), |
||||||
|
favoriteRelays: buildNewUserFavoriteRelaysDraft(), |
||||||
|
blockedRelays: buildNewUserBlockedRelaysDraft(), |
||||||
|
relayList: buildNewUserRelayListDraft(), |
||||||
|
httpRelayList: buildNewUserHttpRelayListDraft(), |
||||||
|
interestList: buildNewUserInterestListDraft(), |
||||||
|
followList: buildNewUserFollowListDraft(), |
||||||
|
muteList: buildNewUserMuteListDraft() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
import { NIP05_AFFILIATION_BY_DOMAIN, type TNip05AffiliationDomain } from '@/constants' |
||||||
|
import { splitNip05Identifier } from '@/lib/nip05' |
||||||
|
|
||||||
|
export function normalizeNip05AffiliationDomain(domain: string): string { |
||||||
|
return domain.trim().toLowerCase().replace(/\.$/, '') |
||||||
|
} |
||||||
|
|
||||||
|
export function affiliationForNip05Domain(domain: string): TNip05AffiliationDomain | undefined { |
||||||
|
return NIP05_AFFILIATION_BY_DOMAIN.get(normalizeNip05AffiliationDomain(domain)) |
||||||
|
} |
||||||
|
|
||||||
|
/** Unique NIP-05 identifiers from kind-0 primary + list fields. */ |
||||||
|
export function collectProfileNip05Identifiers( |
||||||
|
nip05?: string, |
||||||
|
nip05List?: string[] |
||||||
|
): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
const add = (raw?: string) => { |
||||||
|
const id = raw?.trim() |
||||||
|
if (!id || seen.has(id)) return |
||||||
|
seen.add(id) |
||||||
|
out.push(id) |
||||||
|
} |
||||||
|
add(nip05) |
||||||
|
for (const entry of nip05List ?? []) { |
||||||
|
add(entry) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-05 rows on the profile whose domain is in {@link NIP05_AFFILIATION_DOMAINS}. |
||||||
|
* One row per identifier (verification runs separately). |
||||||
|
*/ |
||||||
|
export function affiliationNip05CandidatesFromProfile( |
||||||
|
nip05?: string, |
||||||
|
nip05List?: string[] |
||||||
|
): { nip05: string; affiliation: TNip05AffiliationDomain }[] { |
||||||
|
const out: { nip05: string; affiliation: TNip05AffiliationDomain }[] = [] |
||||||
|
const domainsSeen = new Set<string>() |
||||||
|
for (const id of collectProfileNip05Identifiers(nip05, nip05List)) { |
||||||
|
const parts = splitNip05Identifier(id) |
||||||
|
if (!parts) continue |
||||||
|
const affiliation = affiliationForNip05Domain(parts.domain) |
||||||
|
if (!affiliation || domainsSeen.has(affiliation.domain)) continue |
||||||
|
domainsSeen.add(affiliation.domain) |
||||||
|
out.push({ nip05: id, affiliation }) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
const POST_SIGNUP_NAV_KEY = 'imwaldPostSignupBackupNav' |
||||||
|
const NEW_USER_BACKUP_BANNER_KEY = 'imwaldNewUserBackupBanner' |
||||||
|
const SKIP_NETWORK_HYDRATE_KEY = 'imwaldNewUserSkipNetworkHydrate' |
||||||
|
|
||||||
|
export function schedulePostSignupBackupPrompt(pubkey: string): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
sessionStorage.setItem(POST_SIGNUP_NAV_KEY, pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
/** Returns true when this pubkey had a pending post-signup nav (and clears it). */ |
||||||
|
export function consumePostSignupBackupPrompt(pubkey: string): boolean { |
||||||
|
if (typeof sessionStorage === 'undefined') return false |
||||||
|
const pending = sessionStorage.getItem(POST_SIGNUP_NAV_KEY) |
||||||
|
if (pending !== pubkey) return false |
||||||
|
sessionStorage.removeItem(POST_SIGNUP_NAV_KEY) |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
export function showNewUserBackupBanner(): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
sessionStorage.setItem(NEW_USER_BACKUP_BANNER_KEY, '1') |
||||||
|
} |
||||||
|
|
||||||
|
export function isNewUserBackupBannerVisible(): boolean { |
||||||
|
if (typeof sessionStorage === 'undefined') return false |
||||||
|
return sessionStorage.getItem(NEW_USER_BACKUP_BANNER_KEY) === '1' |
||||||
|
} |
||||||
|
|
||||||
|
export function dismissNewUserBackupBanner(): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
sessionStorage.removeItem(NEW_USER_BACKUP_BANNER_KEY) |
||||||
|
} |
||||||
|
|
||||||
|
/** Skip heavy network hydrate while local template is written and relays publish in background. */ |
||||||
|
export function markFreshSignupSkipNetworkHydrate(pubkey: string): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
sessionStorage.setItem(SKIP_NETWORK_HYDRATE_KEY, pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
export function shouldSkipNetworkHydrateForFreshSignup(pubkey: string): boolean { |
||||||
|
if (typeof sessionStorage === 'undefined') return false |
||||||
|
return sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey |
||||||
|
} |
||||||
|
|
||||||
|
export function clearFreshSignupSkipNetworkHydrate(pubkey: string): void { |
||||||
|
if (typeof sessionStorage === 'undefined') return |
||||||
|
if (sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey) { |
||||||
|
sessionStorage.removeItem(SKIP_NETWORK_HYDRATE_KEY) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { buildRandomPublishRelayCandidateList } from './random-publish-relay-pool' |
||||||
|
|
||||||
|
describe('buildRandomPublishRelayCandidateList', () => { |
||||||
|
it('fills from fallback write relays when NIP-66 list is empty', () => { |
||||||
|
const candidates = buildRandomPublishRelayCandidateList({ |
||||||
|
excludeSessionKeys: new Set(['wss://relay.user.example']), |
||||||
|
sessionBoost: [], |
||||||
|
nip66Lively: [], |
||||||
|
fallbackWriteRelays: ['wss://alpha.example', 'wss://beta.example', 'wss://gamma.example'] |
||||||
|
}) |
||||||
|
expect(candidates.length).toBeGreaterThanOrEqual(3) |
||||||
|
expect(candidates.some((u) => u.includes('alpha.example'))).toBe(true) |
||||||
|
expect(candidates.some((u) => u.includes('relay.user.example'))).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('dedupes session boost and NIP-66 entries', () => { |
||||||
|
const candidates = buildRandomPublishRelayCandidateList({ |
||||||
|
excludeSessionKeys: new Set(), |
||||||
|
sessionBoost: ['wss://same.example/'], |
||||||
|
nip66Lively: ['wss://same.example'], |
||||||
|
fallbackWriteRelays: [] |
||||||
|
}) |
||||||
|
expect(candidates).toHaveLength(1) |
||||||
|
expect(candidates[0]).toMatch(/same\.example/) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
import { RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' |
||||||
|
import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeRelayUrlByScheme } from '@/lib/url' |
||||||
|
|
||||||
|
export function normalizePublishRelayCandidate(url: string): string { |
||||||
|
return normalizeRelayUrlByScheme(normalizeAnyRelayUrl(url) || url) || url.trim() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Ordered candidate pool for optional random publish relays (NIP-66 lively, then write fallbacks). |
||||||
|
* Excludes relays already in the user's publish picker list ({@link excludeSessionKeys}). |
||||||
|
*/ |
||||||
|
export function buildRandomPublishRelayCandidateList(args: { |
||||||
|
excludeSessionKeys: ReadonlySet<string> |
||||||
|
sessionBoost: readonly string[] |
||||||
|
nip66Lively: readonly string[] |
||||||
|
fallbackWriteRelays: readonly string[] |
||||||
|
/** Upper bound on candidates before {@link pickRandomPublishRelays} narrows to {@link RANDOM_PUBLISH_RELAY_COUNT}. */ |
||||||
|
maxCandidates?: number |
||||||
|
}): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
const max = args.maxCandidates ?? RANDOM_PUBLISH_RELAY_COUNT * 8 |
||||||
|
|
||||||
|
const push = (raw: string) => { |
||||||
|
if (out.length >= max) return |
||||||
|
const normalized = normalizePublishRelayCandidate(raw) |
||||||
|
const key = canonicalRelaySessionKey(normalized) |
||||||
|
if (!key || args.excludeSessionKeys.has(key) || seen.has(key)) return |
||||||
|
seen.add(key) |
||||||
|
out.push(normalized) |
||||||
|
} |
||||||
|
|
||||||
|
for (const u of args.sessionBoost) push(u) |
||||||
|
for (const u of args.nip66Lively) push(u) |
||||||
|
if (out.length < RANDOM_PUBLISH_RELAY_COUNT) { |
||||||
|
for (const u of args.fallbackWriteRelays) push(u) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue