27 changed files with 382 additions and 86 deletions
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
import { TDraftEvent } from '@common/types' |
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' |
||||
import { app, ipcMain, safeStorage } from 'electron' |
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs' |
||||
import { Event, finalizeEvent, getPublicKey, nip19 } from 'nostr-tools' |
||||
import { join } from 'path' |
||||
|
||||
export class NostrService { |
||||
private keyPath: string |
||||
private privkey: Uint8Array | null = null |
||||
private pubkey: string | null = null |
||||
|
||||
constructor() { |
||||
this.keyPath = join(app.getPath('userData'), 'private-key') |
||||
} |
||||
|
||||
init() { |
||||
if (existsSync(this.keyPath)) { |
||||
const data = readFileSync(this.keyPath) |
||||
const privateKey = safeStorage.decryptString(data) |
||||
this.privkey = hexToBytes(privateKey) |
||||
this.pubkey = getPublicKey(this.privkey) |
||||
} |
||||
|
||||
ipcMain.handle('nostr:login', (_, nsec: string) => this.login(nsec)) |
||||
ipcMain.handle('nostr:logout', () => this.logout()) |
||||
ipcMain.handle('nostr:getPublicKey', () => this.pubkey) |
||||
ipcMain.handle('nostr:signEvent', (_, event: Omit<Event, 'id' | 'pubkey' | 'sig'>) => |
||||
this.signEvent(event) |
||||
) |
||||
} |
||||
|
||||
private async login(nsec: string): Promise<{ |
||||
pubkey?: string |
||||
reason?: string |
||||
}> { |
||||
try { |
||||
const { type, data } = nip19.decode(nsec) |
||||
if (type !== 'nsec') { |
||||
return { |
||||
reason: 'invalid nsec' |
||||
} |
||||
} |
||||
|
||||
this.privkey = data |
||||
const encryptedPrivateKey = safeStorage.encryptString(bytesToHex(data)) |
||||
writeFileSync(this.keyPath, encryptedPrivateKey) |
||||
|
||||
this.pubkey = getPublicKey(data) |
||||
|
||||
return { |
||||
pubkey: this.pubkey |
||||
} |
||||
} catch (error) { |
||||
console.error(error) |
||||
return { |
||||
reason: error instanceof Error ? error.message : 'invalid nesc' |
||||
} |
||||
} |
||||
} |
||||
|
||||
private logout() { |
||||
rmSync(this.keyPath) |
||||
this.privkey = null |
||||
this.pubkey = null |
||||
} |
||||
|
||||
private signEvent(draftEvent: TDraftEvent) { |
||||
if (!this.privkey) { |
||||
return null |
||||
} |
||||
|
||||
try { |
||||
return finalizeEvent(draftEvent, this.privkey) |
||||
} catch (error) { |
||||
console.error(error) |
||||
return null |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' |
||||
import { Button } from '@renderer/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogHeader, |
||||
DialogTitle, |
||||
DialogTrigger |
||||
} from '@renderer/components/ui/dialog' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '@renderer/components/ui/dropdown-menu' |
||||
import { Input } from '@renderer/components/ui/input' |
||||
import { useFetchProfile } from '@renderer/hooks' |
||||
import { generateImageByPubkey } from '@renderer/lib/pubkey' |
||||
import { toProfile } from '@renderer/lib/url' |
||||
import { useSecondaryPage } from '@renderer/PageManager' |
||||
import { useNostr } from '@renderer/providers/NostrProvider' |
||||
import { LogIn } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
|
||||
export default function AccountButton() { |
||||
const { pubkey } = useNostr() |
||||
|
||||
if (pubkey) { |
||||
return <ProfileButton pubkey={pubkey} /> |
||||
} else { |
||||
return <LoginButton /> |
||||
} |
||||
} |
||||
|
||||
function ProfileButton({ pubkey }: { pubkey: string }) { |
||||
const { logout } = useNostr() |
||||
const { avatar } = useFetchProfile(pubkey) |
||||
const { push } = useSecondaryPage() |
||||
const defaultAvatar = generateImageByPubkey(pubkey) |
||||
|
||||
return ( |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger className="relative non-draggable"> |
||||
<Avatar className="w-6 h-6"> |
||||
<AvatarImage src={avatar} /> |
||||
<AvatarFallback> |
||||
<img src={defaultAvatar} /> |
||||
</AvatarFallback> |
||||
</Avatar> |
||||
<div className="absolute inset-0 hover:bg-black opacity-0 hover:opacity-20 transition-opacity rounded-full" /> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent> |
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>Profile</DropdownMenuItem> |
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}> |
||||
Logout |
||||
</DropdownMenuItem> |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
) |
||||
} |
||||
|
||||
function LoginButton() { |
||||
const { canLogin, login } = useNostr() |
||||
const [open, setOpen] = useState(false) |
||||
const [nsec, setNsec] = useState('') |
||||
const [errMsg, setErrMsg] = useState<string | null>(null) |
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
||||
setNsec(e.target.value) |
||||
setErrMsg(null) |
||||
} |
||||
|
||||
const handleLogin = () => { |
||||
if (nsec === '') return |
||||
|
||||
login(nsec).catch((err) => { |
||||
setErrMsg(err.message) |
||||
}) |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={setOpen}> |
||||
<DialogTrigger> |
||||
<Button variant="titlebar" size="titlebar"> |
||||
<LogIn /> |
||||
</Button> |
||||
</DialogTrigger> |
||||
<DialogContent className="w-80"> |
||||
<DialogHeader> |
||||
<DialogTitle>Sign in</DialogTitle> |
||||
{!canLogin && ( |
||||
<DialogDescription className="text-destructive"> |
||||
Encryption is not available in your device. |
||||
</DialogDescription> |
||||
)} |
||||
</DialogHeader> |
||||
<div className="space-y-1"> |
||||
<Input |
||||
type="password" |
||||
placeholder="nsec1.." |
||||
value={nsec} |
||||
onChange={handleInputChange} |
||||
className={errMsg ? 'border-destructive' : ''} |
||||
disabled={!canLogin} |
||||
/> |
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>} |
||||
</div> |
||||
<Button onClick={handleLogin} disabled={!canLogin}> |
||||
Login |
||||
</Button> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
|
||||
import { TitlebarButton } from '@renderer/components/Titlebar' |
||||
import { Button } from '@renderer/components/ui/button' |
||||
import { usePrimaryPage } from '@renderer/PageManager' |
||||
import { RefreshCcw } from 'lucide-react' |
||||
|
||||
export default function RefreshButton() { |
||||
const { refresh } = usePrimaryPage() |
||||
return ( |
||||
<TitlebarButton onClick={refresh} title="reload"> |
||||
<Button variant="titlebar" size="titlebar" onClick={refresh} title="reload"> |
||||
<RefreshCcw /> |
||||
</TitlebarButton> |
||||
</Button> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
import { TDraftEvent } from '@common/types' |
||||
import { createContext, useContext, useEffect, useState } from 'react' |
||||
import client from '@renderer/services/client.service' |
||||
|
||||
type TNostrContext = { |
||||
pubkey: string | null |
||||
canLogin: boolean |
||||
login: (nsec: string) => Promise<string> |
||||
logout: () => Promise<void> |
||||
publish: (draftEvent: TDraftEvent) => Promise<void> |
||||
} |
||||
|
||||
const NostrContext = createContext<TNostrContext | undefined>(undefined) |
||||
|
||||
export const useNostr = () => { |
||||
const context = useContext(NostrContext) |
||||
if (!context) { |
||||
throw new Error('useNostr must be used within a NostrProvider') |
||||
} |
||||
return context |
||||
} |
||||
|
||||
export function NostrProvider({ children }: { children: React.ReactNode }) { |
||||
const [pubkey, setPubkey] = useState<string | null>(null) |
||||
const [canLogin, setCanLogin] = useState(false) |
||||
|
||||
useEffect(() => { |
||||
window.api.nostr.getPublicKey().then((pubkey) => { |
||||
if (pubkey) { |
||||
setPubkey(pubkey) |
||||
} |
||||
}) |
||||
window.api.system.isEncryptionAvailable().then((isEncryptionAvailable) => { |
||||
setCanLogin(isEncryptionAvailable) |
||||
}) |
||||
}, []) |
||||
|
||||
const login = async (nsec: string) => { |
||||
if (!canLogin) { |
||||
throw new Error('encryption is not available') |
||||
} |
||||
const { pubkey, reason } = await window.api.nostr.login(nsec) |
||||
if (!pubkey) { |
||||
throw new Error(reason ?? 'invalid nsec') |
||||
} |
||||
setPubkey(pubkey) |
||||
return pubkey |
||||
} |
||||
|
||||
const logout = async () => { |
||||
await window.api.nostr.logout() |
||||
setPubkey(null) |
||||
} |
||||
|
||||
const publish = async (draftEvent: TDraftEvent) => { |
||||
const event = await window.api.nostr.signEvent(draftEvent) |
||||
if (!event) { |
||||
throw new Error('sign event failed') |
||||
} |
||||
await client.publishEvent(event) |
||||
} |
||||
|
||||
return ( |
||||
<NostrContext.Provider value={{ pubkey, canLogin, login, logout, publish }}> |
||||
{children} |
||||
</NostrContext.Provider> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue