feat: login (#2)

This commit is contained in:
Cody Tseng
2024-11-04 22:59:09 +08:00
committed by GitHub
parent 199b44d280
commit a7cf6dc5e8
27 changed files with 382 additions and 86 deletions

View File

@@ -1,3 +1,5 @@
import { Event } from 'nostr-tools'
export type TRelayGroup = {
groupName: string
relayUrls: string[]
@@ -11,3 +13,5 @@ export type TConfig = {
export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>

View File

@@ -1,10 +1,11 @@
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
import { join } from 'path'
import icon from '../../resources/icon.png?asset'
import { NostrService } from './services/nostr.service'
import { StorageService } from './services/storage.service'
import { ThemeService } from './services/theme.service'
import { TSendToRenderer } from './types'
import { StorageService } from './services/storage.service'
let mainWindow: BrowserWindow | null = null
@@ -73,6 +74,11 @@ app.whenReady().then(async () => {
const themeService = new ThemeService(storageService, sendToRenderer)
themeService.init()
const nostrService = new NostrService()
nostrService.init()
ipcMain.handle('system:isEncryptionAvailable', () => safeStorage.isEncryptionAvailable())
createWindow()
app.on('activate', function () {

View File

@@ -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
}
}
}

View File

@@ -1,10 +1,14 @@
import { TRelayGroup, TTheme, TThemeSetting } from '@common/types'
import { TDraftEvent, TRelayGroup, TTheme, TThemeSetting } from '@common/types'
import { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools'
declare global {
interface Window {
electron: ElectronAPI
api: {
system: {
isEncryptionAvailable: () => Promise<boolean>
}
theme: {
onChange: (cb: (theme: TTheme) => void) => void
current: () => Promise<TTheme>
@@ -15,6 +19,15 @@ declare global {
getRelayGroups: () => Promise<TRelayGroup[]>
setRelayGroups: (relayGroups: TRelayGroup[]) => Promise<void>
}
nostr: {
login: (nsec: string) => Promise<{
pubkey?: string
reason?: string
}>
logout: () => Promise<void>
getPublicKey: () => Promise<string | null>
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
}
}
}
}

View File

@@ -1,9 +1,12 @@
import { TRelayGroup, TThemeSetting } from '@common/types'
import { TDraftEvent, TRelayGroup, TThemeSetting } from '@common/types'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'
// Custom APIs for renderer
const api = {
system: {
isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable')
},
theme: {
onChange: (cb: (theme: 'dark' | 'light') => void) => {
ipcRenderer.on('theme:change', (_, theme) => {
@@ -18,6 +21,12 @@ const api = {
getRelayGroups: () => ipcRenderer.invoke('storage:getRelayGroups'),
setRelayGroups: (relayGroups: TRelayGroup[]) =>
ipcRenderer.invoke('storage:setRelayGroups', relayGroups)
},
nostr: {
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
logout: () => ipcRenderer.invoke('nostr:logout'),
getPublicKey: () => ipcRenderer.invoke('nostr:getPublicKey'),
signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent)
}
}

View File

@@ -8,6 +8,7 @@ import NoteListPage from './pages/primary/NoteListPage'
import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage'
import { NostrProvider } from './providers/NostrProvider'
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
const routes = [
@@ -20,12 +21,14 @@ export default function App(): JSX.Element {
return (
<div className="h-screen">
<ThemeProvider>
<RelaySettingsProvider>
<PageManager routes={routes}>
<NoteListPage />
</PageManager>
<Toaster />
</RelaySettingsProvider>
<NostrProvider>
<RelaySettingsProvider>
<PageManager routes={routes}>
<NoteListPage />
</PageManager>
<Toaster />
</RelaySettingsProvider>
</NostrProvider>
</ThemeProvider>
</div>
)

View File

@@ -37,7 +37,7 @@ const Content = memo(
nodes.push(
<ImageGallery
className="mt-2 w-fit"
key="images"
key={`image-gallery-${event.id}`}
images={images}
isNsfw={isNsfw}
size={size}
@@ -51,7 +51,7 @@ const Content = memo(
nodes.push(
<VideoPlayer
className="mt-2"
key={`video-${index}`}
key={`video-${index}-${src}`}
src={src}
isNsfw={isNsfw}
size={size}

View File

@@ -111,7 +111,9 @@ export default function NoteList({
<>
{newEvents.length > 0 && (
<div className="flex justify-center w-full mb-4">
<Button onClick={showNewEvents}>show new notes</Button>
<Button size="lg" onClick={showNewEvents}>
show new notes
</Button>
</div>
)}
<div className={cn('flex flex-col gap-4', className)}>

View File

@@ -95,9 +95,9 @@ function RelayGroupName({ groupName }: { groupName: string }) {
onChange={handleRenameInputChange}
onBlur={saveNewGroupName}
onKeyDown={handleRenameInputKeyDown}
className={`font-semibold w-24 h-8 ${newNameError ? 'border-destructive' : ''}`}
className={`font-semibold w-28 ${newNameError ? 'border-destructive' : ''}`}
/>
<Button variant="ghost" className="h-8 w-8" onClick={saveNewGroupName}>
<Button variant="ghost" size="icon" onClick={saveNewGroupName}>
<Check size={18} className="text-green-500" />
</Button>
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>}
@@ -145,11 +145,10 @@ function RelayGroupOptions({ groupName }: { groupName: string }) {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisVertical
size={16}
className="text-muted-foreground hover:text-accent-foreground cursor-pointer"
/>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem>

View File

@@ -42,6 +42,7 @@ export default function RelayUrls({ groupName }: { groupName: string }) {
}
const saveNewRelayUrl = () => {
if (newRelayUrl === '') return
const normalizedUrl = normalizeURL(newRelayUrl)
if (relays.some(({ url }) => url === normalizedUrl)) {
return setNewRelayUrlError('already exists')
@@ -82,16 +83,14 @@ export default function RelayUrls({ groupName }: { groupName: string }) {
</div>
<div className="mt-2 flex gap-2">
<Input
className={`h-8 ${newRelayUrlError ? 'border-destructive' : ''}`}
className={newRelayUrlError ? 'border-destructive' : ''}
placeholder="Add new relay URL"
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl}
/>
<Button className="h-8 w-12" onClick={saveNewRelayUrl}>
Add
</Button>
<Button onClick={saveNewRelayUrl}>Add</Button>
</div>
{newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>}
</>

View File

@@ -56,14 +56,14 @@ export default function RelaySettings() {
</div>
<div className="mt-2 flex gap-2">
<Input
className={`h-8 ${newNameError ? 'border-destructive' : ''}`}
className={newNameError ? 'border-destructive' : ''}
placeholder="Group name"
value={newGroupName}
onChange={handleNewGroupNameChange}
onKeyDown={handleNewGroupNameKeyDown}
onBlur={saveRelayGroup}
/>
<Button className="h-8 w-12">Add</Button>
<Button>Add</Button>
</div>
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
</div>

View File

@@ -1,4 +1,3 @@
import { Button } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
export function Titlebar({
@@ -19,28 +18,3 @@ export function Titlebar({
</div>
)
}
export function TitlebarButton({
onClick,
disabled,
children,
title
}: {
onClick?: () => void
disabled?: boolean
children: React.ReactNode
title?: string
}) {
return (
<Button
className="non-draggable"
variant="ghost"
size="xs"
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</Button>
)
}

View File

@@ -14,15 +14,16 @@ const buttonVariants = cva(
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
xs: 'h-7 w-7 p-0 rounded-full'
default: 'h-8 rounded-md px-2',
sm: 'h-8 rounded-md px-2',
lg: 'h-10 px-4 py-2',
icon: 'h-8 w-8 rounded-full',
titlebar: 'h-7 w-7 rounded-full'
}
},
defaultVariants: {

View File

@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@@ -4,6 +4,6 @@ import { TEmbeddedRenderer } from './types'
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([^\s#]+)/g,
render: (hashtag: string, index: number) => {
return <EmbeddedHashtag key={`hashtag-${index}`} hashtag={hashtag} />
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
}
}

View File

@@ -4,6 +4,6 @@ import { TEmbeddedRenderer } from './types'
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g,
render: (url: string, index: number) => {
return <EmbeddedNormalUrl key={`normal-url-${index}`} url={url} />
return <EmbeddedNormalUrl key={`normal-url-${index}-${url}`} url={url} />
}
}

View File

@@ -5,6 +5,6 @@ export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-npub-${index}`} userId={npub1} />
return <EmbeddedMention key={`embedded-nostr-npub-${index}-${npub1}`} userId={npub1} />
}
}

View File

@@ -5,6 +5,6 @@ export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-profile-${index}`} userId={nprofile} />
return <EmbeddedMention key={`embedded-nostr-profile-${index}-${nprofile}`} userId={nprofile} />
}
}

View File

@@ -4,6 +4,6 @@ import { TEmbeddedRenderer } from './types'
export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => {
return <EmbeddedMention key={`embedded-npub-${index}`} userId={npub1} />
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,4 +1,5 @@
import RelaySettings from '@renderer/components/RelaySettings'
import { Button } from '@renderer/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Server } from 'lucide-react'
@@ -6,11 +7,10 @@ import { Server } from 'lucide-react'
export default function RelaySettingsPopover() {
return (
<Popover>
<PopoverTrigger
className="non-draggable h-7 w-7 p-0 rounded-full flex items-center justify-center hover:bg-accent hover:text-accent-foreground"
title="relay settings"
>
<Server size={16} className="text-foreground" />
<PopoverTrigger asChild>
<Button variant="titlebar" size="titlebar" title="relay settings">
<Server />
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0">
<ScrollArea className="h-full">

View File

@@ -3,8 +3,9 @@ import { Titlebar } from '@renderer/components/Titlebar'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import RelaySettingsPopover from './RelaySettingsPopover'
import AccountButton from './AccountButton'
import RefreshButton from './RefreshButton'
import RelaySettingsPopover from './RelaySettingsPopover'
const PrimaryPageLayout = forwardRef(
(
@@ -46,7 +47,10 @@ export type TPrimaryPageLayoutRef = {
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
return (
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
<div>{content}</div>
<div className="flex gap-1">
<AccountButton />
{content}
</div>
<div className="flex gap-1">
<RefreshButton />
<RelaySettingsPopover />

View File

@@ -1,4 +1,4 @@
import { TitlebarButton } from '@renderer/components/Titlebar'
import { Button } from '@renderer/components/ui/button'
import { useSecondaryPage } from '@renderer/PageManager'
import { ChevronLeft } from 'lucide-react'
@@ -8,9 +8,9 @@ export default function BackButton({ hide = false }: { hide?: boolean }) {
return (
<>
{!hide && (
<TitlebarButton onClick={() => pop()}>
<Button variant="titlebar" size="titlebar" title="back" onClick={() => pop()}>
<ChevronLeft />
</TitlebarButton>
</Button>
)}
</>
)

View File

@@ -1,5 +1,5 @@
import { Button } from '@renderer/components/ui/button'
import { useTheme } from '@renderer/providers/ThemeProvider'
import { TitlebarButton } from '@renderer/components/Titlebar'
import { Moon, Sun, SunMoon } from 'lucide-react'
export default function ThemeToggle() {
@@ -8,17 +8,32 @@ export default function ThemeToggle() {
return (
<>
{themeSetting === 'system' ? (
<TitlebarButton onClick={() => setThemeSetting('light')} title="switch to light theme">
<Button
variant="titlebar"
size="titlebar"
onClick={() => setThemeSetting('light')}
title="switch to light theme"
>
<SunMoon />
</TitlebarButton>
</Button>
) : themeSetting === 'light' ? (
<TitlebarButton onClick={() => setThemeSetting('dark')} title="switch to dark theme">
<Button
variant="titlebar"
size="titlebar"
onClick={() => setThemeSetting('dark')}
title="switch to dark theme"
>
<Sun />
</TitlebarButton>
</Button>
) : (
<TitlebarButton onClick={() => setThemeSetting('system')} title="switch to system theme">
<Button
variant="titlebar"
size="titlebar"
onClick={() => setThemeSetting('system')}
title="switch to system theme"
>
<Moon />
</TitlebarButton>
</Button>
)}
</>
)

View File

@@ -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>
)
}

View File

@@ -74,6 +74,11 @@ class ClientService {
return this.pool.listConnectionStatus()
}
async publishEvent(event: NEvent) {
// TODO: outbox
return await Promise.any(this.pool.publish(this.relayUrls, event))
}
subscribeEvents(
urls: string[],
filter: Filter,
@@ -82,7 +87,6 @@ class ClientService {
onNew: (evt: NEvent) => void
}
) {
console.log('subscribeEvents', urls, filter)
const events: NEvent[] = []
let eose = false
return this.pool.subscribeMany(urls, [filter], {