22 changed files with 640 additions and 515 deletions
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Loader } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function NpubLogin({ |
||||
back, |
||||
onLoginSuccess |
||||
}: { |
||||
back: () => void |
||||
onLoginSuccess: () => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { npubLogin } = useNostr() |
||||
const [pending, setPending] = useState(false) |
||||
const [npubInput, setNpubInput] = useState('') |
||||
const [errMsg, setErrMsg] = useState<string | null>(null) |
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setNpubInput(e.target.value) |
||||
setErrMsg(null) |
||||
} |
||||
|
||||
const handleLogin = () => { |
||||
if (npubInput === '') return |
||||
|
||||
setPending(true) |
||||
npubLogin(npubInput) |
||||
.then(() => onLoginSuccess()) |
||||
.catch((err) => setErrMsg(err.message)) |
||||
.finally(() => setPending(false)) |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<div className="space-y-1"> |
||||
<Input |
||||
placeholder="npub..." |
||||
value={npubInput} |
||||
onChange={handleInputChange} |
||||
className={errMsg ? 'border-destructive' : ''} |
||||
/> |
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>} |
||||
</div> |
||||
<Button onClick={handleLogin} disabled={pending}> |
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} /> |
||||
{t('Login')} |
||||
</Button> |
||||
<Button variant="secondary" onClick={back}> |
||||
{t('Back')} |
||||
</Button> |
||||
</> |
||||
) |
||||
} |
||||
@ -1,247 +0,0 @@
@@ -1,247 +0,0 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogHeader, |
||||
DialogTitle, |
||||
DialogTrigger |
||||
} from '@/components/ui/dialog' |
||||
import { |
||||
Drawer, |
||||
DrawerContent, |
||||
DrawerDescription, |
||||
DrawerHeader, |
||||
DrawerTitle, |
||||
DrawerTrigger |
||||
} from '@/components/ui/drawer' |
||||
import { toProfile } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import client from '@/services/client.service' |
||||
import { TMailboxRelay } from '@/types' |
||||
import { ChevronDown, Circle, CircleCheck, ScanSearch } from 'lucide-react' |
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import RelayIcon from '../RelayIcon' |
||||
import { SimpleUserAvatar } from '../UserAvatar' |
||||
import { SimpleUsername } from '../Username' |
||||
|
||||
export default function CalculateOptimalReadRelaysButton({ |
||||
mergeRelays |
||||
}: { |
||||
mergeRelays: (newRelays: TMailboxRelay[]) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const { pubkey } = useNostr() |
||||
const [open, setOpen] = useState(false) |
||||
|
||||
const trigger = ( |
||||
<Button variant="secondary" className="w-full" disabled={!pubkey}> |
||||
<ScanSearch /> |
||||
{t('Calculate optimal read relays')} |
||||
</Button> |
||||
) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<Drawer open={open} onOpenChange={setOpen}> |
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger> |
||||
<DrawerContent className="max-h-[90vh]"> |
||||
<div className="flex flex-col p-4 gap-4 overflow-auto"> |
||||
<DrawerHeader> |
||||
<DrawerTitle>{t('Select relays to append')}</DrawerTitle> |
||||
<DrawerDescription className="hidden" /> |
||||
</DrawerHeader> |
||||
<OptimalReadRelays close={() => setOpen(false)} mergeRelays={mergeRelays} /> |
||||
</div> |
||||
</DrawerContent> |
||||
</Drawer> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={setOpen}> |
||||
<DialogTrigger asChild>{trigger}</DialogTrigger> |
||||
<DialogContent className="max-h-[90vh] overflow-auto"> |
||||
<DialogHeader> |
||||
<DialogTitle>{t('Select relays to append')}</DialogTitle> |
||||
<DialogDescription className="hidden" /> |
||||
</DialogHeader> |
||||
<OptimalReadRelays close={() => setOpen(false)} mergeRelays={mergeRelays} /> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
|
||||
function OptimalReadRelays({ |
||||
close, |
||||
mergeRelays |
||||
}: { |
||||
close: () => void |
||||
mergeRelays: (newRelays: TMailboxRelay[]) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const [isCalculating, setIsCalculating] = useState(false) |
||||
const [optimalReadRelays, setOptimalReadRelays] = useState<{ url: string; pubkeys: string[] }[]>( |
||||
[] |
||||
) |
||||
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([]) |
||||
|
||||
useEffect(() => { |
||||
if (!pubkey) return |
||||
|
||||
const init = async () => { |
||||
setIsCalculating(true) |
||||
const relays = await client.calculateOptimalReadRelays(pubkey) |
||||
setOptimalReadRelays(relays) |
||||
setIsCalculating(false) |
||||
} |
||||
init() |
||||
}, []) |
||||
|
||||
if (isCalculating) { |
||||
return <div className="text-center text-sm text-muted-foreground">{t('calculating...')}</div> |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-4"> |
||||
<div className="space-y-2"> |
||||
{optimalReadRelays.map((relay) => ( |
||||
<RelayItem |
||||
key={relay.url} |
||||
relay={relay} |
||||
close={close} |
||||
selectedRelayUrls={selectedRelayUrls} |
||||
setSelectedRelayUrls={setSelectedRelayUrls} |
||||
/> |
||||
))} |
||||
</div> |
||||
<Button |
||||
disabled={selectedRelayUrls.length === 0} |
||||
className="w-full" |
||||
onClick={() => { |
||||
mergeRelays( |
||||
selectedRelayUrls.map((url) => ({ |
||||
url, |
||||
scope: 'read' |
||||
})) |
||||
) |
||||
close() |
||||
}} |
||||
> |
||||
{selectedRelayUrls.length === 0 |
||||
? t('Append') |
||||
: t('Append n relays', { n: selectedRelayUrls.length })} |
||||
</Button> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function RelayItem({ |
||||
relay, |
||||
close, |
||||
selectedRelayUrls, |
||||
setSelectedRelayUrls |
||||
}: { |
||||
relay: { url: string; pubkeys: string[] } |
||||
close: () => void |
||||
selectedRelayUrls: string[] |
||||
setSelectedRelayUrls: Dispatch<SetStateAction<string[]>> |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { push } = useSecondaryPage() |
||||
const [expanded, setExpanded] = useState(false) |
||||
|
||||
const selected = selectedRelayUrls.includes(relay.url) |
||||
|
||||
return ( |
||||
<div |
||||
className={`rounded-lg p-4 border select-none cursor-pointer ${selected ? 'border-highlight bg-highlight/5' : ''}`} |
||||
onClick={() => |
||||
setSelectedRelayUrls((pre) => |
||||
pre.includes(relay.url) ? pre.filter((url) => url !== relay.url) : [...pre, relay.url] |
||||
) |
||||
} |
||||
> |
||||
<div className="flex items-center justify-between"> |
||||
<div className="flex items-center gap-2 flex-1 w-0"> |
||||
<SelectToggle |
||||
select={selectedRelayUrls.includes(relay.url)} |
||||
setSelect={(select) => { |
||||
setSelectedRelayUrls((prev) => |
||||
select ? [...prev, relay.url] : prev.filter((url) => url !== relay.url) |
||||
) |
||||
}} |
||||
/> |
||||
<RelayIcon url={relay.url} className="h-8 w-8" /> |
||||
<div className="font-semibold truncate text-lg">{relay.url}</div> |
||||
</div> |
||||
<div |
||||
className="flex items-center cursor-pointer gap-1 text-muted-foreground hover:text-foreground text-sm" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
setExpanded((prev) => !prev) |
||||
}} |
||||
> |
||||
<div> |
||||
{relay.pubkeys.length} {t('followings')} |
||||
</div> |
||||
<ChevronDown |
||||
size={16} |
||||
className={`transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`} |
||||
/> |
||||
</div> |
||||
</div> |
||||
{expanded && ( |
||||
<div className="space-y-2 pt-2 pl-7"> |
||||
{relay.pubkeys.map((pubkey) => ( |
||||
<div |
||||
key={pubkey} |
||||
className="flex cursor-pointer items-center gap-2" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
close() |
||||
push(toProfile(pubkey)) |
||||
}} |
||||
> |
||||
<SimpleUserAvatar userId={pubkey} size="small" /> |
||||
<SimpleUsername userId={pubkey} className="font-semibold truncate" /> |
||||
</div> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function SelectToggle({ |
||||
select, |
||||
setSelect |
||||
}: { |
||||
select: boolean |
||||
setSelect: (select: boolean) => void |
||||
}) { |
||||
return select ? ( |
||||
<CircleCheck |
||||
size={18} |
||||
className="text-highlight shrink-0 cursor-pointer" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
setSelect(false) |
||||
}} |
||||
/> |
||||
) : ( |
||||
<Circle |
||||
size={18} |
||||
className="shrink-0 cursor-pointer" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
setSelect(true) |
||||
}} |
||||
/> |
||||
) |
||||
} |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { ISigner } from '@/types' |
||||
import { nip19 } from 'nostr-tools' |
||||
|
||||
export class NpubSigner implements ISigner { |
||||
private pubkey: string | null = null |
||||
|
||||
login(npub: string) { |
||||
const { type, data } = nip19.decode(npub) |
||||
if (type !== 'npub') { |
||||
throw new Error('invalid nsec') |
||||
} |
||||
this.pubkey = data |
||||
return this.pubkey |
||||
} |
||||
|
||||
async getPublicKey() { |
||||
if (!this.pubkey) { |
||||
throw new Error('Not logged in') |
||||
} |
||||
return this.pubkey |
||||
} |
||||
|
||||
async signEvent(): Promise<any> { |
||||
throw new Error('Not logged in') |
||||
} |
||||
|
||||
async nip04Encrypt(): Promise<any> { |
||||
throw new Error('Not logged in') |
||||
} |
||||
|
||||
async nip04Decrypt(): Promise<any> { |
||||
throw new Error('Not logged in') |
||||
} |
||||
} |
||||
Loading…
Reference in new issue