diff --git a/src/common/types.ts b/src/common/types.ts index 36ee22d2..e91e3687 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 80febe94..600e66ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 () { diff --git a/src/main/services/nostr.service.ts b/src/main/services/nostr.service.ts new file mode 100644 index 00000000..4702df33 --- /dev/null +++ b/src/main/services/nostr.service.ts @@ -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) => + 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 + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fc66a373..56a2751b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + } theme: { onChange: (cb: (theme: TTheme) => void) => void current: () => Promise @@ -15,6 +19,15 @@ declare global { getRelayGroups: () => Promise setRelayGroups: (relayGroups: TRelayGroup[]) => Promise } + nostr: { + login: (nsec: string) => Promise<{ + pubkey?: string + reason?: string + }> + logout: () => Promise + getPublicKey: () => Promise + signEvent: (draftEvent: TDraftEvent) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index a1d86a50..f03d9185 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index be5e29f3..c93e3df4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 (
- - - - - - + + + + + + + +
) diff --git a/src/renderer/src/components/Content/index.tsx b/src/renderer/src/components/Content/index.tsx index 5aa9cf3d..5c423074 100644 --- a/src/renderer/src/components/Content/index.tsx +++ b/src/renderer/src/components/Content/index.tsx @@ -37,7 +37,7 @@ const Content = memo( nodes.push( {newEvents.length > 0 && (
- +
)}
diff --git a/src/renderer/src/components/RelaySettings/RelayGroup.tsx b/src/renderer/src/components/RelaySettings/RelayGroup.tsx index c9c74880..40f3143a 100644 --- a/src/renderer/src/components/RelaySettings/RelayGroup.tsx +++ b/src/renderer/src/components/RelaySettings/RelayGroup.tsx @@ -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' : ''}`} /> - {newNameError &&
{newNameError}
} @@ -145,11 +145,10 @@ function RelayGroupOptions({ groupName }: { groupName: string }) { return ( - - + + setRenamingGroup(groupName)}>Rename diff --git a/src/renderer/src/components/RelaySettings/RelayUrl.tsx b/src/renderer/src/components/RelaySettings/RelayUrl.tsx index 2d936d8c..10787e1c 100644 --- a/src/renderer/src/components/RelaySettings/RelayUrl.tsx +++ b/src/renderer/src/components/RelaySettings/RelayUrl.tsx @@ -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 }) {
- +
{newRelayUrlError &&
{newRelayUrlError}
} diff --git a/src/renderer/src/components/RelaySettings/index.tsx b/src/renderer/src/components/RelaySettings/index.tsx index 09f20e5d..f69600d7 100644 --- a/src/renderer/src/components/RelaySettings/index.tsx +++ b/src/renderer/src/components/RelaySettings/index.tsx @@ -56,14 +56,14 @@ export default function RelaySettings() {
- +
{newNameError &&
{newNameError}
} diff --git a/src/renderer/src/components/Titlebar/index.tsx b/src/renderer/src/components/Titlebar/index.tsx index 2c8a8603..b28f95f3 100644 --- a/src/renderer/src/components/Titlebar/index.tsx +++ b/src/renderer/src/components/Titlebar/index.tsx @@ -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({ ) } - -export function TitlebarButton({ - onClick, - disabled, - children, - title -}: { - onClick?: () => void - disabled?: boolean - children: React.ReactNode - title?: string -}) { - return ( - - ) -} diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index a1cfe035..84552285 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -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: { diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx index 5e3cf657..17bc6200 100644 --- a/src/renderer/src/components/ui/input.tsx +++ b/src/renderer/src/components/ui/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( { - return + return } } diff --git a/src/renderer/src/embedded/EmbeddedNormalUrl.tsx b/src/renderer/src/embedded/EmbeddedNormalUrl.tsx index 385d0793..4bbd86d3 100644 --- a/src/renderer/src/embedded/EmbeddedNormalUrl.tsx +++ b/src/renderer/src/embedded/EmbeddedNormalUrl.tsx @@ -4,6 +4,6 @@ import { TEmbeddedRenderer } from './types' export const embeddedNormalUrlRenderer: TEmbeddedRenderer = { regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g, render: (url: string, index: number) => { - return + return } } diff --git a/src/renderer/src/embedded/EmbeddedNostrNpub.tsx b/src/renderer/src/embedded/EmbeddedNostrNpub.tsx index 52eca7fe..a9bf7476 100644 --- a/src/renderer/src/embedded/EmbeddedNostrNpub.tsx +++ b/src/renderer/src/embedded/EmbeddedNostrNpub.tsx @@ -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 + return } } diff --git a/src/renderer/src/embedded/EmbeddedNostrProfile.tsx b/src/renderer/src/embedded/EmbeddedNostrProfile.tsx index 7bd01d4d..b7bcc4b7 100644 --- a/src/renderer/src/embedded/EmbeddedNostrProfile.tsx +++ b/src/renderer/src/embedded/EmbeddedNostrProfile.tsx @@ -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 + return } } diff --git a/src/renderer/src/embedded/EmbeddedNpub.tsx b/src/renderer/src/embedded/EmbeddedNpub.tsx index f989047b..99e021f8 100644 --- a/src/renderer/src/embedded/EmbeddedNpub.tsx +++ b/src/renderer/src/embedded/EmbeddedNpub.tsx @@ -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 + return } } diff --git a/src/renderer/src/layouts/PrimaryPageLayout/AccountButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/AccountButton.tsx new file mode 100644 index 00000000..b600b216 --- /dev/null +++ b/src/renderer/src/layouts/PrimaryPageLayout/AccountButton.tsx @@ -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 + } else { + return + } +} + +function ProfileButton({ pubkey }: { pubkey: string }) { + const { logout } = useNostr() + const { avatar } = useFetchProfile(pubkey) + const { push } = useSecondaryPage() + const defaultAvatar = generateImageByPubkey(pubkey) + + return ( + + + + + + + + +
+ + + push(toProfile(pubkey))}>Profile + + Logout + + + + ) +} + +function LoginButton() { + const { canLogin, login } = useNostr() + const [open, setOpen] = useState(false) + const [nsec, setNsec] = useState('') + const [errMsg, setErrMsg] = useState(null) + + const handleInputChange = (e: React.ChangeEvent) => { + setNsec(e.target.value) + setErrMsg(null) + } + + const handleLogin = () => { + if (nsec === '') return + + login(nsec).catch((err) => { + setErrMsg(err.message) + }) + } + + return ( + + + + + + + Sign in + {!canLogin && ( + + Encryption is not available in your device. + + )} + +
+ + {errMsg &&
{errMsg}
} +
+ +
+
+ ) +} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx index 5791f8c2..584e9182 100644 --- a/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx +++ b/src/renderer/src/layouts/PrimaryPageLayout/RefreshButton.tsx @@ -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 ( - + ) } diff --git a/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx b/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx index 822438ff..be4c8692 100644 --- a/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx +++ b/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx @@ -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 ( - - + + diff --git a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx index 090a303c..acd3d6f7 100644 --- a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx @@ -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 ( -
{content}
+
+ + {content} +
diff --git a/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx b/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx index 616f51e2..0970f3ad 100644 --- a/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx +++ b/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx @@ -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 && ( - pop()}> + )} ) diff --git a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx index c817efc2..9cca61c2 100644 --- a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx +++ b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx @@ -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' ? ( - setThemeSetting('light')} title="switch to light theme"> + ) : themeSetting === 'light' ? ( - setThemeSetting('dark')} title="switch to dark theme"> + ) : ( - setThemeSetting('system')} title="switch to system theme"> + )} ) diff --git a/src/renderer/src/providers/NostrProvider.tsx b/src/renderer/src/providers/NostrProvider.tsx new file mode 100644 index 00000000..248ad660 --- /dev/null +++ b/src/renderer/src/providers/NostrProvider.tsx @@ -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 + logout: () => Promise + publish: (draftEvent: TDraftEvent) => Promise +} + +const NostrContext = createContext(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(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 ( + + {children} + + ) +} diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index 2d7fca7f..a6195c3e 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -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], {