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

9
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"lru-cache": "^11.0.1",
"lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.1.0",
"react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1",
@@ -7561,6 +7562,14 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"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": {
"version": "4.0.0",
"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",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev:web": "vite --config web.vite.config.ts",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win -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": {
"@electron-toolkit/preload": "^3.0.1",
@@ -47,6 +49,7 @@
"lru-cache": "^11.0.1",
"lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1",
"path-to-regexp": "^8.2.0",
"qrcode.react": "^4.1.0",
"react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1",

View File

@@ -1,3 +1,4 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools'
export type TRelayGroup = {
@@ -15,3 +16,33 @@ export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'
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[] {
return (
this.storage.get('relayGroups') ?? [
{
groupName: 'Global',
relayUrls: [
'wss://relay.damus.io/',
'wss://nos.lol/',
'wss://nostr.mom/',
'wss://relay.primal.net/'
],
isActive: true
}
]
)
getRelayGroups(): TRelayGroup[] | null {
return this.storage.get('relayGroups') ?? null
}
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,10 +24,14 @@ const api = {
},
nostr: {
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'),
signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent)
}
}
// Use `contextBridge` APIs to expose Electron APIs to
@@ -37,6 +41,7 @@ if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('nostr', nostr)
} catch (error) {
console.error(error)
}
@@ -45,4 +50,6 @@ if (process.contextIsolated) {
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
// @ts-ignore (define in dts)
window.nostr = nostr
}

View File

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>Jumble</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- <meta
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 { PageManager } from './PageManager'
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 { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider'
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 {
return (
<div className="h-screen">
@@ -29,7 +18,7 @@ export default function App(): JSX.Element {
<FollowListProvider>
<RelaySettingsProvider>
<NoteStatsProvider>
<PageManager routes={routes}>
<PageManager>
<NoteListPage />
</PageManager>
<Toaster />

View File

@@ -1,36 +1,28 @@
import Sidebar from '@renderer/components/Sidebar'
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup
} 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'
type TRoute = {
pageName: string
element: React.ReactNode
}
type TPushParams = {
pageName: string
props: any
}
import HomePage from '@renderer/pages/secondary/HomePage'
import NotFoundPage from '@renderer/pages/secondary/NotFoundPage'
import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
import { routes } from './routes'
type TPrimaryPageContext = {
refresh: () => void
}
type TSecondaryPageContext = {
push: (params: TPushParams) => void
push: (url: string) => void
pop: () => void
}
type TStackItem = {
index: number
pageName: string
props: any
component: React.ReactNode
url: string
component: React.ReactNode | null
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
@@ -54,56 +46,72 @@ export function useSecondaryPage() {
}
export function PageManager({
routes,
children,
maxStackSize = 5
}: {
routes: TRoute[]
children: React.ReactNode
maxStackSize?: number
}) {
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const routeMap = routes.reduce((acc, route) => {
acc[route.pageName] = route.element
return acc
}, {}) as Record<string, React.ReactNode>
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
)
useEffect(() => {
const url = window.location.pathname
if (url !== '/') {
pushSecondary(url)
}
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) => {
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()
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
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 (
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
<div className="flex h-full">
<Sidebar />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={55} minSize={30}>
<div key={primaryPageKey} className="h-full">
@@ -123,10 +131,11 @@ export function PageManager({
</div>
))
) : (
<BlankPage />
<HomePage />
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
@@ -138,7 +147,7 @@ export function SecondaryPageLink({
className,
onClick
}: {
to: TPushParams
to: string
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
@@ -157,3 +166,33 @@ export function SecondaryPageLink({
</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'
export function EmbeddedNote({ noteId }: { noteId: string }) {
const event = useFetchEventById(noteId)
const { event } = useFetchEventById(noteId)
return event && event.kind === kinds.ShortTextNote ? (
<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 { useToast } from '@renderer/hooks'
import { useFollowList } from '@renderer/providers/FollowListProvider'
import { useNostr } from '@renderer/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
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 [updating, setUpdating] = useState(false)
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) => {
e.stopPropagation()
checkLogin(async () => {
if (isFollowing) return
setUpdating(true)
try {
await follow(pubkey)
} catch (error) {
console.error(error)
toast({
title: 'Follow failed',
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
const handleUnfollow = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isFollowing || !followListEvent) return
setUpdating(true)
try {
await unfollow(pubkey)
} catch (error) {
console.error(error)
toast({
title: 'Unfollow failed',
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
return isFollowing ? (

View File

@@ -1,5 +1,4 @@
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
import { cn } from '@renderer/lib/utils'
import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
@@ -24,13 +23,13 @@ export default function ImageGallery({
}
return (
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
<div className={className} onClick={(e) => e.stopPropagation()}>
<ScrollArea className="w-full">
<div className="flex space-x-2">
{images.map((src, index) => {
return (
<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}
src={src}
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"
onClick={(e) => {
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 { Event, kinds, verifyEvent } from 'nostr-tools'
import Username from '../Username'
@@ -9,6 +10,8 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
return null
}
client.addEventToCache(targetEvent)
return (
<div className={className}>
<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
}) {
const { push } = useSecondaryPage()
const rootEvent = useFetchEventById(getRootEventId(event))
const parentEvent = useFetchEventById(getParentEventId(event))
const { event: rootEvent } = useFetchEventById(getRootEventId(event))
const { event: parentEvent } = useFetchEventById(getParentEventId(event))
return (
<div
className={className}
onClick={(e) => {
e.stopPropagation()
push(toNote(event))
push(toNote(event.id))
}}
>
<Card

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,9 @@ import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
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])
return (

View File

@@ -2,11 +2,16 @@ import { Button } from '@renderer/components/ui/button'
import { usePrimaryPage } from '@renderer/PageManager'
import { RefreshCcw } from 'lucide-react'
export default function RefreshButton() {
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { refresh } = usePrimaryPage()
return (
<Button variant="titlebar" size="titlebar" onClick={refresh} title="reload">
<Button variant={variant} size={variant} onClick={refresh} title="reload">
<RefreshCcw />
{variant === 'sidebar' && <div>Refresh</div>}
</Button>
)
}

View File

@@ -4,15 +4,23 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Server } from 'lucide-react'
export default function RelaySettingsPopover() {
export default function RelaySettingsPopover({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="titlebar" size="titlebar" title="relay settings">
<Button variant={variant} size={variant} title="relay settings">
<Server />
{variant === 'sidebar' && <div>Relays</div>}
</Button>
</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">
<div className="p-4">
<RelaySettings />

View File

@@ -30,7 +30,7 @@ export default function ScrollToTopButton({
return (
<Button
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}
>
<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 (
<div
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
)}
>

View File

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

View File

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

View File

@@ -16,14 +16,16 @@ const buttonVariants = cva(
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
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'
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground',
sidebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
},
size: {
default: 'h-8 rounded-lg px-3',
sm: 'h-8 rounded-lg px-2',
lg: 'h-10 px-4 py-2',
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: {

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<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
className={cn('rounded-lg border bg-card text-card-foreground', className)}
{...props}
/>
))
Card.displayName = "Card"
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = "CardDescription"
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -16,6 +16,7 @@ const PopoverContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
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',
className

View File

@@ -1 +1,12 @@
/// <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'
export function useFetchEventById(id?: string) {
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(undefined)
useEffect(() => {
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)) {
filter = { ids: [id] }
} else {
@@ -19,9 +25,7 @@ export function useFetchEventById(id?: string) {
filter = { ids: [data] }
break
case 'nevent':
if (data.id) {
filter.ids = [data.id]
}
filter = { ids: [data.id] }
break
case 'naddr':
filter = {
@@ -34,8 +38,11 @@ export function useFetchEventById(id?: string) {
}
}
}
if (!filter) return
if (!filter) {
setIsFetching(false)
setError(new Error('Invalid id'))
return
}
let event: Event | undefined
if (filter.ids) {
@@ -45,13 +52,15 @@ export function useFetchEventById(id?: string) {
}
if (event) {
setEvent(event)
} else {
setEvent(undefined)
}
setIsFetching(false)
}
fetchEvent()
fetchEvent().catch((err) => {
setError(err as Error)
setIsFetching(false)
})
}, [id])
return event
return { isFetching, error, event }
}

View File

@@ -5,6 +5,8 @@ import { nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
export function useFetchProfile(id?: string) {
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile>({
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
})
@@ -12,7 +14,11 @@ export function useFetchProfile(id?: string) {
useEffect(() => {
const fetchProfile = async () => {
try {
if (!id) return
if (!id) {
setIsFetching(false)
setError(new Error('No id provided'))
return
}
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) })
const profile = await client.fetchProfile(pubkey)
@@ -38,12 +48,14 @@ export function useFetchProfile(id?: string) {
setProfile(profile)
}
} catch (err) {
console.error(err)
setError(err as Error)
} finally {
setIsFetching(false)
}
}
fetchProfile()
}, [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 { Titlebar } from '@renderer/components/Titlebar'
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 AccountButton from './AccountButton'
import PostButton from './PostButton'
import RefreshButton from './RefreshButton'
import RelaySettingsPopover from './RelaySettingsPopover'
const PrimaryPageLayout = forwardRef(
(
@@ -26,13 +26,9 @@ const PrimaryPageLayout = forwardRef(
)
return (
<ScrollArea
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
<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} />
</ScrollArea>
)
@@ -47,7 +43,7 @@ export type TPrimaryPageLayoutRef = {
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
return (
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
<div className="flex gap-2 items-center">
<AccountButton />
<PostButton />

View File

@@ -1,27 +1,22 @@
import BackButton from '@renderer/components/BackButton'
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 { isMacOS } from '@renderer/lib/platform'
import { useRef } from 'react'
import { Titlebar } from '../../components/Titlebar'
import BackButton from './BackButton'
import ThemeToggle from './ThemeToggle'
export default function SecondaryPageLayout({
children,
titlebarContent,
hideBackButton = false
}: {
children: React.ReactNode
children?: React.ReactNode
titlebarContent?: React.ReactNode
hideBackButton?: boolean
}): JSX.Element {
const scrollAreaRef = useRef<HTMLDivElement>(null)
return (
<ScrollArea
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<ScrollArea ref={scrollAreaRef} className="h-full" scrollBarClassName="pt-9">
<SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} />
<div className="px-4 pb-4 pt-11 w-full h-full">{children}</div>
<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 toNote = (event: Event) => ({ pageName: 'note', props: { event } })
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 { useEffect, useRef, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const { username } = useFetchProfile(pubkey)
export default function FollowingListPage({ id }: { id?: string }) {
const {
profile: { username, pubkey }
} = useFetchProfile(id)
const { followings } = useFetchFollowings(pubkey)
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
const observer = useRef<IntersectionObserver | null>(null)
@@ -57,7 +59,9 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
}
function FollowingItem({ pubkey }: { pubkey: string }) {
const { about, nip05 } = useFetchProfile(pubkey)
const {
profile: { about, nip05 }
} = useFetchProfile(pubkey)
return (
<div className="flex gap-2 items-start">

View File

@@ -1,21 +1,18 @@
import NoteList from '@renderer/components/NoteList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
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()
if (!hashtag) {
return null
if (!id) {
return <NotFoundPage />
}
const normalizedHashtag = hashtag.toLowerCase()
const hashtag = id.toLowerCase()
return (
<SecondaryPageLayout titlebarContent={`# ${normalizedHashtag}`}>
<NoteList
key={normalizedHashtag}
filter={{ '#t': [normalizedHashtag] }}
relayUrls={relayUrls}
/>
<SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
<NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
</SecondaryPageLayout>
)
}

View File

@@ -1,6 +1,6 @@
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
export default function BlankPage() {
export default function HomePage() {
return (
<SecondaryPageLayout hideBackButton>
<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 { getParentEventId, getRootEventId } from '@renderer/lib/event'
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 }) {
const parentEvent = useFetchEventById(getParentEventId(event))
const rootEvent = useFetchEventById(getRootEventId(event))
export default function NotePage({ id }: { id?: string }) {
const { event, isFetching } = useFetchEventById(id)
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 (
<SecondaryPageLayout titlebarContent="note">
{rootEvent && <ParentNote key={`root-note-${event.id}`} event={rootEvent} />}
{parentEvent && <ParentNote key={`parent-note-${event.id}`} event={parentEvent} />}
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
<Separator className="my-4" />
<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 { event } = useFetchEventById(eventId)
if (!event) return null
return (
<div>
<Card
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" />
<Username userId={event.pubkey} className="font-semibold" />

View File

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

View File

@@ -1,5 +1,8 @@
import { TDraftEvent } from '@common/types'
import LoginDialog from '@renderer/components/LoginDialog'
import { useToast } from '@renderer/hooks'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import { IS_ELECTRON, isElectron } from '@renderer/lib/env'
import client from '@renderer/services/client.service'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
@@ -10,11 +13,13 @@ type TNostrContext = {
canLogin: boolean
login: (nsec: string) => Promise<string>
logout: () => Promise<void>
nip07Login: () => Promise<string>
/**
* Default publish the event to current relays, user's write relays and additional relays
*/
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string>
checkLogin: (cb?: () => void | Promise<void>) => void
}
const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -28,25 +33,34 @@ export const useNostr = () => {
}
export function NostrProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast()
const [pubkey, setPubkey] = useState<string | null>(null)
const [canLogin, setCanLogin] = useState(false)
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const relayList = useFetchRelayList(pubkey)
useEffect(() => {
window.api.nostr.getPublicKey().then((pubkey) => {
window.nostr?.getPublicKey().then((pubkey) => {
if (pubkey) {
setPubkey(pubkey)
}
})
window.api.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
if (isElectron(window)) {
window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
setCanLogin(isEncryptionAvailable)
})
} else {
setCanLogin(!!window.nostr)
}
}, [])
const login = async (nsec: string) => {
if (!canLogin) {
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)
if (!pubkey) {
throw new Error(reason ?? 'invalid nsec')
@@ -55,13 +69,34 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 () => {
if (isElectron(window)) {
await window.api.nostr.logout()
}
setPubkey(null)
}
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
const event = await window.api.nostr.signEvent(draftEvent)
const event = await window.nostr?.signEvent(draftEvent)
if (!event) {
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 event = await window.api.nostr.signEvent({
const event = await window.nostr?.signEvent({
content: '',
kind: kinds.HTTPAuth,
created_at: dayjs().unix(),
@@ -85,9 +120,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 (
<NostrContext.Provider value={{ pubkey, canLogin, login, logout, publish, signHttpAuth }}>
<NostrContext.Provider
value={{ pubkey, canLogin, login, nip07Login, logout, publish, signHttpAuth, checkLogin }}
>
{children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
</NostrContext.Provider>
)
}

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { TTheme, TThemeSetting } from '@common/types'
import { isElectron } from '@renderer/lib/env'
import { createContext, useContext, useEffect, useState } from 'react'
type ThemeProviderProps = {
children: React.ReactNode
@@ -8,36 +9,64 @@ type ThemeProviderProps = {
type ThemeProviderState = {
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)
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
(localStorage.getItem('themeSetting') as TTheme) ?? 'system'
(localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system'
)
const [theme, setTheme] = useState<TTheme>('light')
useEffect(() => {
const init = async () => {
// electron
if (isElectron(window)) {
const [themeSetting, theme] = await Promise.all([
window.api.theme.themeSetting(),
window.api.theme.current()
])
localStorage.setItem('theme', theme)
setTheme(theme)
setThemeSetting(themeSetting)
window.api.theme.onChange((theme) => {
localStorage.setItem('theme', theme)
setTheme(theme)
})
} else {
// web
if (themeSetting === 'system') {
setTheme(getSystemTheme())
return
}
setTheme(themeSetting)
}
}
useEffect(() => {
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(() => {
const updateTheme = async () => {
const root = window.document.documentElement
@@ -50,8 +79,18 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const value = {
themeSetting: themeSetting,
setThemeSetting: (themeSetting: TThemeSetting) => {
window.api.theme.set(themeSetting).then(() => setThemeSetting(themeSetting))
setThemeSetting: async (themeSetting: TThemeSetting) => {
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 initPromise!: Promise<void>
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
private eventByFilterCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
fetchMethod: async (filterStr) => {
const events = await this.fetchEvents(
BIG_RELAY_URLS.concat(this.relayUrls),
JSON.parse(filterStr)
)
events.forEach((event) => this.addEventToCache(event))
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>(
this.eventBatchLoadFn.bind(this),
{
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
}
{ cacheMap: this.eventByIdCache }
)
private profileDataloader = new DataLoader<string, TProfile | undefined>(
this.profileBatchLoadFn.bind(this),
@@ -126,13 +126,17 @@ class ClientService {
}
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> {
return this.eventDataloader.load(id)
}
addEventToCache(event: NEvent) {
this.eventByIdCache.set(event.id, Promise.resolve(event))
}
async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
return this.profileDataloader.load(pubkey)
}

View File

@@ -1,5 +1,39 @@
import { TRelayGroup } from '@common/types'
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 {
static instance: StorageService
@@ -7,6 +41,7 @@ class StorageService {
private initPromise!: Promise<void>
private relayGroups: TRelayGroup[] = []
private activeRelayUrls: string[] = []
private storage: Storage = new Storage()
constructor() {
if (!StorageService.instance) {
@@ -17,7 +52,7 @@ class StorageService {
}
async init() {
this.relayGroups = await window.api.storage.getRelayGroups()
this.relayGroups = await this.storage.getRelayGroups()
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
}
@@ -28,7 +63,7 @@ class StorageService {
async setRelayGroups(relayGroups: TRelayGroup[]) {
await this.initPromise
await window.api.storage.setRelayGroups(relayGroups)
await this.storage.setRelayGroups(relayGroups)
this.relayGroups = relayGroups
const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
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()]
})