27 changed files with 382 additions and 86 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
import { TitlebarButton } from '@renderer/components/Titlebar' |
import { Button } from '@renderer/components/ui/button' |
||||||
import { usePrimaryPage } from '@renderer/PageManager' |
import { usePrimaryPage } from '@renderer/PageManager' |
||||||
import { RefreshCcw } from 'lucide-react' |
import { RefreshCcw } from 'lucide-react' |
||||||
|
|
||||||
export default function RefreshButton() { |
export default function RefreshButton() { |
||||||
const { refresh } = usePrimaryPage() |
const { refresh } = usePrimaryPage() |
||||||
return ( |
return ( |
||||||
<TitlebarButton onClick={refresh} title="reload"> |
<Button variant="titlebar" size="titlebar" onClick={refresh} title="reload"> |
||||||
<RefreshCcw /> |
<RefreshCcw /> |
||||||
</TitlebarButton> |
</Button> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -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