feat: basic browsing (#1)

This commit is contained in:
Cody Tseng
2024-10-31 17:53:03 +08:00
committed by GitHub
parent 824e2ea9d5
commit 9b0251240c
104 changed files with 5624 additions and 122 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [CodyTseng]

57
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Build/release
on:
push:
tags:
- v*.*.*
permissions:
contents: write
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-13, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm install
- name: build-linux
if: matrix.os == 'ubuntu-latest'
run: npm run build:linux
- name: build-mac
if: matrix.os == 'macos-13'
run: npm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'
run: npm run build:win
- name: release
uses: softprops/action-gh-release@v2
with:
draft: true
files: |
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

View File

@@ -2,6 +2,19 @@
Yet another Nostr desktop client
## Features
- **Relay-Based Browsing:** Explore content directly through relays without following specific users. Discover diverse topics across different relays
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Groups:** Organize similar relays into custom groups for seamless switching between different content streams
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
## Download
You can download the latest version from the [release page](https://github.com/CodyTseng/jumble/releases). If you want to use Apple Silicon version, you need to build it from the source code.
Because the app is not signed, you may need to allow it to run in the system settings.
## Build from source
You can also build the app from the source code.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 816 KiB

1198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,12 +28,28 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lru-cache": "^11.0.1",
"lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1",
"react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"yet-another-react-lightbox": "^3.21.6"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^2.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 816 KiB

13
src/common/types.ts Normal file
View File

@@ -0,0 +1,13 @@
export type TRelayGroup = {
groupName: string
relayUrls: string[]
isActive: boolean
}
export type TConfig = {
relayGroups: TRelayGroup[]
theme: TThemeSetting
}
export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'

View File

@@ -1,11 +1,16 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ThemeService } from './services/theme.service'
import { TSendToRenderer } from './types'
import { StorageService } from './services/storage.service'
let mainWindow: BrowserWindow | null = null
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
@@ -14,11 +19,16 @@ function createWindow(): void {
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
},
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
mainWindow?.show()
})
mainWindow.on('closed', () => {
mainWindow = null
})
mainWindow.webContents.setWindowOpenHandler((details) => {
@@ -33,14 +43,18 @@ function createWindow(): void {
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
if (is.dev) {
mainWindow.webContents.openDevTools()
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
electronApp.setAppUserModelId('com.jumble')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
@@ -49,8 +63,15 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
const sendToRenderer: TSendToRenderer = (channel, ...args) => {
mainWindow?.webContents.send(channel, ...args)
}
const storageService = new StorageService()
storageService.init()
const themeService = new ThemeService(storageService, sendToRenderer)
themeService.init()
createWindow()

View File

@@ -0,0 +1,85 @@
import { TConfig, TRelayGroup, TThemeSetting } from '@common/types'
import { app, ipcMain } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import path from 'path'
export class StorageService {
private storage: Storage
constructor() {
this.storage = new Storage()
}
init() {
ipcMain.handle('storage:getRelayGroups', () => this.getRelayGroups())
ipcMain.handle('storage:setRelayGroups', (_, relayGroups: TRelayGroup[]) =>
this.setRelayGroups(relayGroups)
)
}
getRelayGroups(): TRelayGroup[] {
return (
this.storage.get('relayGroups') ?? [
{
groupName: 'Global',
relayUrls: [
'wss://relay.damus.io/',
'wss://nos.lol/',
'wss://nostr.mom/',
'wss://relay.primal.net/'
],
isActive: true
}
]
)
}
setRelayGroups(relayGroups: TRelayGroup[]) {
this.storage.set('relayGroups', relayGroups)
}
getTheme() {
return this.storage.get('theme') ?? 'system'
}
setTheme(theme: TThemeSetting) {
this.storage.set('theme', theme)
}
}
class Storage {
private path: string
private config: TConfig
private writeTimer: NodeJS.Timeout | null = null
constructor() {
this.path = path.join(app.getPath('userData'), 'config.json')
this.checkConfigFile(this.path)
const json = readFileSync(this.path, 'utf-8')
this.config = JSON.parse(json)
}
get<K extends keyof TConfig, V extends TConfig[K]>(key: K): V | undefined {
return this.config[key] as V
}
set<K extends keyof TConfig>(key: K, value: TConfig[K]) {
this.config[key] = value
if (this.writeTimer) return
this.writeTimer = setTimeout(() => {
this.writeTimer = null
writeFileSync(this.path, JSON.stringify(this.config))
}, 1000)
}
private checkConfigFile(path: string) {
try {
if (!existsSync(path)) {
writeFileSync(path, '{}')
}
} catch (err) {
console.error(err)
}
}
}

View File

@@ -0,0 +1,43 @@
import { TThemeSetting } from '@common/types'
import { ipcMain, nativeTheme } from 'electron'
import { TSendToRenderer } from '../types'
import { StorageService } from './storage.service'
export class ThemeService {
private themeSetting: TThemeSetting = 'system'
constructor(
private storageService: StorageService,
private sendToRenderer: TSendToRenderer
) {}
init() {
this.themeSetting = this.storageService.getTheme()
ipcMain.handle('theme:current', () => this.getCurrentTheme())
ipcMain.handle('theme:themeSetting', () => this.themeSetting)
ipcMain.handle('theme:set', (_, theme: TThemeSetting) => this.setTheme(theme))
nativeTheme.on('updated', () => {
if (this.themeSetting === 'system') {
this.sendCurrentThemeToRenderer()
}
})
}
getCurrentTheme() {
if (this.themeSetting === 'system') {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
}
return this.themeSetting
}
private setTheme(theme: TThemeSetting) {
this.themeSetting = theme
this.storageService.setTheme(theme)
this.sendCurrentThemeToRenderer()
}
private sendCurrentThemeToRenderer() {
this.sendToRenderer('theme:change', this.getCurrentTheme())
}
}

1
src/main/types.ts Normal file
View File

@@ -0,0 +1 @@
export type TSendToRenderer = (channel: string, ...args: any[]) => void

View File

@@ -1,8 +1,20 @@
import { TRelayGroup, TTheme, TThemeSetting } from '@common/types'
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
api: {
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>
}
}
}
}

View File

@@ -1,8 +1,25 @@
import { contextBridge } from 'electron'
import { TRelayGroup, TThemeSetting } from '@common/types'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'
// Custom APIs for renderer
const api = {}
const api = {
theme: {
onChange: (cb: (theme: 'dark' | 'light') => void) => {
ipcRenderer.on('theme:change', (_, theme) => {
cb(theme)
})
},
current: () => ipcRenderer.invoke('theme:current'),
themeSetting: () => ipcRenderer.invoke('theme:themeSetting'),
set: (themeSetting: TThemeSetting) => ipcRenderer.invoke('theme:set', themeSetting)
},
storage: {
getRelayGroups: () => ipcRenderer.invoke('storage:getRelayGroups'),
setRelayGroups: (relayGroups: TRelayGroup[]) =>
ipcRenderer.invoke('storage:setRelayGroups', relayGroups)
}
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
<!-- <meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
/> -->
</head>
<body>

View File

@@ -1,5 +1,29 @@
function App(): JSX.Element {
return <div>Hello</div>
}
import 'yet-another-react-lightbox/styles.css'
import './assets/main.css'
export default App
import { ThemeProvider } from '@renderer/components/theme-provider'
import { Toaster } from '@renderer/components/ui/toaster'
import { PageManager } from './PageManager'
import NoteListPage from './pages/primary/NoteListPage'
import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage'
const routes = [
{ pageName: 'note', element: <NotePage /> },
{ pageName: 'profile', element: <ProfilePage /> },
{ pageName: 'hashtag', element: <HashtagPage /> }
]
export default function App(): JSX.Element {
return (
<div className="h-screen">
<ThemeProvider>
<PageManager routes={routes}>
<NoteListPage />
</PageManager>
<Toaster />
</ThemeProvider>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup
} from '@renderer/components/ui/resizable'
import { cloneElement, createContext, isValidElement, useContext, useState } from 'react'
import BlankPage from './pages/secondary/BlankPage'
import { cn } from '@renderer/lib/utils'
type TRoute = {
pageName: string
element: React.ReactNode
}
type TPushParams = {
pageName: string
props: any
}
type TSecondaryPageContext = {
push: (params: TPushParams) => void
pop: () => void
}
type TStackItem = {
pageName: string
props: any
component: React.ReactNode
}
const SecondaryPageContext = createContext<TSecondaryPageContext>({
push: () => {},
pop: () => {}
})
export function useSecondaryPage() {
return useContext(SecondaryPageContext)
}
export function PageManager({
routes,
children,
maxStackSize = 5
}: {
routes: TRoute[]
children: React.ReactNode
maxStackSize?: number
}) {
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const routeMap = routes.reduce((acc, route) => {
acc[route.pageName] = route.element
return acc
}, {}) as Record<string, React.ReactNode>
const isCurrentPage = (stack: TStackItem[], { pageName, props }: TPushParams) => {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return (
currentPage.pageName === pageName &&
JSON.stringify(currentPage.props) === JSON.stringify(props) // TODO: deep compare
)
}
const pushSecondary = ({ pageName, props }: TPushParams) => {
if (isCurrentPage(secondaryStack, { pageName, props })) return
const element = routeMap[pageName]
if (!element) return
if (!isValidElement(element)) return
setSecondaryStack((prevStack) => {
const component = cloneElement(element, props)
const newStack = [...prevStack, { pageName, props, component }]
if (newStack.length > maxStackSize) newStack.shift()
return newStack
})
}
const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1))
return (
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={60} minSize={30}>
{children}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40} minSize={30} className="relative">
{secondaryStack.length ? (
secondaryStack.map((item, index) => (
<div
key={index}
className="absolute top-0 left-0 w-full h-full bg-background"
style={{ zIndex: index }}
>
{item.component}
</div>
))
) : (
<BlankPage />
)}
</ResizablePanel>
</ResizablePanelGroup>
</SecondaryPageContext.Provider>
)
}
export function SecondaryPageLink({
to,
children,
className,
onClick
}: {
to: TPushParams
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
const { push } = useSecondaryPage()
return (
<span
className={cn('cursor-pointer', className)}
onClick={(e) => {
onClick && onClick(e)
push(to)
}}
>
{children}
</span>
)
}

View File

@@ -19,68 +19,49 @@
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 259 43% 56%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 259 43% 56%;
--highlight: 259 43% 56%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 259 43% 56%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 259 43% 56%;
--highlight: 259 43% 56%;
}
}

View File

@@ -0,0 +1,125 @@
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
} from '@renderer/embedded'
import { isNsfwEvent } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils'
import { Event } from 'nostr-tools'
import { memo } from 'react'
import { EmbeddedNote } from '../Embedded'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
const Content = memo(
({
event,
className,
size = 'normal'
}: {
event: Event
className?: string
size?: 'normal' | 'small'
}) => {
const { content, images, videos, embeddedNotes } = preprocess(event.content)
const isNsfw = isNsfwEvent(event)
const nodes = embedded(content, [
embeddedNormalUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
])
// Add images
if (images.length) {
nodes.push(
<ImageGallery
className="mt-2 w-fit"
key="images"
images={images}
isNsfw={isNsfw}
size={size}
/>
)
}
// Add videos
if (videos.length) {
videos.forEach((src, index) => {
nodes.push(
<VideoPlayer
className="mt-2"
key={`video-${index}`}
src={src}
isNsfw={isNsfw}
size={size}
/>
)
})
}
// Add embedded notes
if (embeddedNotes.length) {
embeddedNotes.forEach((note, index) => {
const id = note.split(':')[1]
nodes.push(<EmbeddedNote key={`embedded-event-${index}`} noteId={id} />)
})
}
return (
<div className={cn('text-sm text-wrap break-words whitespace-pre-wrap', className)}>
{nodes}
</div>
)
}
)
Content.displayName = 'Content'
export default Content
function preprocess(content: string) {
const urlRegex = /(https?:\/\/[^\s"']+)/g
const urls = content.match(urlRegex) || []
let c = content
const images: string[] = []
const videos: string[] = []
urls.forEach((url) => {
if (isImage(url)) {
c = c.replace(url, '').trim()
images.push(url)
} else if (isVideo(url)) {
c = c.replace(url, '').trim()
videos.push(url)
}
})
const embeddedNotes: string[] = []
const embeddedNoteRegex = /(nostr:note1[a-z0-9]{58}|nostr:nevent1[a-z0-9]+)/g
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
c = c.replace(note, '').trim()
embeddedNotes.push(note)
})
return { content: c, images, videos, embeddedNotes }
}
function isImage(url: string) {
try {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', 'webp', 'heic', 'svg']
return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}
function isVideo(url: string) {
try {
const videoExtensions = ['.mp4', '.webm', '.ogg']
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}

View File

@@ -0,0 +1,14 @@
import { toHashtag } from '@renderer/lib/url'
import { SecondaryPageLink } from '@renderer/PageManager'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-highlight hover:underline"
to={toHashtag(hashtag)}
onClick={(e) => e.stopPropagation()}
>
#{hashtag}
</SecondaryPageLink>
)
}

View File

@@ -0,0 +1,9 @@
import { useFetchProfile } from '@renderer/hooks'
import Username from '../Username'
export function EmbeddedMention({ userId }: { userId: string }) {
const { pubkey } = useFetchProfile(userId)
if (!pubkey) return null
return <Username userId={pubkey} showAt className="text-highlight font-normal" />
}

View File

@@ -0,0 +1,13 @@
export function EmbeddedNormalUrl({ url }: { url: string }) {
return (
<a
className="text-highlight hover:underline"
href={url}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
>
{url}
</a>
)
}

View File

@@ -0,0 +1,22 @@
import { useFetchEventById } from '@renderer/hooks'
import { toNoStrudelNote } from '@renderer/lib/url'
import { kinds } from 'nostr-tools'
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'
export function EmbeddedNote({ noteId }: { noteId: string }) {
const event = useFetchEventById(noteId)
return event && event.kind === kinds.ShortTextNote ? (
<ShortTextNoteCard size="small" className="mt-2 w-full" event={event} />
) : (
<a
href={toNoStrudelNote(noteId)}
target="_blank"
className="text-highlight hover:underline"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
>
{noteId}
</a>
)
}

View File

@@ -0,0 +1,4 @@
export * from './EmbeddedHashtag'
export * from './EmbeddedMention'
export * from './EmbeddedNormalUrl'
export * from './EmbeddedNote'

View File

@@ -0,0 +1,55 @@
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import NsfwOverlay from '../NsfwOverlay'
import { cn } from '@renderer/lib/utils'
export default function ImageGallery({
className,
images,
isNsfw = false,
size = 'normal'
}: {
className?: string
images: string[]
isNsfw?: boolean
size?: 'normal' | 'small'
}) {
const [index, setIndex] = useState(-1)
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
event.preventDefault()
setIndex(current)
}
return (
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
<ScrollArea className="w-fit">
<div className="flex w-fit space-x-2">
{images.map((src, index) => {
return (
<img
className={`rounded-lg max-w-full ${size === 'small' ? 'max-h-[10vh]' : 'max-h-[30vh]'}`}
key={index}
src={src}
onClick={(e) => handlePhotoClick(e, index)}
/>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Lightbox
index={index}
slides={images.map((src) => ({ src }))}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{ closeOnBackdropClick: true, closeOnPullUp: true, closeOnPullDown: true }}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { useFetchNip05 } from '@renderer/hooks/useFetchNip05'
import { BadgeAlert, BadgeCheck } from 'lucide-react'
export default function Nip05({ nip05, pubkey }: { nip05: string; pubkey: string }) {
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
return (
<div className="flex items-center space-x-1">
{nip05Name !== '_' ? (
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
) : null}
<a
href={`https://${nip05Domain}`}
target="_blank"
className={`flex items-center space-x-1 hover:underline ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`}
rel="noreferrer"
>
{nip05IsVerified ? <BadgeCheck size={16} /> : <BadgeAlert size={16} />}
<div className="text-sm">{nip05Domain}</div>
</a>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import RawEventDialog from './RawEventDialog'
export default function NoteOptionsTrigger({ event }: { event: Event }) {
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<Ellipsis
size={14}
className="text-muted-foreground hover:text-foreground cursor-pointer"
/>
</DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={8}>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
raw event
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RawEventDialog
event={event}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
</>
)
}

View File

@@ -0,0 +1,48 @@
import useFetchEventStats from '@renderer/hooks/useFetchEventStats'
import { cn } from '@renderer/lib/utils'
import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service'
import { Heart, MessageCircle, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import NoteOptionsTrigger from './NoteOptionsTrigger'
export default function NoteStats({ event, className }: { event: Event; className?: string }) {
const [replyCount, setReplyCount] = useState(0)
const { stats } = useFetchEventStats(event.id)
useEffect(() => {
const handler = (e: CustomEvent<{ eventId: string; replyCount: number }>) => {
const { eventId, replyCount } = e.detail
if (eventId === event.id) {
setReplyCount(replyCount)
}
}
eventBus.on(EVENT_TYPES.REPLY_COUNT_CHANGED, handler)
return () => {
eventBus.remove(EVENT_TYPES.REPLY_COUNT_CHANGED, handler)
}
}, [])
return (
<div className={cn('flex justify-between', className)}>
<div className="flex gap-1 items-center text-muted-foreground">
<MessageCircle size={14} />
<div className="text-xs">{formatCount(replyCount)}</div>
</div>
<div className="flex gap-1 items-center text-muted-foreground">
<Repeat size={14} />
<div className="text-xs">{formatCount(stats.repostCount)}</div>
</div>
<div className="flex gap-1 items-center text-muted-foreground">
<Heart size={14} />
<div className="text-xs">{formatCount(stats.reactionCount)}</div>
</div>
<NoteOptionsTrigger event={event} />
</div>
)
}
function formatCount(count: number) {
return count >= 100 ? '99+' : count
}

View File

@@ -0,0 +1,29 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog'
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
import { Event } from 'nostr-tools'
export default function RawEventDialog({
event,
isOpen,
onClose
}: {
event: Event
isOpen: boolean
onClose: () => void
}) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="h-[60vh]">
<DialogHeader>
<DialogTitle>Raw Event</DialogTitle>
</DialogHeader>
<ScrollArea className="h-full">
<pre className="text-sm overflow-x-auto text-muted-foreground">
{JSON.stringify(event, null, 2)}
</pre>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,52 @@
import { formatTimestamp } from '@renderer/lib/timestamp'
import { Event } from 'nostr-tools'
import Content from '../Content'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import NoteStats from './NoteStats'
export default function Note({
event,
parentEvent,
size = 'normal',
className,
displayStats = false
}: {
event: Event
parentEvent?: Event
size?: 'normal' | 'small'
className?: string
displayStats?: boolean
}) {
return (
<div className={className}>
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
<div className="flex-1 w-0">
<Username
userId={event.pubkey}
className={`font-semibold max-w-fit flex ${size === 'small' ? 'text-xs' : 'text-sm'}`}
/>
<div className="text-xs text-muted-foreground">{formatTimestamp(event.created_at)}</div>
</div>
</div>
{parentEvent && (
<div className="text-xs text-muted-foreground truncate mt-2">
<ParentNote event={parentEvent} />
</div>
)}
<Content className="mt-2" event={event} />
{displayStats && <NoteStats className="mt-2" event={event} />}
</div>
)
}
function ParentNote({ event }: { event: Event }) {
return (
<div className="flex space-x-1 items-center text-xs rounded-lg border px-1 bg-muted w-fit max-w-full">
<div>reply to</div>
<UserAvatar userId={event.pubkey} size="tiny" />
<div className="truncate">{event.content}</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Event } from 'nostr-tools'
import { useFetchEventById } from '@renderer/hooks'
import { Repeat2 } from 'lucide-react'
import Username from '../Username'
import ShortTextNoteCard from './ShortTextNoteCard'
export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) {
const targetEventId = event.tags.find(([tagName]) => tagName === 'e')?.[1]
const targetEvent = useFetchEventById(targetEventId)
if (!targetEvent) return null
return (
<div className={className}>
<div className="flex gap-1 mb-1 pl-4 text-xs items-center text-muted-foreground">
<Repeat2 size={12} />
<Username userId={event.pubkey} className="font-semibold" />
<div>reposted</div>
</div>
<ShortTextNoteCard event={targetEvent} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Event } from 'nostr-tools'
import { Card } from '@renderer/components/ui/card'
import { toNote } from '@renderer/lib/url'
import { useSecondaryPage } from '@renderer/PageManager'
import Note from '../Note'
import { useFetchEventById } from '@renderer/hooks'
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
export default function ShortTextNoteCard({
event,
className,
size
}: {
event: Event
className?: string
size?: 'normal' | 'small'
}) {
const { push } = useSecondaryPage()
const rootEvent = useFetchEventById(getRootEventId(event))
const parentEvent = useFetchEventById(getParentEventId(event))
return (
<div
className={className}
onClick={(e) => {
e.stopPropagation()
push(toNote(rootEvent ?? event))
}}
>
<Card className="p-4 hover:bg-muted/50 text-left cursor-pointer">
<Note size={size} event={event} parentEvent={parentEvent ?? rootEvent} />
</Card>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import RepostNoteCard from './RepostNoteCard'
import ShortTextNoteCard from './ShortTextNoteCard'
export default function NoteCard({ event, className }: { event: Event; className?: string }) {
if (event.kind === kinds.Repost) {
return <RepostNoteCard event={event} className={className} />
}
return <ShortTextNoteCard event={event} className={className} />
}

View File

@@ -0,0 +1,145 @@
import { isReplyNoteEvent } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils'
import client from '@renderer/services/client.service'
import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service'
import dayjs from 'dayjs'
import { RefreshCcw } from 'lucide-react'
import { Event, Filter, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import NoteCard from '../NoteCard'
export default function NoteList({
filter = {},
className,
isHomeTimeline = false
}: {
filter?: Filter
className?: string
isHomeTimeline?: boolean
}) {
const [events, setEvents] = useState<Event[]>([])
const [since, setSince] = useState<number>(() => dayjs().unix() + 1)
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true)
const [refreshedAt, setRefreshedAt] = useState<number>(() => dayjs().unix())
const [refreshing, setRefreshing] = useState<boolean>(false)
const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const noteFilter = useMemo(() => {
return {
kinds: [kinds.ShortTextNote, kinds.Repost],
limit: 50,
...filter
}
}, [filter])
useEffect(() => {
if (!isHomeTimeline) return
const handleClearList = () => {
setEvents([])
setSince(dayjs().unix() + 1)
setUntil(dayjs().unix())
setHasMore(true)
setRefreshedAt(dayjs().unix())
setRefreshing(false)
}
eventBus.on(EVENT_TYPES.RELOAD_TIMELINE, handleClearList)
return () => {
eventBus.remove(EVENT_TYPES.RELOAD_TIMELINE, handleClearList)
}
}, [])
const loadMore = async () => {
const events = await client.fetchEvents([{ ...noteFilter, until }])
if (events.length === 0) {
setHasMore(false)
return
}
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e))
if (processedEvents.length > 0) {
setEvents((oldEvents) => [...oldEvents, ...processedEvents])
}
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
}
const refresh = async () => {
const now = dayjs().unix()
setRefreshing(true)
const events = await client.fetchEvents([{ ...noteFilter, until: now, since }])
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e))
if (sortedEvents.length >= noteFilter.limit) {
// reset
setEvents(processedEvents)
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
} else if (processedEvents.length > 0) {
// append
setEvents((oldEvents) => [...processedEvents, ...oldEvents])
}
if (sortedEvents.length > 0) {
setSince(sortedEvents[0].created_at + 1)
}
setRefreshedAt(now)
setRefreshing(false)
}
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
if (bottomRef.current) {
observer.current.observe(bottomRef.current)
}
return () => {
if (observer.current && bottomRef.current) {
observer.current.unobserve(bottomRef.current)
}
}
}, [until])
return (
<>
{events.length > 0 && (
<div
className={`flex justify-center items-center gap-1 mb-2 text-muted-foreground ${!refreshing ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={refresh}
>
<RefreshCcw size={12} className={`${refreshing ? 'animate-spin' : ''}`} />
<div className="text-xs">
{refreshing
? 'refreshing...'
: `last refreshed at ${dayjs(refreshedAt * 1000).format('HH:mm:ss')}`}
</div>
</div>
)}
<div className={cn('flex flex-col gap-4', className)}>
{events.map((event, i) => (
<NoteCard key={i} className="w-full" event={event} />
))}
</div>
<div className="text-center text-xs text-muted-foreground mt-2">
{hasMore ? <div ref={bottomRef}>loading...</div> : 'no more notes'}
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@renderer/lib/utils'
import { useState } from 'react'
export default function NsfwOverlay({ className }: { className?: string }) {
const [isHidden, setIsHidden] = useState(true)
return (
isHidden && (
<div
className={cn(
'absolute top-0 left-0 backdrop-blur-3xl w-full h-full cursor-pointer',
className
)}
onClick={() => setIsHidden(false)}
/>
)
)
}

View File

@@ -0,0 +1,23 @@
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer
} from '@renderer/embedded'
import { embeddedNpubRenderer } from '@renderer/embedded/EmbeddedNpub'
import { useMemo } from 'react'
export default function ProfileAbout({ about }: { about?: string }) {
const nodes = useMemo(() => {
return about
? embedded(about, [
embeddedNormalUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNpubRenderer
])
: null
}, [about])
return <>{nodes}</>
}

View File

@@ -0,0 +1,35 @@
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useFetchProfile } from '@renderer/hooks'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
const defaultAvatar = generateImageByPubkey(pubkey)
return (
<div className="w-full flex flex-col gap-2">
<div className="flex space-x-2 w-full items-center">
<Avatar className="w-12 h-12">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
</AvatarFallback>
</Avatar>
<div className="flex-1 w-0">
<div className="text-lg font-semibold truncate">{username}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
</div>
</div>
{about && (
<div
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis"
style={{ display: '-webkit-box', WebkitLineClamp: 6, WebkitBoxOrient: 'vertical' }}
>
<ProfileAbout about={about} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,229 @@
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input'
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
import { useState } from 'react'
import { TRelayGroup } from './types'
import RelayUrls from './RelayUrl'
export default function RelayGroup({
group,
onSwitch,
onDelete,
onRename,
onRelayUrlsUpdate
}: {
group: TRelayGroup
onSwitch: (groupName: string) => void
onDelete: (groupName: string) => void
onRename: (oldGroupName: string, newGroupName: string) => string | null
onRelayUrlsUpdate: (groupName: string, relayUrls: string[]) => void
}) {
const { groupName, isActive, relayUrls } = group
const [expanded, setExpanded] = useState(false)
const [renaming, setRenaming] = useState(false)
const toggleExpanded = () => setExpanded((prev) => !prev)
return (
<div
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : ''}`}
>
<div className="flex justify-between items-center">
<div className="flex space-x-2 items-center">
<RelayGroupActiveToggle
isActive={isActive}
onToggle={() => onSwitch(groupName)}
hasRelayUrls={relayUrls.length > 0}
/>
<RelayGroupName
groupName={groupName}
renaming={renaming}
hasRelayUrls={relayUrls.length > 0}
setRenaming={setRenaming}
save={onRename}
onToggle={() => onSwitch(groupName)}
/>
</div>
<div className="flex gap-1">
<RelayUrlsExpandToggle expanded={expanded} onClick={toggleExpanded}>
{relayUrls.length} relays
</RelayUrlsExpandToggle>
<RelayGroupOptions
groupName={groupName}
isActive={isActive}
onDelete={onDelete}
setRenaming={setRenaming}
/>
</div>
</div>
{expanded && (
<RelayUrls
isActive={isActive}
relayUrls={relayUrls}
update={(urls) => onRelayUrlsUpdate(groupName, urls)}
/>
)}
</div>
)
}
function RelayGroupActiveToggle({
isActive,
hasRelayUrls,
onToggle
}: {
isActive: boolean
hasRelayUrls: boolean
onToggle: () => void
}) {
return (
<>
{isActive ? (
<CircleCheck size={18} className="text-highlight shrink-0" />
) : (
<Circle
size={18}
className={`text-muted-foreground shrink-0 ${hasRelayUrls ? 'cursor-pointer hover:text-foreground ' : ''}`}
onClick={() => {
if (hasRelayUrls) {
onToggle()
}
}}
/>
)}
</>
)
}
function RelayGroupName({
groupName,
renaming,
hasRelayUrls,
setRenaming,
save,
onToggle
}: {
groupName: string
renaming: boolean
hasRelayUrls: boolean
setRenaming: (renaming: boolean) => void
save: (oldGroupName: string, newGroupName: string) => string | null
onToggle: () => void
}) {
const [newGroupName, setNewGroupName] = useState(groupName)
const [newNameError, setNewNameError] = useState<string | null>(null)
const saveNewGroupName = () => {
const errMsg = save(groupName, newGroupName)
if (errMsg) {
setNewNameError(errMsg)
return
}
setRenaming(false)
}
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewGroupName(e.target.value)
setNewNameError(null)
}
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveNewGroupName()
}
}
return (
<>
{renaming ? (
<div className="flex gap-1 items-center">
<Input
value={newGroupName}
onChange={handleRenameInputChange}
onBlur={saveNewGroupName}
onKeyDown={handleRenameInputKeyDown}
className={`font-semibold w-24 h-8 ${newNameError ? 'border-destructive' : ''}`}
/>
<Button variant="ghost" className="h-8 w-8" onClick={saveNewGroupName}>
<Check size={18} className="text-green-500" />
</Button>
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>}
</div>
) : (
<div
className={`h-8 font-semibold flex items-center ${hasRelayUrls ? 'cursor-pointer' : 'text-muted-foreground'}`}
onClick={() => {
if (hasRelayUrls) {
onToggle()
}
}}
>
{groupName}
</div>
)}
</>
)
}
function RelayUrlsExpandToggle({
expanded,
onClick,
children
}: {
expanded: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<div
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
onClick={onClick}
>
<div className="select-none">{children}</div>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
/>
</div>
)
}
function RelayGroupOptions({
groupName,
isActive,
onDelete,
setRenaming
}: {
groupName: string
isActive: boolean
onDelete: (groupName: string) => void
setRenaming: (renaming: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisVertical
size={16}
className="text-muted-foreground hover:text-accent-foreground cursor-pointer"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenaming(true)}>Rename</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActive}
onClick={() => onDelete(groupName)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,146 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import client from '@renderer/services/client.service'
import { CircleX } from 'lucide-react'
import { useEffect, useState } from 'react'
export default function RelayUrls({
isActive,
relayUrls: rawRelayUrls,
update
}: {
isActive: boolean
relayUrls: string[]
update: (urls: string[]) => void
}) {
const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
const [relays, setRelays] = useState<
{
url: string
isConnected: boolean
}[]
>(rawRelayUrls.map((url) => ({ url, isConnected: false })))
useEffect(() => {
const interval = setInterval(() => {
const connectionStatusMap = client.listConnectionStatus()
setRelays((pre) => {
return pre.map((relay) => {
const isConnected = connectionStatusMap.get(relay.url) || false
return { ...relay, isConnected }
})
})
}, 1000)
return () => clearInterval(interval)
}, [])
const removeRelayUrl = (url: string) => {
setRelays((relays) => relays.filter((relay) => relay.url !== url))
update(relays.map(({ url }) => url).filter((u) => u !== url))
}
const saveNewRelayUrl = () => {
const normalizedUrl = normalizeURL(newRelayUrl)
if (relays.some(({ url }) => url === normalizedUrl)) {
return setNewRelayUrlError('already exists')
}
if (/^wss?:\/\/.+$/.test(normalizedUrl) === false) {
return setNewRelayUrlError('invalid URL')
}
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
update(newRelayUrls)
setNewRelayUrl('')
}
const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewRelayUrl(e.target.value)
setNewRelayUrlError(null)
}
const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveNewRelayUrl()
}
}
return (
<>
<div className="mt-1">
{relays.map(({ url, isConnected: isConnected }, index) => (
<RelayUrl
key={index}
isActive={isActive}
url={url}
isConnected={isConnected}
onRemove={() => removeRelayUrl(url)}
/>
))}
</div>
<div className="mt-2 flex gap-2">
<Input
className={`h-8 ${newRelayUrlError ? 'border-destructive' : ''}`}
placeholder="Add new relay URL"
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl}
/>
<Button className="h-8 w-12" onClick={saveNewRelayUrl}>
Add
</Button>
</div>
{newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>}
</>
)
}
function RelayUrl({
isActive,
url,
isConnected,
onRemove
}: {
isActive: boolean
url: string
isConnected: boolean
onRemove: () => void
}) {
return (
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
{!isActive ? (
<div className="text-muted-foreground"></div>
) : isConnected ? (
<div className="text-green-500"></div>
) : (
<div className="text-red-500"></div>
)}
<div className="text-muted-foreground text-sm">{url}</div>
</div>
<div>
<CircleX
size={16}
onClick={onRemove}
className="text-muted-foreground hover:text-destructive cursor-pointer"
/>
</div>
</div>
)
}
// copy from nostr-tools/utils
function normalizeURL(url: string): string {
if (url.indexOf('://') === -1) url = 'wss://' + url
const p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:'))
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}

View File

@@ -0,0 +1,143 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { Separator } from '@renderer/components/ui/separator'
import storage from '@renderer/services/storage.service'
import { useEffect, useRef, useState } from 'react'
import RelayGroup from './RelayGroup'
import { TRelayGroup } from './types'
export default function RelaySettings() {
const [groups, setGroups] = useState<TRelayGroup[]>([])
const [newGroupName, setNewGroupName] = useState('')
const [newNameError, setNewNameError] = useState<string | null>(null)
const dummyRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const init = async () => {
const storedGroups = await storage.getRelayGroups()
setGroups(storedGroups)
}
if (dummyRef.current) {
dummyRef.current.focus()
}
init()
}, [])
const updateGroups = async (newGroups: TRelayGroup[]) => {
setGroups(newGroups)
await storage.setRelayGroups(newGroups)
}
const switchRelayGroup = (groupName: string) => {
updateGroups(
groups.map((group) => ({
...group,
isActive: group.groupName === groupName
}))
)
}
const deleteRelayGroup = (groupName: string) => {
updateGroups(groups.filter((group) => group.groupName !== groupName || group.isActive))
}
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
updateGroups(
groups.map((group) => ({
...group,
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
}))
)
}
const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
if (newGroupName === '') {
return null
}
if (oldGroupName === newGroupName) {
return null
}
if (groups.some((group) => group.groupName === newGroupName)) {
return 'already exists'
}
updateGroups(
groups.map((group) => ({
...group,
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
}))
)
return null
}
const addRelayGroup = () => {
if (newGroupName === '') {
return
}
if (groups.some((group) => group.groupName === newGroupName)) {
return setNewNameError('already exists')
}
setNewGroupName('')
updateGroups([
...groups,
{
groupName: newGroupName,
relayUrls: [],
isActive: false
}
])
}
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewGroupName(e.target.value)
setNewNameError(null)
}
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
addRelayGroup()
}
}
return (
<div>
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
<div className="text-lg font-semibold mb-4">Relay Settings</div>
<div className="space-y-2">
{groups.map((group, index) => (
<RelayGroup
key={index}
group={group}
onSwitch={switchRelayGroup}
onDelete={deleteRelayGroup}
onRename={renameRelayGroup}
onRelayUrlsUpdate={updateRelayGroupRelayUrls}
/>
))}
</div>
{groups.length < 5 && (
<>
<Separator className="my-4" />
<div className="w-full border rounded-lg p-4">
<div className="flex justify-between items-center">
<div className="font-semibold">Add a new relay group</div>
</div>
<div className="mt-2 flex gap-2">
<Input
className={`h-8 ${newNameError ? 'border-destructive' : ''}`}
placeholder="Group name"
value={newGroupName}
onChange={handleNewGroupNameChange}
onKeyDown={handleNewGroupNameKeyDown}
onBlur={addRelayGroup}
/>
<Button className="h-8 w-12">Add</Button>
</div>
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,5 @@
export type TRelayGroup = {
groupName: string
relayUrls: string[]
isActive: boolean
}

View File

@@ -0,0 +1,55 @@
import { Event } from 'nostr-tools'
import { formatTimestamp } from '@renderer/lib/timestamp'
import Content from '../Content'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function ReplyNote({
event,
parentEvent,
onClickParent = () => {},
highlight = false
}: {
event: Event
parentEvent?: Event
onClickParent?: (eventId: string) => void
highlight?: boolean
}) {
return (
<div
className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
>
<UserAvatar userId={event.pubkey} size="small" className="shrink-0" />
<div className="w-full overflow-hidden">
<div className="flex gap-1 items-end">
<Username
userId={event.pubkey}
className="text-xs font-semibold text-muted-foreground hover:text-foreground truncate"
/>
<div className="text-xs text-muted-foreground shrink-0">
{formatTimestamp(event.created_at)}
</div>
</div>
{parentEvent && (
<div
className="text-xs text-muted-foreground truncate hover:text-foreground cursor-pointer"
onClick={() => onClickParent(parentEvent.id)}
>
<ParentReplyNote event={parentEvent} />
</div>
)}
<Content event={event} size="small" />
</div>
</div>
)
}
function ParentReplyNote({ event }: { event: Event }) {
return (
<div className="flex space-x-1 items-center text-xs border rounded-lg w-fit px-1 bg-muted max-w-full">
<div>reply to</div>
<UserAvatar userId={event.pubkey} size="tiny" />
<div className="truncate">{event.content}</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { Separator } from '@renderer/components/ui/separator'
import { getParentEventId } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils'
import client from '@renderer/services/client.service'
import { createReplyCountChangedEvent, eventBus } from '@renderer/services/event-bus.service'
import dayjs from 'dayjs'
import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import ReplyNote from '../ReplyNote'
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
const [eventsWithParentIds, setEventsWithParentId] = useState<[Event, string | undefined][]>([])
const [eventMap, setEventMap] = useState<Record<string, Event>>({})
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [loading, setLoading] = useState<boolean>(false)
const [hasMore, setHasMore] = useState<boolean>(false)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const loadMore = async () => {
setLoading(true)
const events = await client.fetchEvents([
{
'#e': [event.id],
kinds: [1],
limit: 200,
until
}
])
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at)
if (sortedEvents.length > 0) {
const eventMap: Record<string, Event> = {}
const eventsWithParentIds = sortedEvents.map((event) => {
eventMap[event.id] = event
return [event, getParentEventId(event)] as [Event, string | undefined]
})
setEventsWithParentId((pre) => [...eventsWithParentIds, ...pre])
setEventMap((pre) => ({ ...pre, ...eventMap }))
setUntil(sortedEvents[0].created_at - 1)
}
setHasMore(sortedEvents.length >= 200)
setLoading(false)
}
useEffect(() => {
loadMore()
}, [])
useEffect(() => {
eventBus.emit(createReplyCountChangedEvent(event.id, eventsWithParentIds.length))
}, [eventsWithParentIds])
const onClickParent = (eventId: string) => {
const ref = replyRefs.current[eventId]
if (ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
}
setHighlightReplyId(eventId)
setTimeout(() => {
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
}, 1500)
}
return (
<>
<div
className={`text-xs text-center my-2 text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
</div>
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator />}
<div className={cn('mt-2', className)}>
{eventsWithParentIds.map(([event, parentEventId], index) => (
<div ref={(el) => (replyRefs.current[event.id] = el)} key={index}>
<ReplyNote
event={event}
parentEvent={parentEventId ? eventMap[parentEventId] : undefined}
onClickParent={onClickParent}
highlight={highlightReplyId === event.id}
/>
</div>
))}
</div>
{eventsWithParentIds.length === 0 && !loading && !hasMore && (
<div className="text-xs text-center text-muted-foreground">no replies</div>
)}
</>
)
}

View File

@@ -0,0 +1,39 @@
import { Button } from '@renderer/components/ui/button'
import { ChevronUp } from 'lucide-react'
import { useEffect, useState } from 'react'
export default function ScrollToTopButton({
scrollAreaRef
}: {
scrollAreaRef: React.RefObject<HTMLDivElement>
}) {
const [showScrollToTop, setShowScrollToTop] = useState(false)
const handleScrollToTop = () => {
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleScroll = () => {
if (scrollAreaRef.current) {
setShowScrollToTop(scrollAreaRef.current.scrollTop > 1000)
}
}
useEffect(() => {
const scrollArea = scrollAreaRef.current
scrollArea?.addEventListener('scroll', handleScroll)
return () => {
scrollArea?.removeEventListener('scroll', handleScroll)
}
}, [])
return (
<Button
variant="secondary-2"
className={`absolute bottom-4 right-2 rounded-full w-10 h-10 p-0 hover:text-background transition-transform ${showScrollToTop ? '' : 'translate-y-14'}`}
onClick={handleScrollToTop}
>
<ChevronUp />
</Button>
)
}

View File

@@ -0,0 +1,46 @@
import { Button } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
export function Titlebar({
children,
className
}: {
children?: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
'draggable absolute top-0 w-full h-9 z-50 bg-background/80 backdrop-blur-xl flex items-center font-semibold space-x-1 px-1',
className
)}
>
{children}
</div>
)
}
export function TitlebarButton({
onClick,
disabled,
children,
title
}: {
onClick?: () => void
disabled?: boolean
children: React.ReactNode
title?: string
}) {
return (
<Button
className="non-draggable"
variant="ghost"
size="xs"
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</Button>
)
}

View File

@@ -0,0 +1,50 @@
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
import { Skeleton } from '@renderer/components/ui/skeleton'
import { useFetchProfile } from '@renderer/hooks'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { toProfile } from '@renderer/lib/url'
import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard'
const UserAvatarSizeCnMap = {
large: 'w-24 h-24',
normal: 'w-10 h-10',
small: 'w-7 h-7',
tiny: 'w-3 h-3'
}
export default function UserAvatar({
userId,
className,
size = 'normal'
}: {
userId: string
className?: string
size?: 'large' | 'normal' | 'small' | 'tiny'
}) {
const { avatar, pubkey } = useFetchProfile(userId)
if (!pubkey)
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
const defaultAvatar = generateImageByPubkey(pubkey)
return (
<HoverCard>
<HoverCardTrigger>
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
<Avatar className={cn(UserAvatarSizeCnMap[size], className)}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
</AvatarFallback>
</Avatar>
</SecondaryPageLink>
</HoverCardTrigger>
<HoverCardContent className="w-72">
<ProfileCard pubkey={pubkey} />
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -0,0 +1,39 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
import { useFetchProfile } from '@renderer/hooks'
import { toProfile } from '@renderer/lib/url'
import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard'
export default function Username({
userId,
showAt = false,
className
}: {
userId: string
showAt?: boolean
className?: string
}) {
const { username, pubkey } = useFetchProfile(userId)
if (!pubkey) return null
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className={cn('inline-block', className)}>
<SecondaryPageLink
to={toProfile(pubkey)}
className={cn('truncate hover:underline')}
onClick={(e) => e.stopPropagation()}
>
{showAt && '@'}
{username}
</SecondaryPageLink>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-72">
<ProfileCard pubkey={pubkey} />
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@renderer/lib/utils'
import NsfwOverlay from '../NsfwOverlay'
export default function VideoPlayer({
src,
className,
isNsfw = false,
size = 'normal'
}: {
src: string
className?: string
isNsfw?: boolean
size?: 'normal' | 'small'
}) {
return (
<div className="relative">
<video
controls
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
src={src}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { TTheme, TThemeSetting } from '@common/types'
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: TTheme
}
type ThemeProviderState = {
themeSetting: TThemeSetting
setThemeSetting: (themeSetting: TThemeSetting) => void
}
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
(localStorage.getItem('themeSetting') as TTheme) ?? 'system'
)
const [theme, setTheme] = useState<TTheme>('light')
const init = async () => {
const [themeSetting, theme] = await Promise.all([
window.api.theme.themeSetting(),
window.api.theme.current()
])
localStorage.setItem('theme', theme)
setTheme(theme)
setThemeSetting(themeSetting)
window.api.theme.onChange((theme) => {
localStorage.setItem('theme', theme)
setTheme(theme)
})
}
useEffect(() => {
init()
}, [])
useEffect(() => {
const updateTheme = async () => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}
updateTheme()
}, [theme])
const value = {
themeSetting: themeSetting,
setThemeSetting: (themeSetting: TThemeSetting) => {
window.api.theme.set(themeSetting).then(() => setThemeSetting(themeSetting))
}
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
return context
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@renderer/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,35 +1,34 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@renderer/lib/utils"
import { cn } from '@renderer/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
xs: 'h-7 w-7 p-0 rounded-full'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
}
)
@@ -41,16 +40,12 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = "Button"
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

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

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,183 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@renderer/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@renderer/lib/utils'
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
className={cn(
'z-50 w-64 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
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '@renderer/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@renderer/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@renderer/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,40 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@renderer/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollBarClassName?: string }
>(({ className, scrollBarClassName, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport ref={ref} className="h-full w-full rounded-[inherit] *:!block">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className={scrollBarClassName} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@renderer/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@renderer/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@renderer/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,31 @@
import { useToast } from '@renderer/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from '@renderer/components/ui/toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,9 @@
import { EmbeddedHashtag } from '../components/Embedded'
import { TEmbeddedRenderer } from './types'
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([^\s#]+)/g,
render: (hashtag: string, index: number) => {
return <EmbeddedHashtag key={`hashtag-${index}`} hashtag={hashtag} />
}
}

View File

@@ -0,0 +1,9 @@
import { EmbeddedNormalUrl } from '../components/Embedded'
import { TEmbeddedRenderer } from './types'
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g,
render: (url: string, index: number) => {
return <EmbeddedNormalUrl key={`normal-url-${index}`} url={url} />
}
}

View File

@@ -0,0 +1,10 @@
import { EmbeddedMention } from '../components/Embedded'
import { TEmbeddedRenderer } from './types'
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-npub-${index}`} userId={npub1} />
}
}

View File

@@ -0,0 +1,10 @@
import { EmbeddedMention } from '../components/Embedded'
import { TEmbeddedRenderer } from './types'
export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-profile-${index}`} userId={nprofile} />
}
}

View File

@@ -0,0 +1,9 @@
import { EmbeddedMention } from '../components/Embedded'
import { TEmbeddedRenderer } from './types'
export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => {
return <EmbeddedMention key={`embedded-npub-${index}`} userId={npub1} />
}
}

View File

@@ -0,0 +1,17 @@
import reactStringReplace from 'react-string-replace'
import { TEmbeddedRenderer } from './types'
export * from './EmbeddedHashtag'
export * from './EmbeddedNormalUrl'
export * from './EmbeddedNostrNpub'
export * from './EmbeddedNostrProfile'
export function embedded(content: string, renderers: TEmbeddedRenderer[]) {
let nodes: React.ReactNode[] = [content]
renderers.forEach((renderer) => {
nodes = reactStringReplace(nodes, renderer.regex, renderer.render)
})
return nodes
}

View File

@@ -0,0 +1,4 @@
export type TEmbeddedRenderer = {
regex: RegExp
render: (match: string, index: number) => JSX.Element
}

View File

@@ -0,0 +1,2 @@
export * from './useFetchEvent'
export * from './useFetchProfile'

View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@renderer/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -0,0 +1,50 @@
import client from '@renderer/services/client.service'
import { Event, Filter, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
export function useFetchEventById(id?: string) {
const [event, setEvent] = useState<Event | undefined>(undefined)
useEffect(() => {
const fetchEvent = async () => {
if (!id) return
let filter: Filter | undefined
if (/^[0-9a-f]{64}$/.test(id)) {
filter = { ids: [id] }
} else if (/^note1[a-z0-9]{58}$/.test(id)) {
const { data } = nip19.decode(id as `note1${string}`)
filter = { ids: [data] }
} else if (id.startsWith('nevent1')) {
const { data } = nip19.decode(id as `nevent1${string}`)
filter = {}
if (data.id) {
filter.ids = [data.id]
}
if (data.author) {
filter.authors = [data.author]
}
if (data.kind) {
filter.kinds = [data.kind]
}
}
if (!filter) return
let event: Event | undefined
if (filter.ids) {
event = await client.fetchEventById(filter.ids[0])
} else {
event = await client.fetchEventWithCache(filter)
}
if (event) {
setEvent(event)
} else {
setEvent(undefined)
}
}
fetchEvent()
}, [id])
return event
}

View File

@@ -0,0 +1,29 @@
import client from '@renderer/services/client.service'
import { TEventStats } from '@renderer/types'
import { useEffect, useState } from 'react'
export default function useFetchEventStats(eventId: string) {
const [stats, setStats] = useState<TEventStats>({
reactionCount: 0,
repostCount: 0
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
setLoading(true)
try {
const stats = await client.fetchEventStatsById(eventId)
setStats(stats)
} catch (error) {
console.error('Failed to fetch event stats', error)
} finally {
setLoading(false)
}
}
fetchStats()
}, [eventId])
return { stats, loading }
}

View File

@@ -0,0 +1,19 @@
import { verifyNip05 } from '@renderer/lib/nip05'
import { useEffect, useState } from 'react'
export function useFetchNip05(nip05?: string, pubkey?: string) {
const [nip05IsVerified, setNip05IsVerified] = useState(false)
const [nip05Name, setNip05Name] = useState<string>('')
const [nip05Domain, setNip05Domain] = useState<string>('')
useEffect(() => {
if (!nip05 || !pubkey) return
verifyNip05(nip05, pubkey).then(({ isVerified, nip05Name, nip05Domain }) => {
setNip05IsVerified(isVerified)
setNip05Name(nip05Name)
setNip05Domain(nip05Domain)
})
}, [nip05, pubkey])
return { nip05IsVerified, nip05Name, nip05Domain }
}

View File

@@ -0,0 +1,69 @@
import { formatNpub } from '@renderer/lib/pubkey'
import client from '@renderer/services/client.service'
import { nip19 } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
type TProfile = {
username: string
pubkey?: string
npub?: `npub1${string}`
banner?: string
avatar?: string
nip05?: string
about?: string
}
const decodeUserId = (id: string): { pubkey?: string; npub?: `npub1${string}` } => {
if (/^npub1[a-z0-9]{58}$/.test(id)) {
const { data } = nip19.decode(id as `npub1${string}`)
return { pubkey: data, npub: id as `npub1${string}` }
} else if (id.startsWith('nprofile1')) {
const { data } = nip19.decode(id as `nprofile1${string}`)
return { pubkey: data.pubkey, npub: nip19.npubEncode(data.pubkey) }
} else if (/^[0-9a-f]{64}$/.test(id)) {
return { pubkey: id, npub: nip19.npubEncode(id) }
}
return {}
}
export function useFetchProfile(id?: string) {
const initialProfile: TProfile = {
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
}
const [profile, setProfile] = useState<TProfile>(initialProfile)
const fetchProfile = useCallback(async () => {
try {
if (!id) return
const { pubkey, npub } = decodeUserId(id)
if (!pubkey || !npub) return
const profileEvent = await client.fetchProfile(pubkey)
const username = npub ? formatNpub(npub) : initialProfile.username
setProfile({ pubkey, npub, username })
if (!profileEvent) return
const profileObj = JSON.parse(profileEvent.content)
setProfile({
...initialProfile,
pubkey,
npub,
banner: profileObj.banner,
avatar: profileObj.picture,
username:
profileObj.display_name?.trim() || profileObj.name?.trim() || initialProfile.username,
nip05: profileObj.nip05,
about: profileObj.about
})
} catch (err) {
console.error(err)
}
}, [id])
useEffect(() => {
fetchProfile()
}, [id])
return profile
}

View File

@@ -0,0 +1,24 @@
import RelaySettings from '@renderer/components/RelaySettings'
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Server } from 'lucide-react'
export default function RelaySettingsPopover() {
return (
<Popover>
<PopoverTrigger
className="non-draggable h-7 w-7 p-0 rounded-full flex items-center justify-center hover:bg-accent hover:text-accent-foreground"
title="relay settings"
>
<Server size={16} className="text-foreground" />
</PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0">
<ScrollArea className="h-full">
<div className="p-4">
<RelaySettings />
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,14 @@
import { TitlebarButton } from '@renderer/components/Titlebar'
import { createReloadTimelineEvent, eventBus } from '@renderer/services/event-bus.service'
import { Eraser } from 'lucide-react'
export default function ReloadTimelineButton() {
return (
<TitlebarButton
onClick={() => eventBus.emit(createReloadTimelineEvent())}
title="reload timeline"
>
<Eraser />
</TitlebarButton>
)
}

View File

@@ -0,0 +1,56 @@
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
import { Titlebar } from '@renderer/components/Titlebar'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import ReloadTimelineButton from './ReloadTimelineButton'
import RelaySettingsPopover from './RelaySettingsPopover'
const PrimaryPageLayout = forwardRef(
(
{ children, titlebarContent }: { children: React.ReactNode; titlebarContent?: React.ReactNode },
ref
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
useImperativeHandle(
ref,
() => ({
scrollToTop: () => {
scrollAreaRef.current?.scrollTo({ top: 0 })
}
}),
[]
)
return (
<ScrollArea
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<PrimaryPageTitlebar content={titlebarContent} />
<div className="px-4 pb-4 pt-11">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
</ScrollArea>
)
}
)
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
export default PrimaryPageLayout
export type TPrimaryPageLayoutRef = {
scrollToTop: () => void
}
export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) {
return (
<Titlebar className={`justify-between ${isMacOS() ? 'pl-20' : ''}`}>
<div>{content}</div>
<div className="flex gap-1">
<ReloadTimelineButton />
<RelaySettingsPopover />
</div>
</Titlebar>
)
}

View File

@@ -0,0 +1,17 @@
import { TitlebarButton } from '@renderer/components/Titlebar'
import { useSecondaryPage } from '@renderer/PageManager'
import { ChevronLeft } from 'lucide-react'
export default function BackButton({ hide = false }: { hide?: boolean }) {
const { pop } = useSecondaryPage()
return (
<>
{!hide && (
<TitlebarButton onClick={() => pop()}>
<ChevronLeft />
</TitlebarButton>
)}
</>
)
}

View File

@@ -0,0 +1,25 @@
import { useTheme } from '@renderer/components/theme-provider'
import { TitlebarButton } from '@renderer/components/Titlebar'
import { Moon, Sun, SunMoon } from 'lucide-react'
export default function ThemeToggle() {
const { themeSetting, setThemeSetting } = useTheme()
return (
<>
{themeSetting === 'system' ? (
<TitlebarButton onClick={() => setThemeSetting('light')} title="switch to light theme">
<SunMoon />
</TitlebarButton>
) : themeSetting === 'light' ? (
<TitlebarButton onClick={() => setThemeSetting('dark')} title="switch to dark theme">
<Sun />
</TitlebarButton>
) : (
<TitlebarButton onClick={() => setThemeSetting('system')} title="switch to system theme">
<Moon />
</TitlebarButton>
)}
</>
)
}

View File

@@ -0,0 +1,50 @@
import ScrollToTopButton from '@renderer/components/ScrollToTopButton'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { isMacOS } from '@renderer/lib/platform'
import { useRef } from 'react'
import { Titlebar } from '../../components/Titlebar'
import BackButton from './BackButton'
import ThemeToggle from './ThemeToggle'
export default function SecondaryPageLayout({
children,
titlebarContent,
hideBackButton = false
}: {
children: React.ReactNode
titlebarContent?: React.ReactNode
hideBackButton?: boolean
}): JSX.Element {
const scrollAreaRef = useRef<HTMLDivElement>(null)
return (
<ScrollArea
ref={scrollAreaRef}
className="h-full"
scrollBarClassName={isMacOS() ? 'pt-9' : 'pt-4'}
>
<SecondaryPageTitlebar content={titlebarContent} hideBackButton={hideBackButton} />
<div className="px-4 pb-4 pt-11 w-full h-full">{children}</div>
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
</ScrollArea>
)
}
export function SecondaryPageTitlebar({
content,
hideBackButton = false
}: {
content?: React.ReactNode
hideBackButton?: boolean
}): JSX.Element {
return (
<Titlebar className="justify-between">
<div className="flex items-center gap-1 flex-1 w-0">
<BackButton hide={hideBackButton} />
<div className="truncate">{content}</div>
</div>
<div className="flex-shrink-0">
<ThemeToggle />
</div>
</Titlebar>
)
}

View File

@@ -0,0 +1,23 @@
import { Event, kinds } from 'nostr-tools'
export function isNsfwEvent(event: Event) {
return event.tags.some(
([tagName, tagValue]) =>
tagName === 'content-warning' || (tagName === 't' && tagValue.toLowerCase() === 'nsfw')
)
}
export function isReplyNoteEvent(event: Event) {
return (
event.kind === kinds.ShortTextNote &&
event.tags.some(([tagName, , , type]) => tagName === 'e' && ['root', 'reply'].includes(type))
)
}
export function getParentEventId(event: Event) {
return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'reply')?.[1]
}
export function getRootEventId(event: Event) {
return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'root')?.[1]
}

View File

@@ -0,0 +1,41 @@
import { LRUCache } from 'lru-cache'
type TVerifyNip05Result = {
isVerified: boolean
nip05Name: string
nip05Domain: string
}
const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
max: 1000,
fetchMethod: (key) => {
const { nip05, pubkey } = JSON.parse(key)
return _verifyNip05(nip05, pubkey)
}
})
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined]
const result = { isVerified: false, nip05Name, nip05Domain }
if (!nip05Name || !nip05Domain || !pubkey) return result
try {
const res = await fetch(`https://${nip05Domain}/.well-known/nostr.json?name=${nip05Name}`)
const json = await res.json()
if (json.names?.[nip05Name] === pubkey) {
return { ...result, isVerified: true }
}
} catch {
// ignore
}
return result
}
export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const result = await verifyNip05ResultCache.fetch(JSON.stringify({ nip05, pubkey }))
if (result) {
return result
}
const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined]
return { isVerified: false, nip05Name, nip05Domain }
}

View File

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

View File

@@ -0,0 +1,91 @@
import { LRUCache } from 'lru-cache'
import { nip19 } from 'nostr-tools'
export function formatPubkey(pubkey: string) {
const npub = pubkeyToNpub(pubkey)
if (npub) {
return formatNpub(npub)
}
return pubkey.slice(0, 4) + '...' + pubkey.slice(-4)
}
export function formatNpub(npub: string, length = 12) {
if (length < 12) {
length = 12
}
if (length >= 63) {
return npub
}
const prefixLength = Math.floor((length - 5) / 2) + 5
const suffixLength = length - prefixLength
return npub.slice(0, prefixLength) + '...' + npub.slice(-suffixLength)
}
export function pubkeyToNpub(pubkey: string) {
try {
return nip19.npubEncode(pubkey)
} catch {
return null
}
}
export function userIdToPubkey(userId: string) {
if (userId.startsWith('npub1')) {
const { data } = nip19.decode(userId as `npub1${string}`)
return data
}
return userId
}
const pubkeyImageCache = new LRUCache<string, string>({ max: 1000 })
export function generateImageByPubkey(pubkey: string): string {
if (pubkeyImageCache.has(pubkey)) {
return pubkeyImageCache.get(pubkey)!
}
const paddedPubkey = pubkey.padEnd(2, '0')
// Split into 3 parts for colors and the rest for control points
const colors: string[] = []
const controlPoints: string[] = []
for (let i = 0; i < 11; i++) {
const part = paddedPubkey.slice(i * 6, (i + 1) * 6)
if (i < 3) {
colors.push(`#${part}`)
} else {
controlPoints.push(part)
}
}
// Generate SVG with multiple radial gradients
const gradients = controlPoints
.map((point, index) => {
const cx = parseInt(point.slice(0, 2), 16) % 100
const cy = parseInt(point.slice(2, 4), 16) % 100
const r = (parseInt(point.slice(4, 6), 16) % 35) + 30
const c = colors[index % (colors.length - 1)]
return `
<radialGradient id="grad${index}-${pubkey}" cx="${cx}%" cy="${cy}%" r="${r}%">
<stop offset="0%" style="stop-color:${c};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c};stop-opacity:0" />
</radialGradient>
<rect width="100%" height="100%" fill="url(#grad${index}-${pubkey})" />
`
})
.join('')
const image = `
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${colors[2]}" fill-opacity="0.3" />
${gradients}
</svg>
`
const imageData = `data:image/svg+xml;base64,${btoa(image)}`
pubkeyImageCache.set(pubkey, imageData)
return imageData
}

View File

@@ -0,0 +1,28 @@
import dayjs from 'dayjs'
export function formatTimestamp(timestamp: number) {
const time = dayjs(timestamp * 1000)
const now = dayjs()
const diffMonth = now.diff(time, 'month')
if (diffMonth >= 1) {
return time.format('MMM D, YYYY')
}
const diffDay = now.diff(time, 'day')
if (diffDay >= 1) {
return `${diffDay} days ago`
}
const diffHour = now.diff(time, 'hour')
if (diffHour >= 1) {
return `${diffHour} hours ago`
}
const diffMinute = now.diff(time, 'minute')
if (diffMinute >= 1) {
return `${diffMinute} minutes ago`
}
return 'just now'
}

View File

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

View File

@@ -0,0 +1,10 @@
import NoteList from '@renderer/components/NoteList'
import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
export default function NoteListPage() {
return (
<PrimaryPageLayout>
<NoteList isHomeTimeline filter={{ limit: 200 }} />
</PrimaryPageLayout>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import ReplyNoteList from '@renderer/components/ReplyNoteList'
import Note from '@renderer/components/Note'
import { Separator } from '@renderer/components/ui/separator'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { Event } from 'nostr-tools'
export default function NotePage({ event }: { event?: Event }) {
return (
<SecondaryPageLayout titlebarContent="note">
{event && (
<>
<Note key={`note-${event.id}`} event={event} displayStats />
<Separator className="mt-2" />
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
</>
)}
</SecondaryPageLayout>
)
}

View File

@@ -0,0 +1,93 @@
import Nip05 from '@renderer/components/Nip05'
import NoteList from '@renderer/components/NoteList'
import ProfileAbout from '@renderer/components/ProfileAbout'
import { Separator } from '@renderer/components/ui/separator'
import UserAvatar from '@renderer/components/UserAvatar'
import { useFetchProfile } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { Copy } from 'lucide-react'
import { useEffect, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const { banner, username, nip05, about, npub } = useFetchProfile(pubkey)
const [copied, setCopied] = useState(false)
if (!pubkey || !npub) return null
const copyNpub = () => {
if (!npub) return
navigator.clipboard.writeText(npub)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<SecondaryPageLayout titlebarContent={username}>
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-12">
<ProfileBanner
banner={banner}
pubkey={pubkey}
className="w-full h-full object-cover rounded-lg"
/>
<UserAvatar
userId={pubkey}
size="large"
className="absolute bottom-0 left-4 translate-y-1/2 border-4 border-background"
/>
</div>
<div className="px-4 space-y-1">
<div className="text-xl font-semibold">{username}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
<div
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full hover:text-foreground cursor-pointer"
onClick={() => copyNpub()}
>
{copied ? (
<div>Copied!</div>
) : (
<>
<div>{formatNpub(npub, 24)}</div>
<Copy size={14} />
</>
)}
</div>
<div className="text-sm text-wrap break-words whitespace-pre-wrap">
<ProfileAbout about={about} />
</div>
</div>
<Separator className="my-2" />
<NoteList key={pubkey} filter={{ authors: [pubkey] }} />
</SecondaryPageLayout>
)
}
function ProfileBanner({
banner,
pubkey,
className
}: {
banner?: string
pubkey: string
className?: string
}) {
const defaultBanner = generateImageByPubkey(pubkey)
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
useEffect(() => {
if (banner) {
setBannerUrl(banner)
} else {
setBannerUrl(defaultBanner)
}
}, [pubkey, banner])
return (
<img
src={bannerUrl}
alt="Banner"
className={className}
onError={() => setBannerUrl(defaultBanner)}
/>
)
}

View File

@@ -0,0 +1,225 @@
import { TRelayGroup } from '@common/types'
import { TEventStats } from '@renderer/types'
import { LRUCache } from 'lru-cache'
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.service'
class ClientService {
static instance: ClientService
private pool = new SimplePool()
private initPromise!: Promise<void>
private relayUrls: string[] = []
private cache = new LRUCache<string, NEvent>({
max: 10000,
fetchMethod: async (filter) => this.fetchEvent(JSON.parse(filter))
})
// Event cache
private eventsCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
ttl: 1000 * 60 * 10 // 10 minutes
})
private fetchEventQueue = new Map<
string,
{
resolve: (value: NEvent | undefined) => void
reject: (reason: any) => void
}
>()
private fetchEventTimer: NodeJS.Timeout | null = null
// Event stats cache
private eventStatsCache = new LRUCache<string, Promise<TEventStats>>({
max: 10000,
ttl: 1000 * 60 * 10, // 10 minutes
fetchMethod: async (id) => this._fetchEventStatsById(id)
})
// Profile cache
private profilesCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
ttl: 1000 * 60 * 10 // 10 minutes
})
private fetchProfileQueue = new Map<
string,
{
resolve: (value: NEvent | undefined) => void
reject: (reason: any) => void
}
>()
private fetchProfileTimer: NodeJS.Timeout | null = null
constructor() {
if (!ClientService.instance) {
this.initPromise = this.init()
ClientService.instance = this
}
return ClientService.instance
}
async init() {
const relayGroups = await storage.getRelayGroups()
this.relayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
eventBus.on(EVENT_TYPES.RELAY_GROUPS_CHANGED, (event) => {
this.onRelayGroupsChange(event.detail)
})
}
onRelayGroupsChange(relayGroups: TRelayGroup[]) {
const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? []
if (
newRelayUrls.length === this.relayUrls.length &&
newRelayUrls.every((url) => this.relayUrls.includes(url))
) {
return
}
this.relayUrls = newRelayUrls
}
listConnectionStatus() {
return this.pool.listConnectionStatus()
}
async fetchEvents(filters: Filter[]) {
await this.initPromise
return new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = []
this.pool.subscribeManyEose(this.relayUrls, filters, {
onevent(event) {
events.push(event)
},
onclose() {
resolve(events)
}
})
})
}
async fetchEventWithCache(filter: Filter) {
return this.cache.fetch(JSON.stringify(filter))
}
async fetchEvent(filter: Filter) {
const events = await this.fetchEvents([{ ...filter, limit: 1 }])
return events.length ? events[0] : undefined
}
async fetchEventById(id: string): Promise<NEvent | undefined> {
const cache = this.eventsCache.get(id)
if (cache) {
return cache
}
const promise = new Promise<NEvent | undefined>((resolve, reject) => {
this.fetchEventQueue.set(id, { resolve, reject })
if (this.fetchEventTimer) {
return
}
this.fetchEventTimer = setTimeout(async () => {
this.fetchEventTimer = null
const queue = new Map(this.fetchEventQueue)
this.fetchEventQueue.clear()
try {
const ids = Array.from(queue.keys())
const events = await this.fetchEvents([{ ids, limit: ids.length }])
for (const event of events) {
queue.get(event.id)?.resolve(event)
queue.delete(event.id)
}
for (const [, job] of queue) {
job.resolve(undefined)
}
queue.clear()
} catch (err) {
for (const [id, job] of queue) {
this.eventsCache.delete(id)
job.reject(err)
}
}
}, 20)
})
this.eventsCache.set(id, promise)
return promise
}
async fetchEventStatsById(id: string): Promise<TEventStats> {
const stats = await this.eventStatsCache.fetch(id)
return stats ?? { reactionCount: 0, repostCount: 0 }
}
private async _fetchEventStatsById(id: string) {
const [reactionEvents, repostEvents] = await Promise.all([
this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]),
this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }])
])
return { reactionCount: reactionEvents.length, repostCount: repostEvents.length }
}
async fetchProfile(pubkey: string): Promise<NEvent | undefined> {
const cache = this.profilesCache.get(pubkey)
if (cache) {
return cache
}
const promise = new Promise<NEvent | undefined>((resolve, reject) => {
this.fetchProfileQueue.set(pubkey, { resolve, reject })
if (this.fetchProfileTimer) {
return
}
this.fetchProfileTimer = setTimeout(async () => {
this.fetchProfileTimer = null
const queue = new Map(this.fetchProfileQueue)
this.fetchProfileQueue.clear()
try {
const pubkeys = Array.from(queue.keys())
const events = await this.fetchEvents([
{
authors: pubkeys,
kinds: [0],
limit: pubkeys.length
}
])
const eventsMap = new Map<string, NEvent>()
for (const event of events) {
const pubkey = event.pubkey
const existing = eventsMap.get(pubkey)
if (!existing || existing.created_at < event.created_at) {
eventsMap.set(pubkey, event)
}
}
for (const [pubkey, job] of queue) {
const event = eventsMap.get(pubkey)
if (event) {
job.resolve(event)
} else {
job.resolve(undefined)
}
queue.delete(pubkey)
}
} catch (err) {
for (const [pubkey, job] of queue) {
this.profilesCache.delete(pubkey)
job.reject(err)
}
}
}, 20)
})
this.profilesCache.set(pubkey, promise)
return promise
}
}
const instance = new ClientService()
export default instance

View File

@@ -0,0 +1,43 @@
import { TRelayGroup } from '@common/types'
export const EVENT_TYPES = {
RELAY_GROUPS_CHANGED: 'relay-groups-changed',
RELOAD_TIMELINE: 'reload-timeline',
REPLY_COUNT_CHANGED: 'reply-count-changed'
} as const
type TEventMap = {
[EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[]
[EVENT_TYPES.RELOAD_TIMELINE]: unknown
[EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number }
}
type TCustomEventMap = {
[K in keyof TEventMap]: CustomEvent<TEventMap[K]>
}
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
}
export const createReloadTimelineEvent = () => {
return new CustomEvent(EVENT_TYPES.RELOAD_TIMELINE)
}
export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => {
return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } })
}
class EventBus extends EventTarget {
emit<K extends keyof TEventMap>(event: TCustomEventMap[K]): boolean {
return super.dispatchEvent(event)
}
on<K extends keyof TEventMap>(type: K, listener: (event: TCustomEventMap[K]) => void): void {
super.addEventListener(type, listener as EventListener)
}
remove<K extends keyof TEventMap>(type: K, listener: (event: TCustomEventMap[K]) => void): void {
super.removeEventListener(type, listener as EventListener)
}
}
export const eventBus = new EventBus()

View File

@@ -0,0 +1,26 @@
import { TRelayGroup } from '@common/types'
import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
class StorageService {
static instance: StorageService
constructor() {
if (!StorageService.instance) {
StorageService.instance = this
}
return StorageService.instance
}
async getRelayGroups() {
return await window.api.storage.getRelayGroups()
}
async setRelayGroups(relayGroups: TRelayGroup[]) {
await window.api.storage.setRelayGroups(relayGroups)
eventBus.emit(createRelayGroupsChangedEvent(relayGroups))
}
}
const instance = new StorageService()
export default instance

Some files were not shown because too many files have changed in this diff Show More