feat: web (#6)
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
# jumble
|
# jumble
|
||||||
|
|
||||||
Yet another Nostr desktop client
|
Yet another Nostr client
|
||||||
|
|
||||||
> NOTE: Currently, only browsing is supported. Posting, liking, and reposting will be available soon.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -10,6 +8,7 @@ Yet another Nostr desktop client
|
|||||||
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
|
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
|
||||||
- **Relay Groups:** Easily manage and switch between relay groups
|
- **Relay Groups:** Easily manage and switch between relay groups
|
||||||
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
|
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
|
||||||
|
- **Cross-Platform:** Available on macOS, Windows, Linux, and web browsers
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"lru-cache": "^11.0.1",
|
"lru-cache": "^11.0.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"nostr-tools": "^2.9.1",
|
"nostr-tools": "^2.9.1",
|
||||||
|
"path-to-regexp": "^8.2.0",
|
||||||
"qrcode.react": "^4.1.0",
|
"qrcode.react": "^4.1.0",
|
||||||
"react-resizable-panels": "^2.1.5",
|
"react-resizable-panels": "^2.1.5",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
@@ -7561,6 +7562,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-type": {
|
"node_modules/path-type": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
|
"dev:web": "vite --config web.vite.config.ts",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win -p never",
|
"build:win": "npm run build && electron-builder --win -p never",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac -p never",
|
"build:mac": "electron-vite build && electron-builder --mac -p never",
|
||||||
"build:linux": "electron-vite build && electron-builder --linux -p never"
|
"build:linux": "electron-vite build && electron-builder --linux -p never",
|
||||||
|
"build:web": "vite build --config web.vite.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"lru-cache": "^11.0.1",
|
"lru-cache": "^11.0.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"nostr-tools": "^2.9.1",
|
"nostr-tools": "^2.9.1",
|
||||||
|
"path-to-regexp": "^8.2.0",
|
||||||
"qrcode.react": "^4.1.0",
|
"qrcode.react": "^4.1.0",
|
||||||
"react-resizable-panels": "^2.1.5",
|
"react-resizable-panels": "^2.1.5",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
|
|
||||||
export type TRelayGroup = {
|
export type TRelayGroup = {
|
||||||
@@ -15,3 +16,33 @@ export type TThemeSetting = 'light' | 'dark' | 'system'
|
|||||||
export type TTheme = 'light' | 'dark'
|
export type TTheme = 'light' | 'dark'
|
||||||
|
|
||||||
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
|
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
|
||||||
|
|
||||||
|
export type TElectronWindow = {
|
||||||
|
electron: ElectronAPI
|
||||||
|
api: {
|
||||||
|
system: {
|
||||||
|
isEncryptionAvailable: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
theme: {
|
||||||
|
onChange: (cb: (theme: TTheme) => void) => void
|
||||||
|
current: () => Promise<TTheme>
|
||||||
|
themeSetting: () => Promise<TThemeSetting>
|
||||||
|
set: (themeSetting: TThemeSetting) => Promise<void>
|
||||||
|
}
|
||||||
|
storage: {
|
||||||
|
getRelayGroups: () => Promise<TRelayGroup[]>
|
||||||
|
setRelayGroups: (relayGroups: TRelayGroup[]) => Promise<void>
|
||||||
|
}
|
||||||
|
nostr: {
|
||||||
|
login: (nsec: string) => Promise<{
|
||||||
|
pubkey?: string
|
||||||
|
reason?: string
|
||||||
|
}>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nostr: {
|
||||||
|
getPublicKey: () => Promise<string | null>
|
||||||
|
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,21 +17,8 @@ export class StorageService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getRelayGroups(): TRelayGroup[] {
|
getRelayGroups(): TRelayGroup[] | null {
|
||||||
return (
|
return this.storage.get('relayGroups') ?? null
|
||||||
this.storage.get('relayGroups') ?? [
|
|
||||||
{
|
|
||||||
groupName: 'Global',
|
|
||||||
relayUrls: [
|
|
||||||
'wss://relay.damus.io/',
|
|
||||||
'wss://nos.lol/',
|
|
||||||
'wss://nostr.mom/',
|
|
||||||
'wss://relay.primal.net/'
|
|
||||||
],
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelayGroups(relayGroups: TRelayGroup[]) {
|
setRelayGroups(relayGroups: TRelayGroup[]) {
|
||||||
|
|||||||
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,11 +24,15 @@ const api = {
|
|||||||
},
|
},
|
||||||
nostr: {
|
nostr: {
|
||||||
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
|
login: (nsec: string) => ipcRenderer.invoke('nostr:login', nsec),
|
||||||
logout: () => ipcRenderer.invoke('nostr:logout'),
|
logout: () => ipcRenderer.invoke('nostr:logout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-07
|
||||||
|
const nostr = {
|
||||||
getPublicKey: () => ipcRenderer.invoke('nostr:getPublicKey'),
|
getPublicKey: () => ipcRenderer.invoke('nostr:getPublicKey'),
|
||||||
signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent)
|
signEvent: (draftEvent: TDraftEvent) => ipcRenderer.invoke('nostr:signEvent', draftEvent)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Use `contextBridge` APIs to expose Electron APIs to
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
// renderer only if context isolation is enabled, otherwise
|
// renderer only if context isolation is enabled, otherwise
|
||||||
@@ -37,6 +41,7 @@ if (process.contextIsolated) {
|
|||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
contextBridge.exposeInMainWorld('nostr', nostr)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -45,4 +50,6 @@ if (process.contextIsolated) {
|
|||||||
window.electron = electronAPI
|
window.electron = electronAPI
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore (define in dts)
|
||||||
window.api = api
|
window.api = api
|
||||||
|
// @ts-ignore (define in dts)
|
||||||
|
window.nostr = nostr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Electron</title>
|
<title>Jumble</title>
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<!-- <meta
|
<!-- <meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
|
|||||||
@@ -5,22 +5,11 @@ import { Toaster } from '@renderer/components/ui/toaster'
|
|||||||
import { ThemeProvider } from '@renderer/providers/ThemeProvider'
|
import { ThemeProvider } from '@renderer/providers/ThemeProvider'
|
||||||
import { PageManager } from './PageManager'
|
import { PageManager } from './PageManager'
|
||||||
import NoteListPage from './pages/primary/NoteListPage'
|
import NoteListPage from './pages/primary/NoteListPage'
|
||||||
import FollowingListPage from './pages/secondary/FollowingListPage'
|
|
||||||
import HashtagPage from './pages/secondary/HashtagPage'
|
|
||||||
import NotePage from './pages/secondary/NotePage'
|
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
|
||||||
import { FollowListProvider } from './providers/FollowListProvider'
|
import { FollowListProvider } from './providers/FollowListProvider'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{ pageName: 'note', element: <NotePage /> },
|
|
||||||
{ pageName: 'profile', element: <ProfilePage /> },
|
|
||||||
{ pageName: 'hashtag', element: <HashtagPage /> },
|
|
||||||
{ pageName: 'followingList', element: <FollowingListPage /> }
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen">
|
<div className="h-screen">
|
||||||
@@ -29,7 +18,7 @@ export default function App(): JSX.Element {
|
|||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<RelaySettingsProvider>
|
<RelaySettingsProvider>
|
||||||
<NoteStatsProvider>
|
<NoteStatsProvider>
|
||||||
<PageManager routes={routes}>
|
<PageManager>
|
||||||
<NoteListPage />
|
<NoteListPage />
|
||||||
</PageManager>
|
</PageManager>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
|
import Sidebar from '@renderer/components/Sidebar'
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup
|
ResizablePanelGroup
|
||||||
} from '@renderer/components/ui/resizable'
|
} from '@renderer/components/ui/resizable'
|
||||||
import { cloneElement, createContext, isValidElement, useContext, useState } from 'react'
|
|
||||||
import BlankPage from './pages/secondary/BlankPage'
|
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import HomePage from '@renderer/pages/secondary/HomePage'
|
||||||
type TRoute = {
|
import NotFoundPage from '@renderer/pages/secondary/NotFoundPage'
|
||||||
pageName: string
|
import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
|
||||||
element: React.ReactNode
|
import { routes } from './routes'
|
||||||
}
|
|
||||||
|
|
||||||
type TPushParams = {
|
|
||||||
pageName: string
|
|
||||||
props: any
|
|
||||||
}
|
|
||||||
|
|
||||||
type TPrimaryPageContext = {
|
type TPrimaryPageContext = {
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type TSecondaryPageContext = {
|
type TSecondaryPageContext = {
|
||||||
push: (params: TPushParams) => void
|
push: (url: string) => void
|
||||||
pop: () => void
|
pop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type TStackItem = {
|
type TStackItem = {
|
||||||
index: number
|
index: number
|
||||||
pageName: string
|
url: string
|
||||||
props: any
|
component: React.ReactNode | null
|
||||||
component: React.ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||||
@@ -54,56 +46,72 @@ export function useSecondaryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PageManager({
|
export function PageManager({
|
||||||
routes,
|
|
||||||
children,
|
children,
|
||||||
maxStackSize = 5
|
maxStackSize = 5
|
||||||
}: {
|
}: {
|
||||||
routes: TRoute[]
|
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
maxStackSize?: number
|
maxStackSize?: number
|
||||||
}) {
|
}) {
|
||||||
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
|
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
|
||||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||||
|
|
||||||
const routeMap = routes.reduce((acc, route) => {
|
useEffect(() => {
|
||||||
acc[route.pageName] = route.element
|
const url = window.location.pathname
|
||||||
return acc
|
if (url !== '/') {
|
||||||
}, {}) as Record<string, React.ReactNode>
|
pushSecondary(url)
|
||||||
|
|
||||||
const isCurrentPage = (stack: TStackItem[], { pageName, props }: TPushParams) => {
|
|
||||||
const currentPage = stack[stack.length - 1]
|
|
||||||
if (!currentPage) return false
|
|
||||||
|
|
||||||
return (
|
|
||||||
currentPage.pageName === pageName &&
|
|
||||||
JSON.stringify(currentPage.props) === JSON.stringify(props) // TODO: deep compare
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
|
const onPopState = (e: PopStateEvent) => {
|
||||||
|
const state = e.state ?? { index: -1, url: '/' }
|
||||||
|
setSecondaryStack((pre) => {
|
||||||
|
const currentItem = pre[pre.length - 1]
|
||||||
|
const currentIndex = currentItem ? currentItem.index : -1
|
||||||
|
if (state.index === currentIndex) {
|
||||||
|
return pre
|
||||||
|
}
|
||||||
|
if (state.index < currentIndex) {
|
||||||
|
const newStack = pre.filter((item) => item.index <= state.index)
|
||||||
|
const topItem = newStack[newStack.length - 1]
|
||||||
|
if (topItem && !topItem.component) {
|
||||||
|
topItem.component = findAndCreateComponent(topItem.url)
|
||||||
|
}
|
||||||
|
return newStack
|
||||||
|
}
|
||||||
|
|
||||||
const pushSecondary = ({ pageName, props }: TPushParams) => {
|
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
|
||||||
if (isCurrentPage(secondaryStack, { pageName, props })) return
|
|
||||||
|
|
||||||
const element = routeMap[pageName]
|
|
||||||
if (!element) return
|
|
||||||
if (!isValidElement(element)) return
|
|
||||||
|
|
||||||
setSecondaryStack((prevStack) => {
|
|
||||||
const currentStack = prevStack[prevStack.length - 1]
|
|
||||||
const index = currentStack ? currentStack.index + 1 : 0
|
|
||||||
const component = cloneElement(element, props)
|
|
||||||
const newStack = [...prevStack, { index, pageName, props, component }]
|
|
||||||
if (newStack.length > maxStackSize) newStack.shift()
|
|
||||||
return newStack
|
return newStack
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1))
|
window.addEventListener('popstate', onPopState)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', onPopState)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
|
||||||
|
|
||||||
|
const pushSecondary = (url: string) => {
|
||||||
|
setSecondaryStack((prevStack) => {
|
||||||
|
if (isCurrentPage(prevStack, url)) return prevStack
|
||||||
|
|
||||||
|
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize)
|
||||||
|
if (newItem) {
|
||||||
|
window.history.pushState({ index: newItem.index, url }, '', url)
|
||||||
|
}
|
||||||
|
return newStack
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const popSecondary = () => {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
||||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<Sidebar />
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
<ResizablePanel defaultSize={55} minSize={30}>
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
<div key={primaryPageKey} className="h-full">
|
<div key={primaryPageKey} className="h-full">
|
||||||
@@ -123,10 +131,11 @@ export function PageManager({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<BlankPage />
|
<HomePage />
|
||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
</PrimaryPageContext.Provider>
|
</PrimaryPageContext.Provider>
|
||||||
)
|
)
|
||||||
@@ -138,7 +147,7 @@ export function SecondaryPageLink({
|
|||||||
className,
|
className,
|
||||||
onClick
|
onClick
|
||||||
}: {
|
}: {
|
||||||
to: TPushParams
|
to: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: (e: React.MouseEvent) => void
|
onClick?: (e: React.MouseEvent) => void
|
||||||
@@ -157,3 +166,33 @@ export function SecondaryPageLink({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentPage(stack: TStackItem[], url: string) {
|
||||||
|
const currentPage = stack[stack.length - 1]
|
||||||
|
if (!currentPage) return false
|
||||||
|
|
||||||
|
return currentPage.url === url
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAndCreateComponent(url: string) {
|
||||||
|
for (const { matcher, element } of routes) {
|
||||||
|
const match = matcher(url)
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
if (!element) return <NotFoundPage />
|
||||||
|
return cloneElement(element, match.params)
|
||||||
|
}
|
||||||
|
return <NotFoundPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) {
|
||||||
|
const component = findAndCreateComponent(url)
|
||||||
|
const currentStack = stack[stack.length - 1]
|
||||||
|
const newItem = { component, url, index: currentStack ? currentStack.index + 1 : 0 }
|
||||||
|
const newStack = [...stack, newItem]
|
||||||
|
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
|
||||||
|
if (newStack.length - lastCachedIndex > maxStackSize) {
|
||||||
|
newStack[lastCachedIndex].component = null
|
||||||
|
}
|
||||||
|
return { newStack, newItem }
|
||||||
|
}
|
||||||
|
|||||||
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'
|
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'
|
||||||
|
|
||||||
export function EmbeddedNote({ noteId }: { noteId: string }) {
|
export function EmbeddedNote({ noteId }: { noteId: string }) {
|
||||||
const event = useFetchEventById(noteId)
|
const { event } = useFetchEventById(noteId)
|
||||||
|
|
||||||
return event && event.kind === kinds.ShortTextNote ? (
|
return event && event.kind === kinds.ShortTextNote ? (
|
||||||
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} hideStats />
|
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} hideStats />
|
||||||
|
|||||||
@@ -1,43 +1,57 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { useToast } from '@renderer/hooks'
|
||||||
import { useFollowList } from '@renderer/providers/FollowListProvider'
|
import { useFollowList } from '@renderer/providers/FollowListProvider'
|
||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
import { Loader } from 'lucide-react'
|
import { Loader } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { toast } = useToast()
|
||||||
|
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||||
|
|
||||||
if (!accountPubkey || pubkey === accountPubkey || !isReady) return null
|
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
|
||||||
|
|
||||||
const handleFollow = async (e: React.MouseEvent) => {
|
const handleFollow = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
checkLogin(async () => {
|
||||||
if (isFollowing) return
|
if (isFollowing) return
|
||||||
|
|
||||||
setUpdating(true)
|
setUpdating(true)
|
||||||
try {
|
try {
|
||||||
await follow(pubkey)
|
await follow(pubkey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
toast({
|
||||||
|
title: 'Follow failed',
|
||||||
|
description: (error as Error).message,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setUpdating(false)
|
setUpdating(false)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUnfollow = async (e: React.MouseEvent) => {
|
const handleUnfollow = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
checkLogin(async () => {
|
||||||
if (!isFollowing || !followListEvent) return
|
if (!isFollowing || !followListEvent) return
|
||||||
|
|
||||||
setUpdating(true)
|
setUpdating(true)
|
||||||
try {
|
try {
|
||||||
await unfollow(pubkey)
|
await unfollow(pubkey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
toast({
|
||||||
|
title: 'Unfollow failed',
|
||||||
|
description: (error as Error).message,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setUpdating(false)
|
setUpdating(false)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return isFollowing ? (
|
return isFollowing ? (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
|
||||||
import { cn } from '@renderer/lib/utils'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Lightbox from 'yet-another-react-lightbox'
|
import Lightbox from 'yet-another-react-lightbox'
|
||||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||||
@@ -24,13 +23,13 @@ export default function ImageGallery({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
|
<div className={className} onClick={(e) => e.stopPropagation()}>
|
||||||
<ScrollArea className="w-full">
|
<ScrollArea className="w-full">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{images.map((src, index) => {
|
{images.map((src, index) => {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className={`rounded-lg h-fit w-fit cursor-pointer ${size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]'}`}
|
className={`rounded-lg object-cover h-fit cursor-pointer ${size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]'}`}
|
||||||
key={index}
|
key={index}
|
||||||
src={src}
|
src={src}
|
||||||
onClick={(e) => handlePhotoClick(e, index)}
|
onClick={(e) => handlePhotoClick(e, index)}
|
||||||
|
|||||||
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"
|
className="mt-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
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 { Repeat2 } from 'lucide-react'
|
||||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
@@ -9,6 +10,8 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.addEventToCache(targetEvent)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">
|
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ export default function ShortTextNoteCard({
|
|||||||
hideStats?: boolean
|
hideStats?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const rootEvent = useFetchEventById(getRootEventId(event))
|
const { event: rootEvent } = useFetchEventById(getRootEventId(event))
|
||||||
const parentEvent = useFetchEventById(getParentEventId(event))
|
const { event: parentEvent } = useFetchEventById(getParentEventId(event))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
push(toNote(event))
|
push(toNote(event.id))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export default function NoteList({
|
|||||||
setUntil(events[events.length - 1].created_at - 1)
|
setUntil(events[events.length - 1].created_at - 1)
|
||||||
}
|
}
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
|
processedEvents.forEach((e) => {
|
||||||
|
client.addEventToCache(e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
if (!isReplyNoteEvent(event)) {
|
if (!isReplyNoteEvent(event)) {
|
||||||
@@ -100,6 +103,9 @@ export default function NoteList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
||||||
|
processedEvents.forEach((e) => {
|
||||||
|
client.addEventToCache(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNewEvents = () => {
|
const showNewEvents = () => {
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export default function LikeButton({
|
|||||||
variant?: 'normal' | 'reply'
|
variant?: 'normal' | 'reply'
|
||||||
canFetch?: boolean
|
canFetch?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { pubkey, publish } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||||
const [liking, setLiking] = useState(false)
|
const [liking, setLiking] = useState(false)
|
||||||
const { likeCount, hasLiked } = useMemo(
|
const { likeCount, hasLiked } = useMemo(
|
||||||
() => noteStatsMap.get(event.id) ?? {},
|
() => noteStatsMap.get(event.id) ?? {},
|
||||||
[noteStatsMap, event.id]
|
[noteStatsMap, event.id]
|
||||||
)
|
)
|
||||||
const canLike = pubkey && !hasLiked && !liking
|
const canLike = !hasLiked && !liking
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canFetch) return
|
if (!canFetch) return
|
||||||
@@ -39,6 +39,7 @@ export default function LikeButton({
|
|||||||
|
|
||||||
const like = async (e: React.MouseEvent) => {
|
const like = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
checkLogin(async () => {
|
||||||
if (!canLike) return
|
if (!canLike) return
|
||||||
|
|
||||||
setLiking(true)
|
setLiking(true)
|
||||||
@@ -61,6 +62,7 @@ export default function LikeButton({
|
|||||||
setLiking(false)
|
setLiking(false)
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function RepostButton({
|
|||||||
event: Event
|
event: Event
|
||||||
canFetch?: boolean
|
canFetch?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { pubkey, publish } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||||
useNoteStats()
|
useNoteStats()
|
||||||
const [reposting, setReposting] = useState(false)
|
const [reposting, setReposting] = useState(false)
|
||||||
@@ -34,7 +34,7 @@ export default function RepostButton({
|
|||||||
() => noteStatsMap.get(event.id) ?? {},
|
() => noteStatsMap.get(event.id) ?? {},
|
||||||
[noteStatsMap, event.id]
|
[noteStatsMap, event.id]
|
||||||
)
|
)
|
||||||
const canRepost = pubkey && !hasReposted && !reposting
|
const canRepost = !hasReposted && !reposting
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canFetch) return
|
if (!canFetch) return
|
||||||
@@ -49,6 +49,7 @@ export default function RepostButton({
|
|||||||
|
|
||||||
const repost = async (e: React.MouseEvent) => {
|
const repost = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
checkLogin(async () => {
|
||||||
if (!canRepost) return
|
if (!canRepost) return
|
||||||
|
|
||||||
setReposting(true)
|
setReposting(true)
|
||||||
@@ -71,6 +72,7 @@ export default function RepostButton({
|
|||||||
setReposting(false)
|
setReposting(false)
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import PostDialog from '@renderer/components/PostDialog'
|
import PostDialog from '@renderer/components/PostDialog'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { useNostr } from '@renderer/providers/NostrProvider'
|
|
||||||
import { PencilLine } from 'lucide-react'
|
import { PencilLine } from 'lucide-react'
|
||||||
|
|
||||||
export default function PostButton() {
|
export default function PostButton({ variant = 'titlebar' }: { variant?: 'titlebar' | 'sidebar' }) {
|
||||||
const { pubkey } = useNostr()
|
|
||||||
if (!pubkey) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostDialog>
|
<PostDialog>
|
||||||
<Button variant="titlebar" size="titlebar" title="new post">
|
<Button variant={variant} size={variant} title="new post">
|
||||||
<PencilLine />
|
<PencilLine />
|
||||||
|
{variant === 'sidebar' && <div>Post</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</PostDialog>
|
</PostDialog>
|
||||||
)
|
)
|
||||||
@@ -29,10 +29,11 @@ export default function PostDialog({
|
|||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { pubkey, publish } = useNostr()
|
const { publish, checkLogin } = useNostr()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
|
const canPost = !!content && !posting
|
||||||
|
|
||||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setContent(e.target.value)
|
setContent(e.target.value)
|
||||||
@@ -40,7 +41,8 @@ export default function PostDialog({
|
|||||||
|
|
||||||
const post = async (e: React.MouseEvent) => {
|
const post = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!content || !pubkey || posting) {
|
checkLogin(async () => {
|
||||||
|
if (!canPost) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,7 @@ export default function PostDialog({
|
|||||||
title: 'Post successful',
|
title: 'Post successful',
|
||||||
description: 'Your post has been published'
|
description: 'Your post has been published'
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +125,7 @@ export default function PostDialog({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!pubkey || posting} onClick={post}>
|
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||||
{posting && <LoaderCircle className="animate-spin" />}
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
Post
|
Post
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import Nip05 from '../Nip05'
|
|||||||
import ProfileAbout from '../ProfileAbout'
|
import ProfileAbout from '../ProfileAbout'
|
||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||||
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
|
const {
|
||||||
|
profile: { avatar = '', username, nip05, about }
|
||||||
|
} = useFetchProfile(pubkey)
|
||||||
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { Button } from '@renderer/components/ui/button'
|
|||||||
import { usePrimaryPage } from '@renderer/PageManager'
|
import { usePrimaryPage } from '@renderer/PageManager'
|
||||||
import { RefreshCcw } from 'lucide-react'
|
import { RefreshCcw } from 'lucide-react'
|
||||||
|
|
||||||
export default function RefreshButton() {
|
export default function RefreshButton({
|
||||||
|
variant = 'titlebar'
|
||||||
|
}: {
|
||||||
|
variant?: 'titlebar' | 'sidebar'
|
||||||
|
}) {
|
||||||
const { refresh } = usePrimaryPage()
|
const { refresh } = usePrimaryPage()
|
||||||
return (
|
return (
|
||||||
<Button variant="titlebar" size="titlebar" onClick={refresh} title="reload">
|
<Button variant={variant} size={variant} onClick={refresh} title="reload">
|
||||||
<RefreshCcw />
|
<RefreshCcw />
|
||||||
|
{variant === 'sidebar' && <div>Refresh</div>}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,23 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
|
|||||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
import { Server } from 'lucide-react'
|
import { Server } from 'lucide-react'
|
||||||
|
|
||||||
export default function RelaySettingsPopover() {
|
export default function RelaySettingsPopover({
|
||||||
|
variant = 'titlebar'
|
||||||
|
}: {
|
||||||
|
variant?: 'titlebar' | 'sidebar'
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="titlebar" size="titlebar" title="relay settings">
|
<Button variant={variant} size={variant} title="relay settings">
|
||||||
<Server />
|
<Server />
|
||||||
|
{variant === 'sidebar' && <div>Relays</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-96 h-[450px] p-0">
|
<PopoverContent
|
||||||
|
className="w-96 h-[450px] p-0"
|
||||||
|
side={variant === 'titlebar' ? 'bottom' : 'right'}
|
||||||
|
>
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<RelaySettings />
|
<RelaySettings />
|
||||||
@@ -30,7 +30,7 @@ export default function ScrollToTopButton({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary-2"
|
variant="secondary-2"
|
||||||
className={`absolute bottom-4 right-2 rounded-full w-10 h-10 p-0 hover:text-background transition-transform ${showScrollToTop ? '' : 'translate-y-14'}`}
|
className={`absolute bottom-8 right-2 rounded-full w-10 h-10 p-0 hover:text-background transition-transform ${showScrollToTop ? '' : 'translate-y-20'}`}
|
||||||
onClick={handleScrollToTop}
|
onClick={handleScrollToTop}
|
||||||
>
|
>
|
||||||
<ChevronUp />
|
<ChevronUp />
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'draggable absolute top-0 w-full h-9 z-50 bg-background/80 backdrop-blur-xl flex items-center font-semibold space-x-1 px-2',
|
'draggable absolute top-0 w-full h-9 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold space-x-1 px-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export default function UserAvatar({
|
|||||||
className?: string
|
className?: string
|
||||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||||
}) {
|
}) {
|
||||||
const { avatar, pubkey } = useFetchProfile(userId)
|
const {
|
||||||
|
profile: { avatar, pubkey }
|
||||||
|
} = useFetchProfile(userId)
|
||||||
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export default function Username({
|
|||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { username, pubkey } = useFetchProfile(userId)
|
const {
|
||||||
|
profile: { username, pubkey }
|
||||||
|
} = useFetchProfile(userId)
|
||||||
if (!pubkey) return null
|
if (!pubkey) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ const buttonVariants = cva(
|
|||||||
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
||||||
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
|
titlebar: 'non-draggable hover:bg-accent hover:text-accent-foreground',
|
||||||
|
sidebar: 'non-draggable hover:bg-accent hover:text-accent-foreground'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-8 rounded-lg px-3',
|
default: 'h-8 rounded-lg px-3',
|
||||||
sm: 'h-8 rounded-lg px-2',
|
sm: 'h-8 rounded-lg px-2',
|
||||||
lg: 'h-10 px-4 py-2',
|
lg: 'h-10 px-4 py-2',
|
||||||
icon: 'h-8 w-8 rounded-full',
|
icon: 'h-8 w-8 rounded-full',
|
||||||
titlebar: 'h-7 w-7 rounded-full'
|
titlebar: 'h-7 w-7 rounded-full',
|
||||||
|
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -1,79 +1,56 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from "@renderer/lib/utils"
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('rounded-lg border bg-card text-card-foreground', className)}
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
Card.displayName = "Card"
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
)
|
||||||
<div
|
)
|
||||||
ref={ref}
|
CardHeader.displayName = 'CardHeader'
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardHeader.displayName = "CardHeader"
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
HTMLParagraphElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
CardTitle.displayName = "CardTitle"
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
)
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
)
|
||||||
))
|
CardContent.displayName = 'CardContent'
|
||||||
CardContent.displayName = "CardContent"
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
)
|
||||||
<div
|
)
|
||||||
ref={ref}
|
CardFooter.displayName = 'CardFooter'
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
collisionPadding={10}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
|
|||||||
11
src/renderer/src/env.d.ts
vendored
11
src/renderer/src/env.d.ts
vendored
@@ -1 +1,12 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
import { TDraftEvent } from '@common/types'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: {
|
||||||
|
getPublicKey: () => Promise<string | null>
|
||||||
|
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import { Event, Filter, nip19 } from 'nostr-tools'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
export function useFetchEventById(id?: string) {
|
export function useFetchEventById(id?: string) {
|
||||||
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [event, setEvent] = useState<Event | undefined>(undefined)
|
const [event, setEvent] = useState<Event | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchEvent = async () => {
|
const fetchEvent = async () => {
|
||||||
if (!id) return
|
if (!id) {
|
||||||
|
setIsFetching(false)
|
||||||
|
setError(new Error('No id provided'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let filter: Filter = {}
|
let filter: Filter | undefined
|
||||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||||
filter = { ids: [id] }
|
filter = { ids: [id] }
|
||||||
} else {
|
} else {
|
||||||
@@ -19,9 +25,7 @@ export function useFetchEventById(id?: string) {
|
|||||||
filter = { ids: [data] }
|
filter = { ids: [data] }
|
||||||
break
|
break
|
||||||
case 'nevent':
|
case 'nevent':
|
||||||
if (data.id) {
|
filter = { ids: [data.id] }
|
||||||
filter.ids = [data.id]
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'naddr':
|
case 'naddr':
|
||||||
filter = {
|
filter = {
|
||||||
@@ -34,8 +38,11 @@ export function useFetchEventById(id?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!filter) {
|
||||||
if (!filter) return
|
setIsFetching(false)
|
||||||
|
setError(new Error('Invalid id'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let event: Event | undefined
|
let event: Event | undefined
|
||||||
if (filter.ids) {
|
if (filter.ids) {
|
||||||
@@ -45,13 +52,15 @@ export function useFetchEventById(id?: string) {
|
|||||||
}
|
}
|
||||||
if (event) {
|
if (event) {
|
||||||
setEvent(event)
|
setEvent(event)
|
||||||
} else {
|
|
||||||
setEvent(undefined)
|
|
||||||
}
|
}
|
||||||
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchEvent()
|
fetchEvent().catch((err) => {
|
||||||
|
setError(err as Error)
|
||||||
|
setIsFetching(false)
|
||||||
|
})
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
return event
|
return { isFetching, error, event }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
export function useFetchProfile(id?: string) {
|
export function useFetchProfile(id?: string) {
|
||||||
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [profile, setProfile] = useState<TProfile>({
|
const [profile, setProfile] = useState<TProfile>({
|
||||||
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
|
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
|
||||||
})
|
})
|
||||||
@@ -12,7 +14,11 @@ export function useFetchProfile(id?: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
try {
|
||||||
if (!id) return
|
if (!id) {
|
||||||
|
setIsFetching(false)
|
||||||
|
setError(new Error('No id provided'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let pubkey: string | undefined
|
let pubkey: string | undefined
|
||||||
|
|
||||||
@@ -30,7 +36,11 @@ export function useFetchProfile(id?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pubkey) return
|
if (!pubkey) {
|
||||||
|
setIsFetching(false)
|
||||||
|
setError(new Error('Invalid id'))
|
||||||
|
return
|
||||||
|
}
|
||||||
setProfile({ pubkey, username: formatPubkey(pubkey) })
|
setProfile({ pubkey, username: formatPubkey(pubkey) })
|
||||||
|
|
||||||
const profile = await client.fetchProfile(pubkey)
|
const profile = await client.fetchProfile(pubkey)
|
||||||
@@ -38,12 +48,14 @@ export function useFetchProfile(id?: string) {
|
|||||||
setProfile(profile)
|
setProfile(profile)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
setError(err as Error)
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchProfile()
|
fetchProfile()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
return profile
|
return { isFetching, error, profile }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ScrollToTopButton from '@renderer/components/ScrollToTopButton'
|
||||||
import { Titlebar } from '@renderer/components/Titlebar'
|
import { Titlebar } from '@renderer/components/Titlebar'
|
||||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
import { isMacOS } from '@renderer/lib/platform'
|
import { isMacOS } from '@renderer/lib/env'
|
||||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||||
import AccountButton from './AccountButton'
|
|
||||||
import PostButton from './PostButton'
|
|
||||||
import RefreshButton from './RefreshButton'
|
|
||||||
import RelaySettingsPopover from './RelaySettingsPopover'
|
|
||||||
|
|
||||||
const PrimaryPageLayout = forwardRef(
|
const PrimaryPageLayout = forwardRef(
|
||||||
(
|
(
|
||||||
@@ -26,13 +26,9 @@ const PrimaryPageLayout = forwardRef(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
|
||||||
ref={scrollAreaRef}
|
|
||||||
className="h-full"
|
|
||||||
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
|
|
||||||
>
|
|
||||||
<PrimaryPageTitlebar content={titlebarContent} />
|
<PrimaryPageTitlebar content={titlebarContent} />
|
||||||
<div className="px-4 pb-4 pt-11">{children}</div>
|
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
||||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
@@ -47,7 +43,7 @@ export type TPrimaryPageLayoutRef = {
|
|||||||
|
|
||||||
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
|
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
|
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<AccountButton />
|
<AccountButton />
|
||||||
<PostButton />
|
<PostButton />
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
|
import BackButton from '@renderer/components/BackButton'
|
||||||
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
|
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
|
||||||
|
import ThemeToggle from '@renderer/components/ThemeToggle'
|
||||||
|
import { Titlebar } from '@renderer/components/Titlebar'
|
||||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||||
import { isMacOS } from '@renderer/lib/platform'
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Titlebar } from '../../components/Titlebar'
|
|
||||||
import BackButton from './BackButton'
|
|
||||||
import ThemeToggle from './ThemeToggle'
|
|
||||||
|
|
||||||
export default function SecondaryPageLayout({
|
export default function SecondaryPageLayout({
|
||||||
children,
|
children,
|
||||||
titlebarContent,
|
titlebarContent,
|
||||||
hideBackButton = false
|
hideBackButton = false
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children?: React.ReactNode
|
||||||
titlebarContent?: React.ReactNode
|
titlebarContent?: React.ReactNode
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea ref={scrollAreaRef} className="h-full" scrollBarClassName="pt-9">
|
||||||
ref={scrollAreaRef}
|
|
||||||
className="h-full"
|
|
||||||
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
|
|
||||||
>
|
|
||||||
<SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} />
|
<SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} />
|
||||||
<div className="px-4 pb-4 pt-11 w-full h-full">{children}</div>
|
<div className="px-4 pb-4 pt-11 w-full h-full">{children}</div>
|
||||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
||||||
|
|||||||
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 toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||||
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
|
|
||||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||||
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })
|
|
||||||
export const toFollowingList = (pubkey: string) => ({
|
|
||||||
pageName: 'followingList',
|
|
||||||
props: { pubkey }
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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 SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
export default function FollowingListPage({ id }: { id?: string }) {
|
||||||
const { username } = useFetchProfile(pubkey)
|
const {
|
||||||
|
profile: { username, pubkey }
|
||||||
|
} = useFetchProfile(id)
|
||||||
const { followings } = useFetchFollowings(pubkey)
|
const { followings } = useFetchFollowings(pubkey)
|
||||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||||
const observer = useRef<IntersectionObserver | null>(null)
|
const observer = useRef<IntersectionObserver | null>(null)
|
||||||
@@ -57,7 +59,9 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FollowingItem({ pubkey }: { pubkey: string }) {
|
function FollowingItem({ pubkey }: { pubkey: string }) {
|
||||||
const { about, nip05 } = useFetchProfile(pubkey)
|
const {
|
||||||
|
profile: { about, nip05 }
|
||||||
|
} = useFetchProfile(pubkey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import NoteList from '@renderer/components/NoteList'
|
import NoteList from '@renderer/components/NoteList'
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||||
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
export default function HashtagPage({ hashtag }: { hashtag?: string }) {
|
export default function HashtagPage({ id }: { id?: string }) {
|
||||||
const { relayUrls } = useRelaySettings()
|
const { relayUrls } = useRelaySettings()
|
||||||
if (!hashtag) {
|
if (!id) {
|
||||||
return null
|
return <NotFoundPage />
|
||||||
}
|
}
|
||||||
const normalizedHashtag = hashtag.toLowerCase()
|
const hashtag = id.toLowerCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={`# ${normalizedHashtag}`}>
|
<SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
|
||||||
<NoteList
|
<NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
|
||||||
key={normalizedHashtag}
|
|
||||||
filter={{ '#t': [normalizedHashtag] }}
|
|
||||||
relayUrls={relayUrls}
|
|
||||||
/>
|
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
|
|
||||||
export default function BlankPage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout hideBackButton>
|
<SecondaryPageLayout hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
||||||
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 SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
||||||
import { toNote } from '@renderer/lib/link'
|
import { toNote } from '@renderer/lib/link'
|
||||||
import { Event } from 'nostr-tools'
|
import { useMemo } from 'react'
|
||||||
|
import LoadingPage from '../LoadingPage'
|
||||||
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
export default function NotePage({ event }: { event?: Event }) {
|
export default function NotePage({ id }: { id?: string }) {
|
||||||
const parentEvent = useFetchEventById(getParentEventId(event))
|
const { event, isFetching } = useFetchEventById(id)
|
||||||
const rootEvent = useFetchEventById(getRootEventId(event))
|
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||||
|
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
||||||
|
|
||||||
if (!event) return null
|
if (!event && isFetching) return <LoadingPage title="note" />
|
||||||
|
if (!event) return <NotFoundPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent="note">
|
<SecondaryPageLayout titlebarContent="note">
|
||||||
{rootEvent && <ParentNote key={`root-note-${event.id}`} event={rootEvent} />}
|
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||||
{parentEvent && <ParentNote key={`parent-note-${event.id}`} event={parentEvent} />}
|
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
|
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
|
||||||
@@ -28,14 +32,16 @@ export default function NotePage({ event }: { event?: Event }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParentNote({ event }: { event: Event }) {
|
function ParentNote({ eventId }: { eventId?: string }) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const { event } = useFetchEventById(eventId)
|
||||||
|
if (!event) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground"
|
className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => push(toNote(event))}
|
onClick={() => push(toNote(event.id))}
|
||||||
>
|
>
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||||
<Username userId={event.pubkey} className="font-semibold" />
|
<Username userId={event.pubkey} className="font-semibold" />
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ import { useNostr } from '@renderer/providers/NostrProvider'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import PubkeyCopy from './PubkeyCopy'
|
import PubkeyCopy from './PubkeyCopy'
|
||||||
import QrCodePopover from './QrCodePopover'
|
import QrCodePopover from './QrCodePopover'
|
||||||
|
import LoadingPage from '../LoadingPage'
|
||||||
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
export default function ProfilePage({ id }: { id?: string }) {
|
||||||
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
|
const {
|
||||||
|
profile: { banner, username, nip05, about, avatar, pubkey },
|
||||||
|
isFetching
|
||||||
|
} = useFetchProfile(id)
|
||||||
const relayList = useFetchRelayList(pubkey)
|
const relayList = useFetchRelayList(pubkey)
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
const { followings: selfFollowings } = useFollowList()
|
const { followings: selfFollowings } = useFollowList()
|
||||||
@@ -30,7 +35,8 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||||
const isSelf = accountPubkey === pubkey
|
const isSelf = accountPubkey === pubkey
|
||||||
|
|
||||||
if (!pubkey) return null
|
if (!pubkey && isFetching) return <LoadingPage title={username} />
|
||||||
|
if (!pubkey) return <NotFoundPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={username}>
|
<SecondaryPageLayout titlebarContent={username}>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { TDraftEvent } from '@common/types'
|
import { TDraftEvent } from '@common/types'
|
||||||
|
import LoginDialog from '@renderer/components/LoginDialog'
|
||||||
|
import { useToast } from '@renderer/hooks'
|
||||||
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
||||||
|
import { IS_ELECTRON, isElectron } from '@renderer/lib/env'
|
||||||
import client from '@renderer/services/client.service'
|
import client from '@renderer/services/client.service'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
@@ -10,11 +13,13 @@ type TNostrContext = {
|
|||||||
canLogin: boolean
|
canLogin: boolean
|
||||||
login: (nsec: string) => Promise<string>
|
login: (nsec: string) => Promise<string>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
|
nip07Login: () => Promise<string>
|
||||||
/**
|
/**
|
||||||
* Default publish the event to current relays, user's write relays and additional relays
|
* Default publish the event to current relays, user's write relays and additional relays
|
||||||
*/
|
*/
|
||||||
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
||||||
signHttpAuth: (url: string, method: string) => Promise<string>
|
signHttpAuth: (url: string, method: string) => Promise<string>
|
||||||
|
checkLogin: (cb?: () => void | Promise<void>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||||
@@ -28,25 +33,34 @@ export const useNostr = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { toast } = useToast()
|
||||||
const [pubkey, setPubkey] = useState<string | null>(null)
|
const [pubkey, setPubkey] = useState<string | null>(null)
|
||||||
const [canLogin, setCanLogin] = useState(false)
|
const [canLogin, setCanLogin] = useState(false)
|
||||||
|
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||||
const relayList = useFetchRelayList(pubkey)
|
const relayList = useFetchRelayList(pubkey)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.api.nostr.getPublicKey().then((pubkey) => {
|
window.nostr?.getPublicKey().then((pubkey) => {
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
setPubkey(pubkey)
|
setPubkey(pubkey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.api.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
|
if (isElectron(window)) {
|
||||||
|
window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
|
||||||
setCanLogin(isEncryptionAvailable)
|
setCanLogin(isEncryptionAvailable)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
setCanLogin(!!window.nostr)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = async (nsec: string) => {
|
const login = async (nsec: string) => {
|
||||||
if (!canLogin) {
|
if (!canLogin) {
|
||||||
throw new Error('encryption is not available')
|
throw new Error('encryption is not available')
|
||||||
}
|
}
|
||||||
|
if (!isElectron(window)) {
|
||||||
|
throw new Error('login is not available')
|
||||||
|
}
|
||||||
const { pubkey, reason } = await window.api.nostr.login(nsec)
|
const { pubkey, reason } = await window.api.nostr.login(nsec)
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error(reason ?? 'invalid nsec')
|
throw new Error(reason ?? 'invalid nsec')
|
||||||
@@ -55,13 +69,34 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nip07Login = async () => {
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
throw new Error('electron app should not use nip07 login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.nostr) {
|
||||||
|
throw new Error(
|
||||||
|
'You need to install a nostr signer extension to login. Such as Alby or nos2x'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey = await window.nostr.getPublicKey()
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error('You did not allow to access your pubkey')
|
||||||
|
}
|
||||||
|
setPubkey(pubkey)
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
if (isElectron(window)) {
|
||||||
await window.api.nostr.logout()
|
await window.api.nostr.logout()
|
||||||
|
}
|
||||||
setPubkey(null)
|
setPubkey(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
||||||
const event = await window.api.nostr.signEvent(draftEvent)
|
const event = await window.nostr?.signEvent(draftEvent)
|
||||||
if (!event) {
|
if (!event) {
|
||||||
throw new Error('sign event failed')
|
throw new Error('sign event failed')
|
||||||
}
|
}
|
||||||
@@ -70,7 +105,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signHttpAuth = async (url: string, method: string) => {
|
const signHttpAuth = async (url: string, method: string) => {
|
||||||
const event = await window.api.nostr.signEvent({
|
const event = await window.nostr?.signEvent({
|
||||||
content: '',
|
content: '',
|
||||||
kind: kinds.HTTPAuth,
|
kind: kinds.HTTPAuth,
|
||||||
created_at: dayjs().unix(),
|
created_at: dayjs().unix(),
|
||||||
@@ -85,9 +120,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return 'Nostr ' + btoa(JSON.stringify(event))
|
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkLogin = async (cb?: () => void) => {
|
||||||
|
if (pubkey) {
|
||||||
|
return cb && cb()
|
||||||
|
}
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
return setOpenLoginDialog(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await nip07Login()
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Login failed',
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return cb && cb()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NostrContext.Provider value={{ pubkey, canLogin, login, logout, publish, signHttpAuth }}>
|
<NostrContext.Provider
|
||||||
|
value={{ pubkey, canLogin, login, nip07Login, logout, publish, signHttpAuth, checkLogin }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
|
||||||
</NostrContext.Provider>
|
</NostrContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
|
||||||
import { TTheme, TThemeSetting } from '@common/types'
|
import { TTheme, TThemeSetting } from '@common/types'
|
||||||
|
import { isElectron } from '@renderer/lib/env'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -8,36 +9,64 @@ type ThemeProviderProps = {
|
|||||||
|
|
||||||
type ThemeProviderState = {
|
type ThemeProviderState = {
|
||||||
themeSetting: TThemeSetting
|
themeSetting: TThemeSetting
|
||||||
setThemeSetting: (themeSetting: TThemeSetting) => void
|
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// web only
|
||||||
|
function getSystemTheme() {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
|
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
|
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
|
||||||
(localStorage.getItem('themeSetting') as TTheme) ?? 'system'
|
(localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system'
|
||||||
)
|
)
|
||||||
const [theme, setTheme] = useState<TTheme>('light')
|
const [theme, setTheme] = useState<TTheme>('light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
// electron
|
||||||
|
if (isElectron(window)) {
|
||||||
const [themeSetting, theme] = await Promise.all([
|
const [themeSetting, theme] = await Promise.all([
|
||||||
window.api.theme.themeSetting(),
|
window.api.theme.themeSetting(),
|
||||||
window.api.theme.current()
|
window.api.theme.current()
|
||||||
])
|
])
|
||||||
localStorage.setItem('theme', theme)
|
|
||||||
setTheme(theme)
|
setTheme(theme)
|
||||||
setThemeSetting(themeSetting)
|
setThemeSetting(themeSetting)
|
||||||
|
|
||||||
window.api.theme.onChange((theme) => {
|
window.api.theme.onChange((theme) => {
|
||||||
localStorage.setItem('theme', theme)
|
|
||||||
setTheme(theme)
|
setTheme(theme)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// web
|
||||||
|
if (themeSetting === 'system') {
|
||||||
|
setTheme(getSystemTheme())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTheme(themeSetting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
init()
|
init()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeSetting !== 'system' || isElectron(window)) return
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setTheme(e.matches ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
mediaQuery.addEventListener('change', handleChange)
|
||||||
|
setTheme(mediaQuery.matches ? 'dark' : 'light')
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange)
|
||||||
|
}
|
||||||
|
}, [themeSetting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateTheme = async () => {
|
const updateTheme = async () => {
|
||||||
const root = window.document.documentElement
|
const root = window.document.documentElement
|
||||||
@@ -50,8 +79,18 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
themeSetting: themeSetting,
|
themeSetting: themeSetting,
|
||||||
setThemeSetting: (themeSetting: TThemeSetting) => {
|
setThemeSetting: async (themeSetting: TThemeSetting) => {
|
||||||
window.api.theme.set(themeSetting).then(() => setThemeSetting(themeSetting))
|
if (isElectron(window)) {
|
||||||
|
await window.api.theme.set(themeSetting)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('themeSetting', themeSetting)
|
||||||
|
}
|
||||||
|
setThemeSetting(themeSetting)
|
||||||
|
if (themeSetting === 'system') {
|
||||||
|
setTheme(getSystemTheme())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTheme(themeSetting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 relayUrls: string[] = BIG_RELAY_URLS
|
||||||
private initPromise!: Promise<void>
|
private initPromise!: Promise<void>
|
||||||
|
|
||||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
private eventByFilterCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||||
max: 10000,
|
max: 10000,
|
||||||
fetchMethod: async (filterStr) => {
|
fetchMethod: async (filterStr) => {
|
||||||
const events = await this.fetchEvents(
|
const events = await this.fetchEvents(
|
||||||
BIG_RELAY_URLS.concat(this.relayUrls),
|
BIG_RELAY_URLS.concat(this.relayUrls),
|
||||||
JSON.parse(filterStr)
|
JSON.parse(filterStr)
|
||||||
)
|
)
|
||||||
|
events.forEach((event) => this.addEventToCache(event))
|
||||||
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
private eventByIdCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
||||||
private eventDataloader = new DataLoader<string, NEvent | undefined>(
|
private eventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.eventBatchLoadFn.bind(this),
|
this.eventBatchLoadFn.bind(this),
|
||||||
{
|
{ cacheMap: this.eventByIdCache }
|
||||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
private profileDataloader = new DataLoader<string, TProfile | undefined>(
|
private profileDataloader = new DataLoader<string, TProfile | undefined>(
|
||||||
this.profileBatchLoadFn.bind(this),
|
this.profileBatchLoadFn.bind(this),
|
||||||
@@ -126,13 +126,17 @@ class ClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchEventByFilter(filter: Filter) {
|
async fetchEventByFilter(filter: Filter) {
|
||||||
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
return this.eventByFilterCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
||||||
return this.eventDataloader.load(id)
|
return this.eventDataloader.load(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addEventToCache(event: NEvent) {
|
||||||
|
this.eventByIdCache.set(event.id, Promise.resolve(event))
|
||||||
|
}
|
||||||
|
|
||||||
async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
|
async fetchProfile(pubkey: string): Promise<TProfile | undefined> {
|
||||||
return this.profileDataloader.load(pubkey)
|
return this.profileDataloader.load(pubkey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
import { TRelayGroup } from '@common/types'
|
import { TRelayGroup } from '@common/types'
|
||||||
import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
|
import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
|
||||||
|
import { isElectron } from '@renderer/lib/env'
|
||||||
|
|
||||||
|
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
||||||
|
{
|
||||||
|
groupName: 'Global',
|
||||||
|
relayUrls: [
|
||||||
|
'wss://relay.damus.io/',
|
||||||
|
'wss://nos.lol/',
|
||||||
|
'wss://nostr.mom/',
|
||||||
|
'wss://relay.primal.net/'
|
||||||
|
],
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class Storage {
|
||||||
|
async getRelayGroups() {
|
||||||
|
if (isElectron(window)) {
|
||||||
|
const relayGroups = await window.api.storage.getRelayGroups()
|
||||||
|
return relayGroups ?? DEFAULT_RELAY_GROUPS
|
||||||
|
} else {
|
||||||
|
const relayGroupsStr = localStorage.getItem('relayGroups')
|
||||||
|
return relayGroupsStr ? (JSON.parse(relayGroupsStr) as TRelayGroup[]) : DEFAULT_RELAY_GROUPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRelayGroups(relayGroups: TRelayGroup[]) {
|
||||||
|
if (isElectron(window)) {
|
||||||
|
return window.api.storage.setRelayGroups(relayGroups)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('relayGroups', JSON.stringify(relayGroups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class StorageService {
|
class StorageService {
|
||||||
static instance: StorageService
|
static instance: StorageService
|
||||||
@@ -7,6 +41,7 @@ class StorageService {
|
|||||||
private initPromise!: Promise<void>
|
private initPromise!: Promise<void>
|
||||||
private relayGroups: TRelayGroup[] = []
|
private relayGroups: TRelayGroup[] = []
|
||||||
private activeRelayUrls: string[] = []
|
private activeRelayUrls: string[] = []
|
||||||
|
private storage: Storage = new Storage()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!StorageService.instance) {
|
if (!StorageService.instance) {
|
||||||
@@ -17,7 +52,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.relayGroups = await window.api.storage.getRelayGroups()
|
this.relayGroups = await this.storage.getRelayGroups()
|
||||||
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +63,7 @@ class StorageService {
|
|||||||
|
|
||||||
async setRelayGroups(relayGroups: TRelayGroup[]) {
|
async setRelayGroups(relayGroups: TRelayGroup[]) {
|
||||||
await this.initPromise
|
await this.initPromise
|
||||||
await window.api.storage.setRelayGroups(relayGroups)
|
await this.storage.setRelayGroups(relayGroups)
|
||||||
this.relayGroups = relayGroups
|
this.relayGroups = relayGroups
|
||||||
const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
|
||||||
if (
|
if (
|
||||||
|
|||||||
17
web.vite.config.ts
Normal file
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