feat: integrate nstart (#33)

This commit is contained in:
Cody Tseng
2025-01-29 15:32:26 +08:00
committed by GitHub
parent 7daa566cec
commit a264b747e7
15 changed files with 154 additions and 47 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ dist
dist-ssr dist-ssr
dev-dist dev-dist
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

6
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"nstart-modal": "^0.2.0",
"path-to-regexp": "^8.2.0", "path-to-regexp": "^8.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -7011,6 +7012,11 @@
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"optional": true "optional": true
}, },
"node_modules/nstart-modal": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/nstart-modal/-/nstart-modal-0.2.0.tgz",
"integrity": "sha512-rfgsSGjakAUud3Csy8xWQqjFPATvXzUfebJM4kpWbc4ljABqW0STKBYnwr7TJ5bXftWZi/X9TnSug3nziX+vhw=="
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -45,6 +45,7 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"nstart-modal": "^0.2.0",
"path-to-regexp": "^8.2.0", "path-to-regexp": "^8.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -105,10 +105,19 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
const onPopState = (e: PopStateEvent) => { const onPopState = (e: PopStateEvent) => {
const state = e.state ?? { index: -1, url: '/' } let state = e.state as { index: number; url: string } | null
setSecondaryStack((pre) => { setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1] as TStackItem | undefined const currentItem = pre[pre.length - 1] as TStackItem | undefined
const currentIndex = currentItem?.index const currentIndex = currentItem?.index
if (!state) {
if (window.location.pathname + window.location.search + window.location.hash !== '/') {
// Just change the URL
return pre
} else {
// Back to root
state = { index: -1, url: '/' }
}
}
// Go forward // Go forward
if (currentIndex === undefined || state.index > currentIndex) { if (currentIndex === undefined || state.index > currentIndex) {
@@ -124,7 +133,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
// Go back // Go back
const newStack = pre.filter((item) => item.index <= state.index) const newStack = pre.filter((item) => item.index <= state!.index)
const topItem = newStack[newStack.length - 1] as TStackItem | undefined const topItem = newStack[newStack.length - 1] as TStackItem | undefined
if (!topItem) { if (!topItem) {
// Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty) // Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)

View File

@@ -9,12 +9,18 @@ import { useState } from 'react'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) { export default function AccountList({
className,
afterSwitch
}: {
className?: string
afterSwitch: () => void
}) {
const { accounts, account, switchAccount } = useNostr() const { accounts, account, switchAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null) const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return ( return (
<div className="space-y-2"> <div className={cn('space-y-2', className)}>
{accounts.map((act) => ( {accounts.map((act) => (
<div <div
key={`${act.pubkey}-${act.signerType}`} key={`${act.pubkey}-${act.signerType}`}

View File

@@ -1,12 +1,13 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList' import AccountList from '../AccountList'
import BunkerLogin from './BunkerLogin' import BunkerLogin from './BunkerLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
import GenerateNewAccount from './GenerateNewAccount' import GenerateNewAccount from './GenerateNewAccount'
import PrivateKeyLogin from './PrivateKeyLogin'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
@@ -36,38 +37,74 @@ function AccountManagerNav({
close?: () => void close?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { nip07Login, accounts } = useNostr() const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
return ( return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-4"> <div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
<div className="text-center text-muted-foreground text-sm font-semibold"> <div>
{t('Add an Account')} <div className="text-center text-muted-foreground text-sm font-semibold">
{t('Add an Account')}
</div>
<div className="space-y-2 mt-4">
{!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
</div>
</div> </div>
{!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
<Separator /> <Separator />
<div className="text-center text-muted-foreground text-sm font-semibold"> <div>
{t("Don't have an account yet?")} <div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://start.njump.me',
an: 'Jumble',
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Signup with Nstart wizard')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or generate your private key here')}
</Button>
</div> </div>
<Button variant="secondary" onClick={() => setPage('generate')} className="w-full">
{t('Generate New Account')}
</Button>
{accounts.length > 0 && ( {accounts.length > 0 && (
<> <>
<Separator /> <Separator />
<div className="text-center text-muted-foreground text-sm font-semibold"> <div>
{t('Logged in Accounts')} <div className="text-center text-muted-foreground text-sm font-semibold">
{t('Logged in Accounts')}
</div>
<AccountList className="mt-4" afterSwitch={() => close?.()} />
</div> </div>
<AccountList afterSwitch={() => close?.()} />
</> </>
)} )}
</div> </div>

View File

@@ -1,9 +1,11 @@
import PostEditor from '@/components/PostEditor' import PostEditor from '@/components/PostEditor'
import { useNostr } from '@/providers/NostrProvider'
import { PencilLine } from 'lucide-react' import { PencilLine } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function PostButton() { export default function PostButton() {
const { checkLogin } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@@ -11,7 +13,9 @@ export default function PostButton() {
<BottomNavigationBarItem <BottomNavigationBarItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setOpen(true) checkLogin(() => {
setOpen(true)
})
}} }}
> >
<PencilLine /> <PencilLine />

View File

@@ -1,10 +1,4 @@
import { import { Dialog, DialogContent } from '@/components/ui/dialog'
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Dispatch } from 'react' import { Dispatch } from 'react'
@@ -33,11 +27,7 @@ export default function LoginDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[520px] max-h-[90vh] overflow-auto"> <DialogContent className="w-[520px] max-h-[90vh] py-8 overflow-auto">
<DialogHeader>
<DialogTitle className="hidden" />
<DialogDescription className="hidden" />
</DialogHeader>
<AccountManager close={() => setOpen(false)} /> <AccountManager close={() => setOpen(false)} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,3 +1,4 @@
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@@ -8,6 +9,7 @@ import { formatCount } from './utils'
export default function ReplyButton({ event }: { event: Event }) { export default function ReplyButton({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { checkLogin } = useNostr()
const { noteStatsMap } = useNoteStats() const { noteStatsMap } = useNoteStats()
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id]) const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -18,7 +20,9 @@ export default function ReplyButton({ event }: { event: Event }) {
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400" className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setOpen(true) checkLogin(() => {
setOpen(true)
})
}} }}
title={t('Reply')} title={t('Reply')}
> >

View File

@@ -94,7 +94,14 @@ export default function RepostButton({
<DropdownMenuItem onClick={repost} disabled={!canRepost}> <DropdownMenuItem onClick={repost} disabled={!canRepost}>
<Repeat /> {t('Repost')} <Repeat /> {t('Repost')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsPostDialogOpen(true)}> <DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
>
<PencilLine /> {t('Quote')} <PencilLine /> {t('Quote')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -1,5 +1,6 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -23,6 +24,7 @@ export default function ReplyNote({
highlight?: boolean highlight?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { checkLogin } = useNostr()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
@@ -56,7 +58,7 @@ export default function ReplyNote({
className="text-muted-foreground hover:text-primary cursor-pointer" className="text-muted-foreground hover:text-primary cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setIsPostDialogOpen(true) checkLogin(() => setIsPostDialogOpen(true))
}} }}
> >
{t('reply')} {t('reply')}

View File

@@ -1,9 +1,11 @@
import PostEditor from '@/components/PostEditor' import PostEditor from '@/components/PostEditor'
import { useNostr } from '@/providers/NostrProvider'
import { PencilLine } from 'lucide-react' import { PencilLine } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function PostButton() { export default function PostButton() {
const { checkLogin } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@@ -13,7 +15,9 @@ export default function PostButton() {
description="Post" description="Post"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setOpen(true) checkLogin(() => {
setOpen(true)
})
}} }}
variant="default" variant="default"
className="bg-primary xl:justify-center gap-2" className="bg-primary xl:justify-center gap-2"

View File

@@ -132,7 +132,7 @@ export default {
'read & write relays notice': 'read & write relays notice':
'The number of read and write servers should ideally be kept between 2 and 4.', 'The number of read and write servers should ideally be kept between 2 and 4.',
"Don't have an account yet?": "Don't have an account yet?", "Don't have an account yet?": "Don't have an account yet?",
'Generate New Account': 'Generate New Account', 'or generate your private key here': 'or generate your private key here',
'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.': '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.':
'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.', 'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.',
Edit: 'Edit', Edit: 'Edit',
@@ -146,6 +146,7 @@ export default {
Back: 'Back', Back: 'Back',
'optional: encrypt nsec': 'optional: encrypt nsec', 'optional: encrypt nsec': 'optional: encrypt nsec',
password: 'password', password: 'password',
'Signup with Nstart wizard': 'Signup with Nstart wizard',
'Save to': 'Save to', 'Save to': 'Save to',
'Enter a name for the new relay set': 'Enter a name for the new relay set', 'Enter a name for the new relay set': 'Enter a name for the new relay set',
'Save to a new relay set': 'Save to a new relay set', 'Save to a new relay set': 'Save to a new relay set',

View File

@@ -132,7 +132,7 @@ export default {
'写服务器用于发布您的事件。其他用户会从您的写服务器寻找您发布的事件。', '写服务器用于发布您的事件。其他用户会从您的写服务器寻找您发布的事件。',
'read & write relays notice': '读服务器和写服务器的数量都应尽量保持在 2 到 4 个之间。', 'read & write relays notice': '读服务器和写服务器的数量都应尽量保持在 2 到 4 个之间。',
"Don't have an account yet?": '还没有账户?', "Don't have an account yet?": '还没有账户?',
'Generate New Account': '生成新账户', 'or generate your private key here': '或者直接生成私钥',
'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.': 'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.':
'这是私钥,请勿与他人分享。请妥善保管,否则将无法找回。', '这是私钥,请勿与他人分享。请妥善保管,否则将无法找回。',
Edit: '编辑', Edit: '编辑',
@@ -147,6 +147,7 @@ export default {
'password (optional): encrypt nsec': '密码 (可选): 加密 nsec', 'password (optional): encrypt nsec': '密码 (可选): 加密 nsec',
'optional: encrypt nsec': '可选: 加密 nsec', 'optional: encrypt nsec': '可选: 加密 nsec',
password: '密码', password: '密码',
'Signup with Nstart wizard': '使用 Nstart 向导注册',
'Save to': '保存到', 'Save to': '保存到',
'Enter a name for the new relay set': '输入新服务器组的名称', 'Enter a name for the new relay set': '输入新服务器组的名称',
'Save to a new relay set': '保存到新服务器组', 'Save to a new relay set': '保存到新服务器组',

View File

@@ -69,6 +69,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
if (hasNostrLoginHash()) {
return await loginByNostrLoginHash()
}
const accounts = storage.getAccounts() const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return if (!act) return
@@ -76,6 +80,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await loginWithAccountPointer(act) await loginWithAccountPointer(act)
} }
init() init()
const handleHashChange = () => {
if (hasNostrLoginHash()) {
loginByNostrLoginHash()
}
}
window.addEventListener('hashchange', handleHashChange)
return () => {
window.removeEventListener('hashchange', handleHashChange)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -138,6 +154,24 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
}, [account]) }, [account])
const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
}
const loginByNostrLoginHash = async () => {
const credential = window.location.hash.replace('#nostr-login=', '')
const urlWithoutHash = window.location.href.split('#')[0]
history.replaceState(null, '', urlWithoutHash)
if (credential.startsWith('bunker://')) {
return await bunkerLogin(credential)
} else if (credential.startsWith('ncryptsec')) {
return await ncryptsecLogin(credential)
} else if (credential.startsWith('nsec')) {
return await nsecLogin(credential)
}
}
const login = (signer: ISigner, act: TAccount) => { const login = (signer: ISigner, act: TAccount) => {
storage.addAccount(act) storage.addAccount(act)
storage.switchAccount(act) storage.switchAccount(act)