feat: login (#2)
This commit is contained in:
@@ -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'>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
80
src/main/services/nostr.service.ts
Normal file
80
src/main/services/nostr.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/preload/index.d.ts
vendored
15
src/preload/index.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
115
src/renderer/src/layouts/PrimaryPageLayout/AccountButton.tsx
Normal file
115
src/renderer/src/layouts/PrimaryPageLayout/AccountButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
68
src/renderer/src/providers/NostrProvider.tsx
Normal file
68
src/renderer/src/providers/NostrProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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], {
|
||||
|
||||
Reference in New Issue
Block a user