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 = { export type TRelayGroup = {
groupName: string groupName: string
relayUrls: string[] relayUrls: string[]
@@ -11,3 +13,5 @@ export type TConfig = {
export type TThemeSetting = 'light' | 'dark' | 'system' export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark' 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 { 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 { join } from 'path'
import icon from '../../resources/icon.png?asset' 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 { ThemeService } from './services/theme.service'
import { TSendToRenderer } from './types' import { TSendToRenderer } from './types'
import { StorageService } from './services/storage.service'
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -73,6 +74,11 @@ app.whenReady().then(async () => {
const themeService = new ThemeService(storageService, sendToRenderer) const themeService = new ThemeService(storageService, sendToRenderer)
themeService.init() themeService.init()
const nostrService = new NostrService()
nostrService.init()
ipcMain.handle('system:isEncryptionAvailable', () => safeStorage.isEncryptionAvailable())
createWindow() createWindow()
app.on('activate', function () { 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 { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools'
declare global { declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronAPI
api: { api: {
system: {
isEncryptionAvailable: () => Promise<boolean>
}
theme: { theme: {
onChange: (cb: (theme: TTheme) => void) => void onChange: (cb: (theme: TTheme) => void) => void
current: () => Promise<TTheme> current: () => Promise<TTheme>
@@ -15,6 +19,15 @@ declare global {
getRelayGroups: () => Promise<TRelayGroup[]> getRelayGroups: () => Promise<TRelayGroup[]>
setRelayGroups: (relayGroups: TRelayGroup[]) => Promise<void> 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 { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
system: {
isEncryptionAvailable: () => ipcRenderer.invoke('system:isEncryptionAvailable')
},
theme: { theme: {
onChange: (cb: (theme: 'dark' | 'light') => void) => { onChange: (cb: (theme: 'dark' | 'light') => void) => {
ipcRenderer.on('theme:change', (_, theme) => { ipcRenderer.on('theme:change', (_, theme) => {
@@ -18,6 +21,12 @@ const api = {
getRelayGroups: () => ipcRenderer.invoke('storage:getRelayGroups'), getRelayGroups: () => ipcRenderer.invoke('storage:getRelayGroups'),
setRelayGroups: (relayGroups: TRelayGroup[]) => setRelayGroups: (relayGroups: TRelayGroup[]) =>
ipcRenderer.invoke('storage:setRelayGroups', relayGroups) 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 HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import { NostrProvider } from './providers/NostrProvider'
import { RelaySettingsProvider } from './providers/RelaySettingsProvider' import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
const routes = [ const routes = [
@@ -20,12 +21,14 @@ export default function App(): JSX.Element {
return ( return (
<div className="h-screen"> <div className="h-screen">
<ThemeProvider> <ThemeProvider>
<NostrProvider>
<RelaySettingsProvider> <RelaySettingsProvider>
<PageManager routes={routes}> <PageManager routes={routes}>
<NoteListPage /> <NoteListPage />
</PageManager> </PageManager>
<Toaster /> <Toaster />
</RelaySettingsProvider> </RelaySettingsProvider>
</NostrProvider>
</ThemeProvider> </ThemeProvider>
</div> </div>
) )

View File

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

View File

@@ -111,7 +111,9 @@ export default function NoteList({
<> <>
{newEvents.length > 0 && ( {newEvents.length > 0 && (
<div className="flex justify-center w-full mb-4"> <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>
)} )}
<div className={cn('flex flex-col gap-4', className)}> <div className={cn('flex flex-col gap-4', className)}>

View File

@@ -95,9 +95,9 @@ function RelayGroupName({ groupName }: { groupName: string }) {
onChange={handleRenameInputChange} onChange={handleRenameInputChange}
onBlur={saveNewGroupName} onBlur={saveNewGroupName}
onKeyDown={handleRenameInputKeyDown} 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" /> <Check size={18} className="text-green-500" />
</Button> </Button>
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>} {newNameError && <div className="text-xs text-destructive">{newNameError}</div>}
@@ -145,11 +145,10 @@ function RelayGroupOptions({ groupName }: { groupName: string }) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<EllipsisVertical <Button variant="ghost" size="icon">
size={16} <EllipsisVertical />
className="text-muted-foreground hover:text-accent-foreground cursor-pointer" </Button>
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem> <DropdownMenuItem onClick={() => setRenamingGroup(groupName)}>Rename</DropdownMenuItem>

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { Button } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
export function Titlebar({ export function Titlebar({
@@ -19,28 +18,3 @@ export function Titlebar({
</div> </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', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-muted/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight', 'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline' link: 'text-primary underline-offset-4 hover:underline',
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-8 rounded-md px-2',
sm: 'h-9 rounded-md px-3', sm: 'h-8 rounded-md px-2',
lg: 'h-11 rounded-md px-8', lg: 'h-10 px-4 py-2',
icon: 'h-10 w-10', icon: 'h-8 w-8 rounded-full',
xs: 'h-7 w-7 p-0 rounded-full' titlebar: 'h-7 w-7 rounded-full'
} }
}, },
defaultVariants: { defaultVariants: {

View File

@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

@@ -4,6 +4,6 @@ import { TEmbeddedRenderer } from './types'
export const embeddedHashtagRenderer: TEmbeddedRenderer = { export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([^\s#]+)/g, regex: /#([^\s#]+)/g,
render: (hashtag: string, index: number) => { 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 = { export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g, regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g,
render: (url: string, index: number) => { 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, regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => { render: (id: string, index: number) => {
const npub1 = id.split(':')[1] 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, regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => { render: (id: string, index: number) => {
const nprofile = id.split(':')[1] 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 = { export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g, regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => { 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 { 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>
) )
} }

View File

@@ -1,4 +1,5 @@
import RelaySettings from '@renderer/components/RelaySettings' import RelaySettings from '@renderer/components/RelaySettings'
import { Button } from '@renderer/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
import { ScrollArea } from '@renderer/components/ui/scroll-area' import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
@@ -6,11 +7,10 @@ import { Server } from 'lucide-react'
export default function RelaySettingsPopover() { export default function RelaySettingsPopover() {
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger asChild>
className="non-draggable h-7 w-7 p-0 rounded-full flex items-center justify-center hover:bg-accent hover:text-accent-foreground" <Button variant="titlebar" size="titlebar" title="relay settings">
title="relay settings" <Server />
> </Button>
<Server size={16} className="text-foreground" />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0"> <PopoverContent className="w-96 h-[450px] p-0">
<ScrollArea className="h-full"> <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 { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform' import { isMacOS } from '@renderer/lib/platform'
import { forwardRef, useImperativeHandle, useRef } from 'react' import { forwardRef, useImperativeHandle, useRef } from 'react'
import RelaySettingsPopover from './RelaySettingsPopover' import AccountButton from './AccountButton'
import RefreshButton from './RefreshButton' import RefreshButton from './RefreshButton'
import RelaySettingsPopover from './RelaySettingsPopover'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@@ -46,7 +47,10 @@ export type TPrimaryPageLayoutRef = {
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) { export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
return ( return (
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}> <Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
<div>{content}</div> <div className="flex gap-1">
<AccountButton />
{content}
</div>
<div className="flex gap-1"> <div className="flex gap-1">
<RefreshButton /> <RefreshButton />
<RelaySettingsPopover /> <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 { useSecondaryPage } from '@renderer/PageManager'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
@@ -8,9 +8,9 @@ export default function BackButton({ hide = false }: { hide?: boolean }) {
return ( return (
<> <>
{!hide && ( {!hide && (
<TitlebarButton onClick={() => pop()}> <Button variant="titlebar" size="titlebar" title="back" onClick={() => pop()}>
<ChevronLeft /> <ChevronLeft />
</TitlebarButton> </Button>
)} )}
</> </>
) )

View File

@@ -1,5 +1,5 @@
import { Button } from '@renderer/components/ui/button'
import { useTheme } from '@renderer/providers/ThemeProvider' import { useTheme } from '@renderer/providers/ThemeProvider'
import { TitlebarButton } from '@renderer/components/Titlebar'
import { Moon, Sun, SunMoon } from 'lucide-react' import { Moon, Sun, SunMoon } from 'lucide-react'
export default function ThemeToggle() { export default function ThemeToggle() {
@@ -8,17 +8,32 @@ export default function ThemeToggle() {
return ( return (
<> <>
{themeSetting === 'system' ? ( {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 /> <SunMoon />
</TitlebarButton> </Button>
) : themeSetting === 'light' ? ( ) : 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 /> <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 /> <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() return this.pool.listConnectionStatus()
} }
async publishEvent(event: NEvent) {
// TODO: outbox
return await Promise.any(this.pool.publish(this.relayUrls, event))
}
subscribeEvents( subscribeEvents(
urls: string[], urls: string[],
filter: Filter, filter: Filter,
@@ -82,7 +87,6 @@ class ClientService {
onNew: (evt: NEvent) => void onNew: (evt: NEvent) => void
} }
) { ) {
console.log('subscribeEvents', urls, filter)
const events: NEvent[] = [] const events: NEvent[] = []
let eose = false let eose = false
return this.pool.subscribeMany(urls, [filter], { return this.pool.subscribeMany(urls, [filter], {