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

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

View File

@@ -0,0 +1 @@
export type TEventStats = { reactionCount: number; repostCount: number }