feat: web (#6)

This commit is contained in:
Cody Tseng
2024-11-16 15:44:37 +08:00
committed by GitHub
parent ab667afc30
commit 26c2512d61
60 changed files with 937 additions and 547 deletions

View File

@@ -1,8 +1,6 @@
# jumble # jumble
Yet another Nostr desktop client Yet another Nostr client
> NOTE: Currently, only browsing is supported. Posting, liking, and reposting will be available soon.
## Features ## Features
@@ -10,6 +8,7 @@ Yet another Nostr desktop client
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays - **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Groups:** Easily manage and switch between relay groups - **Relay Groups:** Easily manage and switch between relay groups
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions - **Clean Interface:** Enjoy a minimalist design and intuitive interactions
- **Cross-Platform:** Available on macOS, Windows, Linux, and web browsers
## Download ## Download

9
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"lru-cache": "^11.0.1", "lru-cache": "^11.0.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1", "nostr-tools": "^2.9.1",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.1.0", "qrcode.react": "^4.1.0",
"react-resizable-panels": "^2.1.5", "react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
@@ -7561,6 +7562,14 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
}, },
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"engines": {
"node": ">=16"
}
},
"node_modules/path-type": { "node_modules/path-type": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",

View File

@@ -18,12 +18,14 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"dev:web": "vite --config web.vite.config.ts",
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win -p never", "build:win": "npm run build && electron-builder --win -p never",
"build:mac": "electron-vite build && electron-builder --mac -p never", "build:mac": "electron-vite build && electron-builder --mac -p never",
"build:linux": "electron-vite build && electron-builder --linux -p never" "build:linux": "electron-vite build && electron-builder --linux -p never",
"build:web": "vite build --config web.vite.config.ts"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
@@ -47,6 +49,7 @@
"lru-cache": "^11.0.1", "lru-cache": "^11.0.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1", "nostr-tools": "^2.9.1",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.1.0", "qrcode.react": "^4.1.0",
"react-resizable-panels": "^2.1.5", "react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",

View File

@@ -1,3 +1,4 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
export type TRelayGroup = { export type TRelayGroup = {
@@ -15,3 +16,33 @@ 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'> export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
export type TElectronWindow = {
electron: ElectronAPI
api: {
system: {
isEncryptionAvailable: () => Promise<boolean>
}
theme: {
onChange: (cb: (theme: TTheme) => void) => void
current: () => Promise<TTheme>
themeSetting: () => Promise<TThemeSetting>
set: (themeSetting: TThemeSetting) => Promise<void>
}
storage: {
getRelayGroups: () => Promise<TRelayGroup[]>
setRelayGroups: (relayGroups: TRelayGroup[]) => Promise<void>
}
nostr: {
login: (nsec: string) => Promise<{
pubkey?: string
reason?: string
}>
logout: () => Promise<void>
}
}
nostr: {
getPublicKey: () => Promise<string | null>
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
}
}

View File

@@ -17,21 +17,8 @@ export class StorageService {
) )
} }
getRelayGroups(): TRelayGroup[] { getRelayGroups(): TRelayGroup[] | null {
return ( return this.storage.get('relayGroups') ?? null
this.storage.get('relayGroups') ?? [
{
groupName: 'Global',
relayUrls: [
'wss://relay.damus.io/',
'wss://nos.lol/',
'wss://nostr.mom/',
'wss://relay.primal.net/'
],
isActive: true
}
]
)
} }
setRelayGroups(relayGroups: TRelayGroup[]) { setRelayGroups(relayGroups: TRelayGroup[]) {

View File

@@ -1,33 +0,0 @@
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>
themeSetting: () => Promise<TThemeSetting>
set: (themeSetting: TThemeSetting) => Promise<void>
}
storage: {
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

@@ -24,11 +24,15 @@ const api = {
}, },
nostr: { nostr: {
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec), login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
logout: () => ipcRenderer.invoke('nostr:logout'), logout: () => ipcRenderer.invoke('nostr:logout')
}
}
// NIP-07
const nostr = {
getPublicKey: () => ipcRenderer.invoke('nostr:getPublicKey'), getPublicKey: () => ipcRenderer.invoke('nostr:getPublicKey'),
signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent) signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent)
} }
}
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise // renderer only if context isolation is enabled, otherwise
@@ -37,6 +41,7 @@ if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('nostr', nostr)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -45,4 +50,6 @@ if (process.contextIsolated) {
window.electron = electronAPI window.electron = electronAPI
// @ts-ignore (define in dts) // @ts-ignore (define in dts)
window.api = api window.api = api
// @ts-ignore (define in dts)
window.nostr = nostr
} }

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Electron</title> <title>Jumble</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- <meta <!-- <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"

View File

@@ -5,22 +5,11 @@ import { Toaster } from '@renderer/components/ui/toaster'
import { ThemeProvider } from '@renderer/providers/ThemeProvider' import { ThemeProvider } from '@renderer/providers/ThemeProvider'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
import NoteListPage from './pages/primary/NoteListPage' import NoteListPage from './pages/primary/NoteListPage'
import FollowingListPage from './pages/secondary/FollowingListPage'
import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage'
import { FollowListProvider } from './providers/FollowListProvider' import { FollowListProvider } from './providers/FollowListProvider'
import { NostrProvider } from './providers/NostrProvider' import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider'
import { RelaySettingsProvider } from './providers/RelaySettingsProvider' import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
const routes = [
{ pageName: 'note', element: <NotePage /> },
{ pageName: 'profile', element: <ProfilePage /> },
{ pageName: 'hashtag', element: <HashtagPage /> },
{ pageName: 'followingList', element: <FollowingListPage /> }
]
export default function App(): JSX.Element { export default function App(): JSX.Element {
return ( return (
<div className="h-screen"> <div className="h-screen">
@@ -29,7 +18,7 @@ export default function App(): JSX.Element {
<FollowListProvider> <FollowListProvider>
<RelaySettingsProvider> <RelaySettingsProvider>
<NoteStatsProvider> <NoteStatsProvider>
<PageManager routes={routes}> <PageManager>
<NoteListPage /> <NoteListPage />
</PageManager> </PageManager>
<Toaster /> <Toaster />

View File

@@ -1,36 +1,28 @@
import Sidebar from '@renderer/components/Sidebar'
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup ResizablePanelGroup
} from '@renderer/components/ui/resizable' } from '@renderer/components/ui/resizable'
import { cloneElement, createContext, isValidElement, useContext, useState } from 'react'
import BlankPage from './pages/secondary/BlankPage'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import HomePage from '@renderer/pages/secondary/HomePage'
type TRoute = { import NotFoundPage from '@renderer/pages/secondary/NotFoundPage'
pageName: string import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
element: React.ReactNode import { routes } from './routes'
}
type TPushParams = {
pageName: string
props: any
}
type TPrimaryPageContext = { type TPrimaryPageContext = {
refresh: () => void refresh: () => void
} }
type TSecondaryPageContext = { type TSecondaryPageContext = {
push: (params: TPushParams) => void push: (url: string) => void
pop: () => void pop: () => void
} }
type TStackItem = { type TStackItem = {
index: number index: number
pageName: string url: string
props: any component: React.ReactNode | null
component: React.ReactNode
} }
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined) const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
@@ -54,56 +46,72 @@ export function useSecondaryPage() {
} }
export function PageManager({ export function PageManager({
routes,
children, children,
maxStackSize = 5 maxStackSize = 5
}: { }: {
routes: TRoute[]
children: React.ReactNode children: React.ReactNode
maxStackSize?: number maxStackSize?: number
}) { }) {
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0) const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([]) const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const routeMap = routes.reduce((acc, route) => { useEffect(() => {
acc[route.pageName] = route.element const url = window.location.pathname
return acc if (url !== '/') {
}, {}) as Record<string, React.ReactNode> pushSecondary(url)
const isCurrentPage = (stack: TStackItem[], { pageName, props }: TPushParams) => {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return (
currentPage.pageName === pageName &&
JSON.stringify(currentPage.props) === JSON.stringify(props) // TODO: deep compare
)
} }
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1) const onPopState = (e: PopStateEvent) => {
const state = e.state ?? { index: -1, url: '/' }
setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1]
const currentIndex = currentItem ? currentItem.index : -1
if (state.index === currentIndex) {
return pre
}
if (state.index < currentIndex) {
const newStack = pre.filter((item) => item.index <= state.index)
const topItem = newStack[newStack.length - 1]
if (topItem && !topItem.component) {
topItem.component = findAndCreateComponent(topItem.url)
}
return newStack
}
const pushSecondary = ({ pageName, props }: TPushParams) => { const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
if (isCurrentPage(secondaryStack, { pageName, props })) return
const element = routeMap[pageName]
if (!element) return
if (!isValidElement(element)) return
setSecondaryStack((prevStack) => {
const currentStack = prevStack[prevStack.length - 1]
const index = currentStack ? currentStack.index + 1 : 0
const component = cloneElement(element, props)
const newStack = [...prevStack, { index, pageName, props, component }]
if (newStack.length > maxStackSize) newStack.shift()
return newStack return newStack
}) })
} }
const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1)) window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', onPopState)
}
}, [])
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
const pushSecondary = (url: string) => {
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize)
if (newItem) {
window.history.pushState({ index: newItem.index, url }, '', url)
}
return newStack
})
}
const popSecondary = () => {
window.history.back()
}
return ( return (
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}> <PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}> <SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
<div className="flex h-full">
<Sidebar />
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={55} minSize={30}> <ResizablePanel defaultSize={55} minSize={30}>
<div key={primaryPageKey} className="h-full"> <div key={primaryPageKey} className="h-full">
@@ -123,10 +131,11 @@ export function PageManager({
</div> </div>
)) ))
) : ( ) : (
<BlankPage /> <HomePage />
)} )}
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div>
</SecondaryPageContext.Provider> </SecondaryPageContext.Provider>
</PrimaryPageContext.Provider> </PrimaryPageContext.Provider>
) )
@@ -138,7 +147,7 @@ export function SecondaryPageLink({
className, className,
onClick onClick
}: { }: {
to: TPushParams to: string
children: React.ReactNode children: React.ReactNode
className?: string className?: string
onClick?: (e: React.MouseEvent) => void onClick?: (e: React.MouseEvent) => void
@@ -157,3 +166,33 @@ export function SecondaryPageLink({
</span> </span>
) )
} }
function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return currentPage.url === url
}
function findAndCreateComponent(url: string) {
for (const { matcher, element } of routes) {
const match = matcher(url)
if (!match) continue
if (!element) return <NotFoundPage />
return cloneElement(element, match.params)
}
return <NotFoundPage />
}
function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) {
const component = findAndCreateComponent(url)
const currentStack = stack[stack.length - 1]
const newItem = { component, url, index: currentStack ? currentStack.index + 1 : 0 }
const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].component = null
}
return { newStack, newItem }
}

View File

@@ -0,0 +1,29 @@
import { Button } from '@renderer/components/ui/button'
import { useNostr } from '@renderer/providers/NostrProvider'
import { LogIn } from 'lucide-react'
export default function LoginButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { checkLogin } = useNostr()
let triggerComponent: React.ReactNode
if (variant === 'titlebar') {
triggerComponent = <LogIn />
} else {
triggerComponent = (
<>
<LogIn size={16} />
<div>Login</div>
</>
)
}
return (
<Button variant={variant} size={variant} onClick={() => checkLogin()}>
{triggerComponent}
</Button>
)
}

View File

@@ -0,0 +1,70 @@
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { useFetchProfile } from '@renderer/hooks'
import { toProfile } from '@renderer/lib/link'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useSecondaryPage } from '@renderer/PageManager'
import { useNostr } from '@renderer/providers/NostrProvider'
export default function ProfileButton({
pubkey,
variant = 'titlebar'
}: {
pubkey: string
variant?: 'titlebar' | 'sidebar'
}) {
const { logout } = useNostr()
const {
profile: { avatar, username }
} = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
const defaultAvatar = generateImageByPubkey(pubkey)
let triggerComponent: React.ReactNode
if (variant === 'titlebar') {
triggerComponent = (
<button>
<Avatar className="w-6 h-6 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</button>
)
} else {
triggerComponent = (
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2">
<div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-10 h-10">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
<div className="truncate font-semibold text-lg">{username}</div>
</div>
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="non-draggable" asChild>
{triggerComponent}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>Profile</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,17 @@
import { useNostr } from '@renderer/providers/NostrProvider'
import LoginButton from './LoginButton'
import ProfileButton from './ProfileButton'
export default function AccountButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { pubkey } = useNostr()
if (pubkey) {
return <ProfileButton variant={variant} pubkey={pubkey} />
} else {
return <LoginButton variant={variant} />
}
}

View File

@@ -4,7 +4,7 @@ import { kinds } from 'nostr-tools'
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard' import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'
export function EmbeddedNote({ noteId }: { noteId: string }) { export function EmbeddedNote({ noteId }: { noteId: string }) {
const event = useFetchEventById(noteId) const { event } = useFetchEventById(noteId)
return event && event.kind === kinds.ShortTextNote ? ( return event && event.kind === kinds.ShortTextNote ? (
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} hideStats /> <ShortTextNoteCard size="small" className="mt-2 w-full" event={event} hideStats />

View File

@@ -1,43 +1,57 @@
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { useToast } from '@renderer/hooks'
import { useFollowList } from '@renderer/providers/FollowListProvider' import { useFollowList } from '@renderer/providers/FollowListProvider'
import { useNostr } from '@renderer/providers/NostrProvider' import { useNostr } from '@renderer/providers/NostrProvider'
import { Loader } from 'lucide-react' import { Loader } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
export default function FollowButton({ pubkey }: { pubkey: string }) { export default function FollowButton({ pubkey }: { pubkey: string }) {
const { pubkey: accountPubkey } = useNostr() const { toast } = useToast()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList() const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
if (!accountPubkey || pubkey === accountPubkey || !isReady) return null if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
const handleFollow = async (e: React.MouseEvent) => { const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => {
if (isFollowing) return if (isFollowing) return
setUpdating(true) setUpdating(true)
try { try {
await follow(pubkey) await follow(pubkey)
} catch (error) { } catch (error) {
console.error(error) toast({
title: 'Follow failed',
description: (error as Error).message,
variant: 'destructive'
})
} finally { } finally {
setUpdating(false) setUpdating(false)
} }
})
} }
const handleUnfollow = async (e: React.MouseEvent) => { const handleUnfollow = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => {
if (!isFollowing || !followListEvent) return if (!isFollowing || !followListEvent) return
setUpdating(true) setUpdating(true)
try { try {
await unfollow(pubkey) await unfollow(pubkey)
} catch (error) { } catch (error) {
console.error(error) toast({
title: 'Unfollow failed',
description: (error as Error).message,
variant: 'destructive'
})
} finally { } finally {
setUpdating(false) setUpdating(false)
} }
})
} }
return isFollowing ? ( return isFollowing ? (

View File

@@ -1,5 +1,4 @@
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
import { cn } from '@renderer/lib/utils'
import { useState } from 'react' import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
@@ -24,13 +23,13 @@ export default function ImageGallery({
} }
return ( return (
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}> <div className={className} onClick={(e) => e.stopPropagation()}>
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex space-x-2"> <div className="flex space-x-2">
{images.map((src, index) => { {images.map((src, index) => {
return ( return (
<img <img
className={`rounded-lg h-fit w-fit cursor-pointer ${size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]'}`} className={`rounded-lg object-cover h-fit cursor-pointer ${size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]'}`}
key={index} key={index}
src={src} src={src}
onClick={(e) => handlePhotoClick(e, index)} onClick={(e) => handlePhotoClick(e, index)}

View File

@@ -0,0 +1,65 @@
import { Button } from '@renderer/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog'
import { Input } from '@renderer/components/ui/input'
import { useNostr } from '@renderer/providers/NostrProvider'
import { Dispatch, useState } from 'react'
export default function LoginDialog({
open,
setOpen
}: {
open: boolean
setOpen: Dispatch<boolean>
}) {
const { login, canLogin } = useNostr()
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)
.then(() => setOpen(false))
.catch((err) => {
setErrMsg(err.message)
})
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-80">
<DialogHeader>
<DialogTitle>Sign in</DialogTitle>
<DialogDescription className="text-destructive">
{!canLogin && '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

@@ -47,7 +47,7 @@ export default function Note({
className="mt-2" className="mt-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
push(toNote(parentEvent)) push(toNote(parentEvent.id))
}} }}
/> />
)} )}

View File

@@ -1,3 +1,4 @@
import client from '@renderer/services/client.service'
import { Repeat2 } from 'lucide-react' import { Repeat2 } from 'lucide-react'
import { Event, kinds, verifyEvent } from 'nostr-tools' import { Event, kinds, verifyEvent } from 'nostr-tools'
import Username from '../Username' import Username from '../Username'
@@ -9,6 +10,8 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
return null return null
} }
client.addEventToCache(targetEvent)
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground"> <div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">

View File

@@ -18,15 +18,15 @@ export default function ShortTextNoteCard({
hideStats?: boolean hideStats?: boolean
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const rootEvent = useFetchEventById(getRootEventId(event)) const { event: rootEvent } = useFetchEventById(getRootEventId(event))
const parentEvent = useFetchEventById(getParentEventId(event)) const { event: parentEvent } = useFetchEventById(getParentEventId(event))
return ( return (
<div <div
className={className} className={className}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
push(toNote(event)) push(toNote(event.id))
}} }}
> >
<Card <Card

View File

@@ -47,6 +47,9 @@ export default function NoteList({
setUntil(events[events.length - 1].created_at - 1) setUntil(events[events.length - 1].created_at - 1)
} }
setInitialized(true) setInitialized(true)
processedEvents.forEach((e) => {
client.addEventToCache(e)
})
}, },
onNew: (event) => { onNew: (event) => {
if (!isReplyNoteEvent(event)) { if (!isReplyNoteEvent(event)) {
@@ -100,6 +103,9 @@ export default function NoteList({
} }
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
processedEvents.forEach((e) => {
client.addEventToCache(e)
})
} }
const showNewEvents = () => { const showNewEvents = () => {

View File

@@ -17,14 +17,14 @@ export default function LikeButton({
variant?: 'normal' | 'reply' variant?: 'normal' | 'reply'
canFetch?: boolean canFetch?: boolean
}) { }) {
const { pubkey, publish } = useNostr() const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats() const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const { likeCount, hasLiked } = useMemo( const { likeCount, hasLiked } = useMemo(
() => noteStatsMap.get(event.id) ?? {}, () => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id] [noteStatsMap, event.id]
) )
const canLike = pubkey && !hasLiked && !liking const canLike = !hasLiked && !liking
useEffect(() => { useEffect(() => {
if (!canFetch) return if (!canFetch) return
@@ -39,6 +39,7 @@ export default function LikeButton({
const like = async (e: React.MouseEvent) => { const like = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => {
if (!canLike) return if (!canLike) return
setLiking(true) setLiking(true)
@@ -61,6 +62,7 @@ export default function LikeButton({
setLiking(false) setLiking(false)
clearTimeout(timer) clearTimeout(timer)
} }
})
} }
return ( return (

View File

@@ -26,7 +26,7 @@ export default function RepostButton({
event: Event event: Event
canFetch?: boolean canFetch?: boolean
}) { }) {
const { pubkey, publish } = useNostr() const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } = const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
useNoteStats() useNoteStats()
const [reposting, setReposting] = useState(false) const [reposting, setReposting] = useState(false)
@@ -34,7 +34,7 @@ export default function RepostButton({
() => noteStatsMap.get(event.id) ?? {}, () => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id] [noteStatsMap, event.id]
) )
const canRepost = pubkey && !hasReposted && !reposting const canRepost = !hasReposted && !reposting
useEffect(() => { useEffect(() => {
if (!canFetch) return if (!canFetch) return
@@ -49,6 +49,7 @@ export default function RepostButton({
const repost = async (e: React.MouseEvent) => { const repost = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => {
if (!canRepost) return if (!canRepost) return
setReposting(true) setReposting(true)
@@ -71,6 +72,7 @@ export default function RepostButton({
setReposting(false) setReposting(false)
clearTimeout(timer) clearTimeout(timer)
} }
})
} }
return ( return (

View File

@@ -1,16 +1,13 @@
import PostDialog from '@renderer/components/PostDialog' import PostDialog from '@renderer/components/PostDialog'
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { useNostr } from '@renderer/providers/NostrProvider'
import { PencilLine } from 'lucide-react' import { PencilLine } from 'lucide-react'
export default function PostButton() { export default function PostButton({ variant = 'titlebar' }: { variant?: 'titlebar' | 'sidebar' }) {
const { pubkey } = useNostr()
if (!pubkey) return null
return ( return (
<PostDialog> <PostDialog>
<Button variant="titlebar" size="titlebar" title="new post"> <Button variant={variant} size={variant} title="new post">
<PencilLine /> <PencilLine />
{variant === 'sidebar' && <div>Post</div>}
</Button> </Button>
</PostDialog> </PostDialog>
) )

View File

@@ -29,10 +29,11 @@ export default function PostDialog({
parentEvent?: Event parentEvent?: Event
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const { pubkey, publish } = useNostr() const { publish, checkLogin } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
const canPost = !!content && !posting
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value) setContent(e.target.value)
@@ -40,7 +41,8 @@ export default function PostDialog({
const post = async (e: React.MouseEvent) => { const post = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!content || !pubkey || posting) { checkLogin(async () => {
if (!canPost) {
setOpen(false) setOpen(false)
return return
} }
@@ -81,6 +83,7 @@ export default function PostDialog({
title: 'Post successful', title: 'Post successful',
description: 'Your post has been published' description: 'Your post has been published'
}) })
})
} }
return ( return (
@@ -122,7 +125,7 @@ export default function PostDialog({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={!pubkey || posting} onClick={post}> <Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && <LoaderCircle className="animate-spin" />}
Post Post
</Button> </Button>

View File

@@ -7,7 +7,9 @@ import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout' import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) { export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey) const {
profile: { avatar = '', username, nip05, about }
} = useFetchProfile(pubkey)
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey]) const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
return ( return (

View File

@@ -2,11 +2,16 @@ 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({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { refresh } = usePrimaryPage() const { refresh } = usePrimaryPage()
return ( return (
<Button variant="titlebar" size="titlebar" onClick={refresh} title="reload"> <Button variant={variant} size={variant} onClick={refresh} title="reload">
<RefreshCcw /> <RefreshCcw />
{variant === 'sidebar' && <div>Refresh</div>}
</Button> </Button>
) )
} }

View File

@@ -4,15 +4,23 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
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'
export default function RelaySettingsPopover() { export default function RelaySettingsPopover({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="titlebar" size="titlebar" title="relay settings"> <Button variant={variant} size={variant} title="relay settings">
<Server /> <Server />
{variant === 'sidebar' && <div>Relays</div>}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0"> <PopoverContent
className="w-96 h-[450px] p-0"
side={variant === 'titlebar' ? 'bottom' : 'right'}
>
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-4"> <div className="p-4">
<RelaySettings /> <RelaySettings />

View File

@@ -30,7 +30,7 @@ export default function ScrollToTopButton({
return ( return (
<Button <Button
variant="secondary-2" variant="secondary-2"
className={`absolute bottom-4 right-2 rounded-full w-10 h-10 p-0 hover:text-background transition-transform ${showScrollToTop ? '' : 'translate-y-14'}`} className={`absolute bottom-8 right-2 rounded-full w-10 h-10 p-0 hover:text-background transition-transform ${showScrollToTop ? '' : 'translate-y-20'}`}
onClick={handleScrollToTop} onClick={handleScrollToTop}
> >
<ChevronUp /> <ChevronUp />

View File

@@ -0,0 +1,22 @@
import { toHome } from '@renderer/lib/link'
import { SecondaryPageLink } from '@renderer/PageManager'
import AccountButton from '../AccountButton'
import PostButton from '../PostButton'
import RefreshButton from '../RefreshButton'
import RelaySettingsPopover from '../RelaySettingsPopover'
export default function PrimaryPageSidebar() {
return (
<div className="draggable w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-9 pl-4 justify-between">
<div className="space-y-2">
<div className="text-3xl font-extrabold font-mono text-center mb-4">
<SecondaryPageLink to={toHome()}>Jumble</SecondaryPageLink>
</div>
<PostButton variant="sidebar" />
<RelaySettingsPopover variant="sidebar" />
<RefreshButton variant="sidebar" />
</div>
<AccountButton variant="sidebar" />
</div>
)
}

View File

@@ -10,7 +10,7 @@ export function Titlebar({
return ( return (
<div <div
className={cn( className={cn(
'draggable absolute top-0 w-full h-9 z-50 bg-background/80 backdrop-blur-xl flex items-center font-semibold space-x-1 px-2', 'draggable absolute top-0 w-full h-9 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold space-x-1 px-2',
className className
)} )}
> >

View File

@@ -25,7 +25,9 @@ export default function UserAvatar({
className?: string className?: string
size?: 'large' | 'normal' | 'small' | 'tiny' size?: 'large' | 'normal' | 'small' | 'tiny'
}) { }) {
const { avatar, pubkey } = useFetchProfile(userId) const {
profile: { avatar, pubkey }
} = useFetchProfile(userId)
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
if (!pubkey) { if (!pubkey) {

View File

@@ -14,7 +14,9 @@ export default function Username({
showAt?: boolean showAt?: boolean
className?: string className?: string
}) { }) {
const { username, pubkey } = useFetchProfile(userId) const {
profile: { username, pubkey }
} = useFetchProfile(userId)
if (!pubkey) return null if (!pubkey) return null
return ( return (

View File

@@ -16,14 +16,16 @@ const buttonVariants = cva(
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight', 'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
ghost: 'text-muted-foreground hover:bg-accent hover:text-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' titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground',
sidebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
}, },
size: { size: {
default: 'h-8 rounded-lg px-3', default: 'h-8 rounded-lg px-3',
sm: 'h-8 rounded-lg px-2', sm: 'h-8 rounded-lg px-2',
lg: 'h-10 px-4 py-2', lg: 'h-10 px-4 py-2',
icon: 'h-8 w-8 rounded-full', icon: 'h-8 w-8 rounded-full',
titlebar: 'h-7 w-7 rounded-full' titlebar: 'h-7 w-7 rounded-full',
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold'
} }
}, },
defaultVariants: { defaultVariants: {

View File

@@ -1,79 +1,56 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@renderer/lib/utils" import { cn } from '@renderer/lib/utils'
const Card = React.forwardRef< const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn('rounded-lg border bg-card text-card-foreground', className)}
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props} {...props}
/> />
)) )
Card.displayName = "Card" )
Card.displayName = 'Card'
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div )
ref={ref} CardHeader.displayName = 'CardHeader'
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props} {...props}
/> />
)) )
CardTitle.displayName = "CardTitle" )
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)) ))
CardDescription.displayName = "CardDescription" CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef< const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> )
)) CardContent.displayName = 'CardContent'
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div )
ref={ref} CardFooter.displayName = 'CardFooter'
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -16,6 +16,7 @@ const PopoverContent = React.forwardRef<
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
collisionPadding={10}
className={cn( className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import { TDraftEvent } from '@common/types'
import { Event } from 'nostr-tools'
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<string | null>
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
}
}
}

View File

@@ -3,13 +3,19 @@ import { Event, Filter, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchEventById(id?: string) { export function useFetchEventById(id?: string) {
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(undefined) const [event, setEvent] = useState<Event | undefined>(undefined)
useEffect(() => { useEffect(() => {
const fetchEvent = async () => { const fetchEvent = async () => {
if (!id) return if (!id) {
setIsFetching(false)
setError(new Error('No id provided'))
return
}
let filter: Filter = {} let filter: Filter | undefined
if (/^[0-9a-f]{64}$/.test(id)) { if (/^[0-9a-f]{64}$/.test(id)) {
filter = { ids: [id] } filter = { ids: [id] }
} else { } else {
@@ -19,9 +25,7 @@ export function useFetchEventById(id?: string) {
filter = { ids: [data] } filter = { ids: [data] }
break break
case 'nevent': case 'nevent':
if (data.id) { filter = { ids: [data.id] }
filter.ids = [data.id]
}
break break
case 'naddr': case 'naddr':
filter = { filter = {
@@ -34,8 +38,11 @@ export function useFetchEventById(id?: string) {
} }
} }
} }
if (!filter) {
if (!filter) return setIsFetching(false)
setError(new Error('Invalid id'))
return
}
let event: Event | undefined let event: Event | undefined
if (filter.ids) { if (filter.ids) {
@@ -45,13 +52,15 @@ export function useFetchEventById(id?: string) {
} }
if (event) { if (event) {
setEvent(event) setEvent(event)
} else {
setEvent(undefined)
} }
setIsFetching(false)
} }
fetchEvent() fetchEvent().catch((err) => {
setError(err as Error)
setIsFetching(false)
})
}, [id]) }, [id])
return event return { isFetching, error, event }
} }

View File

@@ -5,6 +5,8 @@ import { nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchProfile(id?: string) { export function useFetchProfile(id?: string) {
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile>({ const [profile, setProfile] = useState<TProfile>({
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username' username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
}) })
@@ -12,7 +14,11 @@ export function useFetchProfile(id?: string) {
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { const fetchProfile = async () => {
try { try {
if (!id) return if (!id) {
setIsFetching(false)
setError(new Error('No id provided'))
return
}
let pubkey: string | undefined let pubkey: string | undefined
@@ -30,7 +36,11 @@ export function useFetchProfile(id?: string) {
} }
} }
if (!pubkey) return if (!pubkey) {
setIsFetching(false)
setError(new Error('Invalid id'))
return
}
setProfile({ pubkey, username: formatPubkey(pubkey) }) setProfile({ pubkey, username: formatPubkey(pubkey) })
const profile = await client.fetchProfile(pubkey) const profile = await client.fetchProfile(pubkey)
@@ -38,12 +48,14 @@ export function useFetchProfile(id?: string) {
setProfile(profile) setProfile(profile)
} }
} catch (err) { } catch (err) {
console.error(err) setError(err as Error)
} finally {
setIsFetching(false)
} }
} }
fetchProfile() fetchProfile()
}, [id]) }, [id])
return profile return { isFetching, error, profile }
} }

View File

@@ -1,114 +0,0 @@
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/link'
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="non-draggable">
<Avatar className="w-6 h-6 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</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 asChild>
<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 AccountButton from '@renderer/components/AccountButton'
import PostButton from '@renderer/components/PostButton'
import RefreshButton from '@renderer/components/RefreshButton'
import RelaySettingsPopover from '@renderer/components/RelaySettingsPopover'
import ScrollToTopButton from '@renderer/components/ScrollToTopButton' import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
import { Titlebar } from '@renderer/components/Titlebar' 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/env'
import { forwardRef, useImperativeHandle, useRef } from 'react' import { forwardRef, useImperativeHandle, useRef } from 'react'
import AccountButton from './AccountButton'
import PostButton from './PostButton'
import RefreshButton from './RefreshButton'
import RelaySettingsPopover from './RelaySettingsPopover'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@@ -26,13 +26,9 @@ const PrimaryPageLayout = forwardRef(
) )
return ( return (
<ScrollArea <ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<PrimaryPageTitlebar content={titlebarContent} /> <PrimaryPageTitlebar content={titlebarContent} />
<div className="px-4 pb-4 pt-11">{children}</div> <div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} /> <ScrollToTopButton scrollAreaRef={scrollAreaRef} />
</ScrollArea> </ScrollArea>
) )
@@ -47,7 +43,7 @@ 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 xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<AccountButton /> <AccountButton />
<PostButton /> <PostButton />

View File

@@ -1,27 +1,22 @@
import BackButton from '@renderer/components/BackButton'
import ScrollToTopButton from '@renderer/components/ScrollToTopButton' import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
import ThemeToggle from '@renderer/components/ThemeToggle'
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 { useRef } from 'react' import { useRef } from 'react'
import { Titlebar } from '../../components/Titlebar'
import BackButton from './BackButton'
import ThemeToggle from './ThemeToggle'
export default function SecondaryPageLayout({ export default function SecondaryPageLayout({
children, children,
titlebarContent, titlebarContent,
hideBackButton = false hideBackButton = false
}: { }: {
children: React.ReactNode children?: React.ReactNode
titlebarContent?: React.ReactNode titlebarContent?: React.ReactNode
hideBackButton?: boolean hideBackButton?: boolean
}): JSX.Element { }): JSX.Element {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
return ( return (
<ScrollArea <ScrollArea ref={scrollAreaRef} className="h-full" scrollBarClassName="pt-9">
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} /> <SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} />
<div className="px-4 pb-4 pt-11 w-full h-full">{children}</div> <div className="px-4 pb-4 pt-11 w-full h-full">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} /> <ScrollToTopButton scrollAreaRef={scrollAreaRef} />

View File

@@ -0,0 +1,11 @@
import { TElectronWindow } from '@common/types'
export function isElectron(w: any): w is TElectronWindow {
return !!w.electron && !!w.api
}
export function isMacOS() {
return isElectron(window) && window.electron.process.platform === 'darwin'
}
export const IS_ELECTRON = isElectron(window)

View File

@@ -1,11 +1,8 @@
import { Event } from 'nostr-tools' export const toHome = () => '/'
export const toProfile = (pubkey: string) => `/user/${pubkey}`
export const toNote = (eventId: string) => `/note/${eventId}`
export const toHashtag = (hashtag: string) => `/hashtag/${hashtag}`
export const toFollowingList = (pubkey: string) => `/user/${pubkey}/following`
export const toProfile = (pubkey: string) => ({ pageName: 'profile', props: { pubkey } })
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })
export const toFollowingList = (pubkey: string) => ({
pageName: 'followingList',
props: { pubkey }
})

View File

@@ -1,3 +0,0 @@
export function isMacOS() {
return window.electron.process.platform === 'darwin'
}

View File

@@ -6,8 +6,10 @@ import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) { export default function FollowingListPage({ id }: { id?: string }) {
const { username } = useFetchProfile(pubkey) const {
profile: { username, pubkey }
} = useFetchProfile(id)
const { followings } = useFetchFollowings(pubkey) const { followings } = useFetchFollowings(pubkey)
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([]) const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
const observer = useRef<IntersectionObserver | null>(null) const observer = useRef<IntersectionObserver | null>(null)
@@ -57,7 +59,9 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
} }
function FollowingItem({ pubkey }: { pubkey: string }) { function FollowingItem({ pubkey }: { pubkey: string }) {
const { about, nip05 } = useFetchProfile(pubkey) const {
profile: { about, nip05 }
} = useFetchProfile(pubkey)
return ( return (
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">

View File

@@ -1,21 +1,18 @@
import NoteList from '@renderer/components/NoteList' import NoteList from '@renderer/components/NoteList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import NotFoundPage from '../NotFoundPage'
export default function HashtagPage({ hashtag }: { hashtag?: string }) { export default function HashtagPage({ id }: { id?: string }) {
const { relayUrls } = useRelaySettings() const { relayUrls } = useRelaySettings()
if (!hashtag) { if (!id) {
return null return <NotFoundPage />
} }
const normalizedHashtag = hashtag.toLowerCase() const hashtag = id.toLowerCase()
return ( return (
<SecondaryPageLayout titlebarContent={`# ${normalizedHashtag}`}> <SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
<NoteList <NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
key={normalizedHashtag}
filter={{ '#t': [normalizedHashtag] }}
relayUrls={relayUrls}
/>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }

View File

@@ -1,6 +1,6 @@
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
export default function BlankPage() { export default function HomePage() {
return ( return (
<SecondaryPageLayout hideBackButton> <SecondaryPageLayout hideBackButton>
<div className="text-muted-foreground w-full h-full flex items-center justify-center"> <div className="text-muted-foreground w-full h-full flex items-center justify-center">

View File

@@ -0,0 +1,11 @@
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
export default function LoadingPage({ title }: { title?: string }) {
return (
<SecondaryPageLayout titlebarContent={title}>
<div className="text-muted-foreground text-center">
<div>Loading...</div>
</div>
</SecondaryPageLayout>
)
}

View File

@@ -0,0 +1,19 @@
import { Button } from '@renderer/components/ui/button'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { toHome } from '@renderer/lib/link'
import { useSecondaryPage } from '@renderer/PageManager'
export default function NotFoundPage() {
const { push } = useSecondaryPage()
return (
<SecondaryPageLayout hideBackButton>
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
<div>Lost in the void 🌌</div>
<div>(404)</div>
<Button variant="secondary" onClick={() => push(toHome())}>
Carry me home
</Button>
</div>
</SecondaryPageLayout>
)
}

View File

@@ -9,18 +9,22 @@ import { useFetchEventById } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { getParentEventId, getRootEventId } from '@renderer/lib/event' import { getParentEventId, getRootEventId } from '@renderer/lib/event'
import { toNote } from '@renderer/lib/link' import { toNote } from '@renderer/lib/link'
import { Event } from 'nostr-tools' import { useMemo } from 'react'
import LoadingPage from '../LoadingPage'
import NotFoundPage from '../NotFoundPage'
export default function NotePage({ event }: { event?: Event }) { export default function NotePage({ id }: { id?: string }) {
const parentEvent = useFetchEventById(getParentEventId(event)) const { event, isFetching } = useFetchEventById(id)
const rootEvent = useFetchEventById(getRootEventId(event)) const parentEventId = useMemo(() => getParentEventId(event), [event])
const rootEventId = useMemo(() => getRootEventId(event), [event])
if (!event) return null if (!event && isFetching) return <LoadingPage title="note" />
if (!event) return <NotFoundPage />
return ( return (
<SecondaryPageLayout titlebarContent="note"> <SecondaryPageLayout titlebarContent="note">
{rootEvent && <ParentNote key={`root-note-${event.id}`} event={rootEvent} />} <ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
{parentEvent && <ParentNote key={`parent-note-${event.id}`} event={parentEvent} />} <ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
<Note key={`note-${event.id}`} event={event} fetchNoteStats /> <Note key={`note-${event.id}`} event={event} fetchNoteStats />
<Separator className="my-4" /> <Separator className="my-4" />
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} /> <ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
@@ -28,14 +32,16 @@ export default function NotePage({ event }: { event?: Event }) {
) )
} }
function ParentNote({ event }: { event: Event }) { function ParentNote({ eventId }: { eventId?: string }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { event } = useFetchEventById(eventId)
if (!event) return null
return ( return (
<div> <div>
<Card <Card
className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground" className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground"
onClick={() => push(toNote(event))} onClick={() => push(toNote(event.id))}
> >
<UserAvatar userId={event.pubkey} size="tiny" /> <UserAvatar userId={event.pubkey} size="tiny" />
<Username userId={event.pubkey} className="font-semibold" /> <Username userId={event.pubkey} className="font-semibold" />

View File

@@ -16,9 +16,14 @@ import { useNostr } from '@renderer/providers/NostrProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import PubkeyCopy from './PubkeyCopy' import PubkeyCopy from './PubkeyCopy'
import QrCodePopover from './QrCodePopover' import QrCodePopover from './QrCodePopover'
import LoadingPage from '../LoadingPage'
import NotFoundPage from '../NotFoundPage'
export default function ProfilePage({ pubkey }: { pubkey?: string }) { export default function ProfilePage({ id }: { id?: string }) {
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey) const {
profile: { banner, username, nip05, about, avatar, pubkey },
isFetching
} = useFetchProfile(id)
const relayList = useFetchRelayList(pubkey) const relayList = useFetchRelayList(pubkey)
const { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey } = useNostr()
const { followings: selfFollowings } = useFollowList() const { followings: selfFollowings } = useFollowList()
@@ -30,7 +35,8 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
const isSelf = accountPubkey === pubkey const isSelf = accountPubkey === pubkey
if (!pubkey) return null if (!pubkey && isFetching) return <LoadingPage title={username} />
if (!pubkey) return <NotFoundPage />
return ( return (
<SecondaryPageLayout titlebarContent={username}> <SecondaryPageLayout titlebarContent={username}>

View File

@@ -1,5 +1,8 @@
import { TDraftEvent } from '@common/types' import { TDraftEvent } from '@common/types'
import LoginDialog from '@renderer/components/LoginDialog'
import { useToast } from '@renderer/hooks'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import { IS_ELECTRON, isElectron } from '@renderer/lib/env'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@@ -10,11 +13,13 @@ type TNostrContext = {
canLogin: boolean canLogin: boolean
login: (nsec: string) => Promise<string> login: (nsec: string) => Promise<string>
logout: () => Promise<void> logout: () => Promise<void>
nip07Login: () => Promise<string>
/** /**
* Default publish the event to current relays, user's write relays and additional relays * Default publish the event to current relays, user's write relays and additional relays
*/ */
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event> publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string> signHttpAuth: (url: string, method: string) => Promise<string>
checkLogin: (cb?: () => void | Promise<void>) => void
} }
const NostrContext = createContext<TNostrContext | undefined>(undefined) const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -28,25 +33,34 @@ export const useNostr = () => {
} }
export function NostrProvider({ children }: { children: React.ReactNode }) { export function NostrProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast()
const [pubkey, setPubkey] = useState<string | null>(null) const [pubkey, setPubkey] = useState<string | null>(null)
const [canLogin, setCanLogin] = useState(false) const [canLogin, setCanLogin] = useState(false)
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const relayList = useFetchRelayList(pubkey) const relayList = useFetchRelayList(pubkey)
useEffect(() => { useEffect(() => {
window.api.nostr.getPublicKey().then((pubkey) => { window.nostr?.getPublicKey().then((pubkey) => {
if (pubkey) { if (pubkey) {
setPubkey(pubkey) setPubkey(pubkey)
} }
}) })
window.api.system.isEncryptionAvailable().then((isEncryptionAvailable) => { if (isElectron(window)) {
window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
setCanLogin(isEncryptionAvailable) setCanLogin(isEncryptionAvailable)
}) })
} else {
setCanLogin(!!window.nostr)
}
}, []) }, [])
const login = async (nsec: string) => { const login = async (nsec: string) => {
if (!canLogin) { if (!canLogin) {
throw new Error('encryption is not available') throw new Error('encryption is not available')
} }
if (!isElectron(window)) {
throw new Error('login is not available')
}
const { pubkey, reason } = await window.api.nostr.login(nsec) const { pubkey, reason } = await window.api.nostr.login(nsec)
if (!pubkey) { if (!pubkey) {
throw new Error(reason ?? 'invalid nsec') throw new Error(reason ?? 'invalid nsec')
@@ -55,13 +69,34 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return pubkey return pubkey
} }
const nip07Login = async () => {
if (IS_ELECTRON) {
throw new Error('electron app should not use nip07 login')
}
if (!window.nostr) {
throw new Error(
'You need to install a nostr signer extension to login. Such as Alby or nos2x'
)
}
const pubkey = await window.nostr.getPublicKey()
if (!pubkey) {
throw new Error('You did not allow to access your pubkey')
}
setPubkey(pubkey)
return pubkey
}
const logout = async () => { const logout = async () => {
if (isElectron(window)) {
await window.api.nostr.logout() await window.api.nostr.logout()
}
setPubkey(null) setPubkey(null)
} }
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
const event = await window.api.nostr.signEvent(draftEvent) const event = await window.nostr?.signEvent(draftEvent)
if (!event) { if (!event) {
throw new Error('sign event failed') throw new Error('sign event failed')
} }
@@ -70,7 +105,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const signHttpAuth = async (url: string, method: string) => { const signHttpAuth = async (url: string, method: string) => {
const event = await window.api.nostr.signEvent({ const event = await window.nostr?.signEvent({
content: '', content: '',
kind: kinds.HTTPAuth, kind: kinds.HTTPAuth,
created_at: dayjs().unix(), created_at: dayjs().unix(),
@@ -85,9 +120,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return 'Nostr ' + btoa(JSON.stringify(event)) return 'Nostr ' + btoa(JSON.stringify(event))
} }
const checkLogin = async (cb?: () => void) => {
if (pubkey) {
return cb && cb()
}
if (IS_ELECTRON) {
return setOpenLoginDialog(true)
}
try {
await nip07Login()
} catch (err) {
toast({
title: 'Login failed',
description: (err as Error).message,
variant: 'destructive'
})
return
}
return cb && cb()
}
return ( return (
<NostrContext.Provider value={{ pubkey, canLogin, login, logout, publish, signHttpAuth }}> <NostrContext.Provider
value={{ pubkey, canLogin, login, nip07Login, logout, publish, signHttpAuth, checkLogin }}
>
{children} {children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
</NostrContext.Provider> </NostrContext.Provider>
) )
} }

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { TTheme, TThemeSetting } from '@common/types' import { TTheme, TThemeSetting } from '@common/types'
import { isElectron } from '@renderer/lib/env'
import { createContext, useContext, useEffect, useState } from 'react'
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode children: React.ReactNode
@@ -8,36 +9,64 @@ type ThemeProviderProps = {
type ThemeProviderState = { type ThemeProviderState = {
themeSetting: TThemeSetting themeSetting: TThemeSetting
setThemeSetting: (themeSetting: TThemeSetting) => void setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
}
// web only
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} }
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined) const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [themeSetting, setThemeSetting] = useState<TThemeSetting>( const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
(localStorage.getItem('themeSetting') as TTheme) ?? 'system' (localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system'
) )
const [theme, setTheme] = useState<TTheme>('light') const [theme, setTheme] = useState<TTheme>('light')
useEffect(() => {
const init = async () => { const init = async () => {
// electron
if (isElectron(window)) {
const [themeSetting, theme] = await Promise.all([ const [themeSetting, theme] = await Promise.all([
window.api.theme.themeSetting(), window.api.theme.themeSetting(),
window.api.theme.current() window.api.theme.current()
]) ])
localStorage.setItem('theme', theme)
setTheme(theme) setTheme(theme)
setThemeSetting(themeSetting) setThemeSetting(themeSetting)
window.api.theme.onChange((theme) => { window.api.theme.onChange((theme) => {
localStorage.setItem('theme', theme)
setTheme(theme) setTheme(theme)
}) })
} else {
// web
if (themeSetting === 'system') {
setTheme(getSystemTheme())
return
}
setTheme(themeSetting)
}
} }
useEffect(() => {
init() init()
}, []) }, [])
useEffect(() => {
if (themeSetting !== 'system' || isElectron(window)) return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
setTheme(e.matches ? 'dark' : 'light')
}
mediaQuery.addEventListener('change', handleChange)
setTheme(mediaQuery.matches ? 'dark' : 'light')
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}, [themeSetting])
useEffect(() => { useEffect(() => {
const updateTheme = async () => { const updateTheme = async () => {
const root = window.document.documentElement const root = window.document.documentElement
@@ -50,8 +79,18 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const value = { const value = {
themeSetting: themeSetting, themeSetting: themeSetting,
setThemeSetting: (themeSetting: TThemeSetting) => { setThemeSetting: async (themeSetting: TThemeSetting) => {
window.api.theme.set(themeSetting).then(() => setThemeSetting(themeSetting)) if (isElectron(window)) {
await window.api.theme.set(themeSetting)
} else {
localStorage.setItem('themeSetting', themeSetting)
}
setThemeSetting(themeSetting)
if (themeSetting === 'system') {
setTheme(getSystemTheme())
return
}
setTheme(themeSetting)
} }
} }

View File

@@ -0,0 +1,21 @@
import { match } from 'path-to-regexp'
import { isValidElement } from 'react'
import FollowingListPage from './pages/secondary/FollowingListPage'
import HashtagPage from './pages/secondary/HashtagPage'
import HomePage from './pages/secondary/HomePage'
import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage'
const ROUTES = [
{ path: '/', element: <HomePage /> },
{ path: '/note/:id', element: <NotePage /> },
{ path: '/user/:id', element: <ProfilePage /> },
{ path: '/user/:id/following', element: <FollowingListPage /> },
{ path: '/hashtag/:id', element: <HashtagPage /> }
]
export const routes = ROUTES.map(({ path, element }) => ({
path,
element: isValidElement(element) ? element : null,
matcher: match(path)
}))

View File

@@ -23,21 +23,21 @@ class ClientService {
private relayUrls: string[] = BIG_RELAY_URLS private relayUrls: string[] = BIG_RELAY_URLS
private initPromise!: Promise<void> private initPromise!: Promise<void>
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ private eventByFilterCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000, max: 10000,
fetchMethod: async (filterStr) => { fetchMethod: async (filterStr) => {
const events = await this.fetchEvents( const events = await this.fetchEvents(
BIG_RELAY_URLS.concat(this.relayUrls), BIG_RELAY_URLS.concat(this.relayUrls),
JSON.parse(filterStr) JSON.parse(filterStr)
) )
events.forEach((event) => this.addEventToCache(event))
return events.sort((a, b) => b.created_at - a.created_at)[0] return events.sort((a, b) => b.created_at - a.created_at)[0]
} }
}) })
private eventByIdCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
private eventDataloader = new DataLoader<string, NEvent | undefined>( private eventDataloader = new DataLoader<string, NEvent | undefined>(
this.eventBatchLoadFn.bind(this), this.eventBatchLoadFn.bind(this),
{ { cacheMap: this.eventByIdCache }
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
}
) )
private profileDataloader = new DataLoader<string, TProfile | undefined>( private profileDataloader = new DataLoader<string, TProfile | undefined>(
this.profileBatchLoadFn.bind(this), this.profileBatchLoadFn.bind(this),
@@ -126,13 +126,17 @@ class ClientService {
} }
async fetchEventByFilter(filter: Filter) { async fetchEventByFilter(filter: Filter) {
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 })) return this.eventByFilterCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
} }
async fetchEventById(id: string): Promise<NEvent | undefined> { async fetchEventById(id: string): Promise<NEvent | undefined> {
return this.eventDataloader.load(id) return this.eventDataloader.load(id)
} }
addEventToCache(event: NEvent) {
this.eventByIdCache.set(event.id, Promise.resolve(event))
}
async fetchProfile(pubkey: string): Promise<TProfile | undefined> { async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
return this.profileDataloader.load(pubkey) return this.profileDataloader.load(pubkey)
} }

View File

@@ -1,5 +1,39 @@
import { TRelayGroup } from '@common/types' import { TRelayGroup } from '@common/types'
import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service' import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
import { isElectron } from '@renderer/lib/env'
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
{
groupName: 'Global',
relayUrls: [
'wss://relay.damus.io/',
'wss://nos.lol/',
'wss://nostr.mom/',
'wss://relay.primal.net/'
],
isActive: true
}
]
class Storage {
async getRelayGroups() {
if (isElectron(window)) {
const relayGroups = await window.api.storage.getRelayGroups()
return relayGroups ?? DEFAULT_RELAY_GROUPS
} else {
const relayGroupsStr = localStorage.getItem('relayGroups')
return relayGroupsStr ? (JSON.parse(relayGroupsStr) as TRelayGroup[]) : DEFAULT_RELAY_GROUPS
}
}
async setRelayGroups(relayGroups: TRelayGroup[]) {
if (isElectron(window)) {
return window.api.storage.setRelayGroups(relayGroups)
} else {
localStorage.setItem('relayGroups', JSON.stringify(relayGroups))
}
}
}
class StorageService { class StorageService {
static instance: StorageService static instance: StorageService
@@ -7,6 +41,7 @@ class StorageService {
private initPromise!: Promise<void> private initPromise!: Promise<void>
private relayGroups: TRelayGroup[] = [] private relayGroups: TRelayGroup[] = []
private activeRelayUrls: string[] = [] private activeRelayUrls: string[] = []
private storage: Storage = new Storage()
constructor() { constructor() {
if (!StorageService.instance) { if (!StorageService.instance) {
@@ -17,7 +52,7 @@ class StorageService {
} }
async init() { async init() {
this.relayGroups = await window.api.storage.getRelayGroups() this.relayGroups = await this.storage.getRelayGroups()
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? [] this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
} }
@@ -28,7 +63,7 @@ class StorageService {
async setRelayGroups(relayGroups: TRelayGroup[]) { async setRelayGroups(relayGroups: TRelayGroup[]) {
await this.initPromise await this.initPromise
await window.api.storage.setRelayGroups(relayGroups) await this.storage.setRelayGroups(relayGroups)
this.relayGroups = relayGroups this.relayGroups = relayGroups
const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
if ( if (

17
web.vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
root: path.resolve(__dirname, 'src/renderer'),
build: {
outDir: path.resolve(__dirname, 'dist/web'),
emptyOutDir: true
},
resolve: {
alias: {
'@renderer': path.resolve('src/renderer/src')
}
},
plugins: [react()]
})