feat: web (#6)
This commit is contained in:
@@ -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
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
33
src/preload/index.d.ts
vendored
33
src/preload/index.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
29
src/renderer/src/components/AccountButton/LoginButton.tsx
Normal file
29
src/renderer/src/components/AccountButton/LoginButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/renderer/src/components/AccountButton/ProfileButton.tsx
Normal file
70
src/renderer/src/components/AccountButton/ProfileButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
src/renderer/src/components/AccountButton/index.tsx
Normal file
17
src/renderer/src/components/AccountButton/index.tsx
Normal 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} />
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
65
src/renderer/src/components/LoginDialog/index.tsx
Normal file
65
src/renderer/src/components/LoginDialog/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default function Note({
|
||||
className="mt-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEvent))
|
||||
push(toNote(parentEvent.id))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
@@ -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 />
|
||||
|
||||
22
src/renderer/src/components/Sidebar/index.tsx
Normal file
22
src/renderer/src/components/Sidebar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
11
src/renderer/src/env.d.ts
vendored
11
src/renderer/src/env.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
11
src/renderer/src/lib/env.ts
Normal file
11
src/renderer/src/lib/env.ts
Normal 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)
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isMacOS() {
|
||||
return window.electron.process.platform === 'darwin'
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
11
src/renderer/src/pages/secondary/LoadingPage/index.tsx
Normal file
11
src/renderer/src/pages/secondary/LoadingPage/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/renderer/src/pages/secondary/NotFoundPage/index.tsx
Normal file
19
src/renderer/src/pages/secondary/NotFoundPage/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/renderer/src/routes.tsx
Normal file
21
src/renderer/src/routes.tsx
Normal 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)
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
17
web.vite.config.ts
Normal 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()]
|
||||
})
|
||||
Reference in New Issue
Block a user