Browse Source

feat: basic browsing (#1)

imwald
Cody Tseng 1 year ago committed by GitHub
parent
commit
9b0251240c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      .github/FUNDING.yml
  2. 57
      .github/workflows/release.yml
  3. 13
      README.md
  4. BIN
      build/icon.icns
  5. BIN
      build/icon.ico
  6. BIN
      build/icon.png
  7. 1310
      package-lock.json
  8. 18
      package.json
  9. BIN
      resources/icon.png
  10. 13
      src/common/types.ts
  11. 39
      src/main/index.ts
  12. 85
      src/main/services/storage.service.ts
  13. 43
      src/main/services/theme.service.ts
  14. 1
      src/main/types.ts
  15. 14
      src/preload/index.d.ts
  16. 21
      src/preload/index.ts
  17. 4
      src/renderer/index.html
  18. 32
      src/renderer/src/App.tsx
  19. 134
      src/renderer/src/PageManager.tsx
  20. 95
      src/renderer/src/assets/main.css
  21. 125
      src/renderer/src/components/Content/index.tsx
  22. 14
      src/renderer/src/components/Embedded/EmbeddedHashtag.tsx
  23. 9
      src/renderer/src/components/Embedded/EmbeddedMention.tsx
  24. 13
      src/renderer/src/components/Embedded/EmbeddedNormalUrl.tsx
  25. 22
      src/renderer/src/components/Embedded/EmbeddedNote.tsx
  26. 4
      src/renderer/src/components/Embedded/index.tsx
  27. 55
      src/renderer/src/components/ImageGallery/index.tsx
  28. 22
      src/renderer/src/components/Nip05/index.tsx
  29. 37
      src/renderer/src/components/Note/NoteOptionsTrigger.tsx
  30. 48
      src/renderer/src/components/Note/NoteStats.tsx
  31. 29
      src/renderer/src/components/Note/RawEventDialog.tsx
  32. 52
      src/renderer/src/components/Note/index.tsx
  33. 22
      src/renderer/src/components/NoteCard/RepostNoteCard.tsx
  34. 35
      src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx
  35. 11
      src/renderer/src/components/NoteCard/index.tsx
  36. 145
      src/renderer/src/components/NoteList/index.tsx
  37. 18
      src/renderer/src/components/NsfwOverlay/index.tsx
  38. 23
      src/renderer/src/components/ProfileAbout/index.tsx
  39. 35
      src/renderer/src/components/ProfileCard/index.tsx
  40. 229
      src/renderer/src/components/RelaySettings/RelayGroup.tsx
  41. 146
      src/renderer/src/components/RelaySettings/RelayUrl.tsx
  42. 143
      src/renderer/src/components/RelaySettings/index.tsx
  43. 5
      src/renderer/src/components/RelaySettings/types.ts
  44. 55
      src/renderer/src/components/ReplyNote/index.tsx
  45. 90
      src/renderer/src/components/ReplyNoteList/index.tsx
  46. 39
      src/renderer/src/components/ScrollToTopButton/index.tsx
  47. 46
      src/renderer/src/components/Titlebar/index.tsx
  48. 50
      src/renderer/src/components/UserAvatar/index.tsx
  49. 39
      src/renderer/src/components/Username/index.tsx
  50. 25
      src/renderer/src/components/VideoPlayer/index.tsx
  51. 71
      src/renderer/src/components/theme-provider.tsx
  52. 48
      src/renderer/src/components/ui/avatar.tsx
  53. 53
      src/renderer/src/components/ui/button.tsx
  54. 79
      src/renderer/src/components/ui/card.tsx
  55. 120
      src/renderer/src/components/ui/dialog.tsx
  56. 183
      src/renderer/src/components/ui/dropdown-menu.tsx
  57. 28
      src/renderer/src/components/ui/hover-card.tsx
  58. 24
      src/renderer/src/components/ui/input.tsx
  59. 29
      src/renderer/src/components/ui/popover.tsx
  60. 42
      src/renderer/src/components/ui/radio-group.tsx
  61. 43
      src/renderer/src/components/ui/resizable.tsx
  62. 40
      src/renderer/src/components/ui/scroll-area.tsx
  63. 158
      src/renderer/src/components/ui/select.tsx
  64. 29
      src/renderer/src/components/ui/separator.tsx
  65. 15
      src/renderer/src/components/ui/skeleton.tsx
  66. 127
      src/renderer/src/components/ui/toast.tsx
  67. 31
      src/renderer/src/components/ui/toaster.tsx
  68. 9
      src/renderer/src/embedded/EmbeddedHashtag.tsx
  69. 9
      src/renderer/src/embedded/EmbeddedNormalUrl.tsx
  70. 10
      src/renderer/src/embedded/EmbeddedNostrNpub.tsx
  71. 10
      src/renderer/src/embedded/EmbeddedNostrProfile.tsx
  72. 9
      src/renderer/src/embedded/EmbeddedNpub.tsx
  73. 17
      src/renderer/src/embedded/index.tsx
  74. 4
      src/renderer/src/embedded/types.tsx
  75. 2
      src/renderer/src/hooks/index.tsx
  76. 194
      src/renderer/src/hooks/use-toast.ts
  77. 50
      src/renderer/src/hooks/useFetchEvent.tsx
  78. 29
      src/renderer/src/hooks/useFetchEventStats.tsx
  79. 19
      src/renderer/src/hooks/useFetchNip05.tsx
  80. 69
      src/renderer/src/hooks/useFetchProfile.tsx
  81. 24
      src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx
  82. 14
      src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx
  83. 56
      src/renderer/src/layouts/PrimaryPageLayout/index.tsx
  84. 17
      src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx
  85. 25
      src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx
  86. 50
      src/renderer/src/layouts/SecondaryPageLayout/index.tsx
  87. 23
      src/renderer/src/lib/event.ts
  88. 41
      src/renderer/src/lib/nip05.ts
  89. 3
      src/renderer/src/lib/platform.ts
  90. 91
      src/renderer/src/lib/pubkey.ts
  91. 28
      src/renderer/src/lib/timestamp.ts
  92. 7
      src/renderer/src/lib/url.ts
  93. 10
      src/renderer/src/pages/primary/NoteListPage/index.tsx
  94. 11
      src/renderer/src/pages/secondary/BlankPage/index.tsx
  95. 15
      src/renderer/src/pages/secondary/HashtagPage/index.tsx
  96. 19
      src/renderer/src/pages/secondary/NotePage/index.tsx
  97. 93
      src/renderer/src/pages/secondary/ProfilePage/index.tsx
  98. 225
      src/renderer/src/services/client.service.ts
  99. 43
      src/renderer/src/services/event-bus.service.ts
  100. 26
      src/renderer/src/services/storage.service.ts
  101. Some files were not shown because too many files have changed in this diff Show More

1
.github/FUNDING.yml

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

57
.github/workflows/release.yml

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

13
README.md

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

BIN
build/icon.icns

Binary file not shown.

BIN
build/icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 240 KiB

BIN
build/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 816 KiB

1310
package-lock.json generated

File diff suppressed because it is too large Load Diff

18
package.json

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

BIN
resources/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 816 KiB

13
src/common/types.ts

@ -0,0 +1,13 @@ @@ -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'

39
src/main/index.ts

@ -1,11 +1,16 @@ @@ -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 { @@ -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 { @@ -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(() => { @@ -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()

85
src/main/services/storage.service.ts

@ -0,0 +1,85 @@ @@ -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)
}
}
}

43
src/main/services/theme.service.ts

@ -0,0 +1,43 @@ @@ -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

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

14
src/preload/index.d.ts vendored

@ -1,8 +1,20 @@ @@ -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>
}
}
}
}

21
src/preload/index.ts

@ -1,8 +1,25 @@ @@ -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

4
src/renderer/index.html

@ -4,10 +4,10 @@ @@ -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>

32
src/renderer/src/App.tsx

@ -1,5 +1,29 @@ @@ -1,5 +1,29 @@
function App(): JSX.Element {
return <div>Hello</div>
}
import 'yet-another-react-lightbox/styles.css'
import './assets/main.css'
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'
export default App
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>
)
}

134
src/renderer/src/PageManager.tsx

@ -0,0 +1,134 @@ @@ -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>
)
}

95
src/renderer/src/assets/main.css

@ -19,68 +19,49 @@ @@ -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%;
}
}

125
src/renderer/src/components/Content/index.tsx

@ -0,0 +1,125 @@ @@ -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
}
}

14
src/renderer/src/components/Embedded/EmbeddedHashtag.tsx

@ -0,0 +1,14 @@ @@ -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>
)
}

9
src/renderer/src/components/Embedded/EmbeddedMention.tsx

@ -0,0 +1,9 @@ @@ -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" />
}

13
src/renderer/src/components/Embedded/EmbeddedNormalUrl.tsx

@ -0,0 +1,13 @@ @@ -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>
)
}

22
src/renderer/src/components/Embedded/EmbeddedNote.tsx

@ -0,0 +1,22 @@ @@ -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>
)
}

4
src/renderer/src/components/Embedded/index.tsx

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

55
src/renderer/src/components/ImageGallery/index.tsx

@ -0,0 +1,55 @@ @@ -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>
)
}

22
src/renderer/src/components/Nip05/index.tsx

@ -0,0 +1,22 @@ @@ -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>
)
}

37
src/renderer/src/components/Note/NoteOptionsTrigger.tsx

@ -0,0 +1,37 @@ @@ -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)}
/>
</>
)
}

48
src/renderer/src/components/Note/NoteStats.tsx

@ -0,0 +1,48 @@ @@ -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
}

29
src/renderer/src/components/Note/RawEventDialog.tsx

@ -0,0 +1,29 @@ @@ -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>
)
}

52
src/renderer/src/components/Note/index.tsx

@ -0,0 +1,52 @@ @@ -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>
)
}

22
src/renderer/src/components/NoteCard/RepostNoteCard.tsx

@ -0,0 +1,22 @@ @@ -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>
)
}

35
src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx

@ -0,0 +1,35 @@ @@ -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>
)
}

11
src/renderer/src/components/NoteCard/index.tsx

@ -0,0 +1,11 @@ @@ -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} />
}

145
src/renderer/src/components/NoteList/index.tsx

@ -0,0 +1,145 @@ @@ -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>
</>
)
}

18
src/renderer/src/components/NsfwOverlay/index.tsx

@ -0,0 +1,18 @@ @@ -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)}
/>
)
)
}

23
src/renderer/src/components/ProfileAbout/index.tsx

@ -0,0 +1,23 @@ @@ -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}</>
}

35
src/renderer/src/components/ProfileCard/index.tsx

@ -0,0 +1,35 @@ @@ -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>
)
}

229
src/renderer/src/components/RelaySettings/RelayGroup.tsx

@ -0,0 +1,229 @@ @@ -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>
)
}

146
src/renderer/src/components/RelaySettings/RelayUrl.tsx

@ -0,0 +1,146 @@ @@ -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()
}

143
src/renderer/src/components/RelaySettings/index.tsx

@ -0,0 +1,143 @@ @@ -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>
)
}

5
src/renderer/src/components/RelaySettings/types.ts

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

55
src/renderer/src/components/ReplyNote/index.tsx

@ -0,0 +1,55 @@ @@ -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>
)
}

90
src/renderer/src/components/ReplyNoteList/index.tsx

@ -0,0 +1,90 @@ @@ -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>
)}
</>
)
}

39
src/renderer/src/components/ScrollToTopButton/index.tsx

@ -0,0 +1,39 @@ @@ -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>
)
}

46
src/renderer/src/components/Titlebar/index.tsx

@ -0,0 +1,46 @@ @@ -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>
)
}

50
src/renderer/src/components/UserAvatar/index.tsx

@ -0,0 +1,50 @@ @@ -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>
)
}

39
src/renderer/src/components/Username/index.tsx

@ -0,0 +1,39 @@ @@ -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>
)
}

25
src/renderer/src/components/VideoPlayer/index.tsx

@ -0,0 +1,25 @@ @@ -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>
)
}

71
src/renderer/src/components/theme-provider.tsx

@ -0,0 +1,71 @@ @@ -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
}

48
src/renderer/src/components/ui/avatar.tsx

@ -0,0 +1,48 @@ @@ -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 }

53
src/renderer/src/components/ui/button.tsx

@ -1,35 +1,34 @@ @@ -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 @@ -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 }

79
src/renderer/src/components/ui/card.tsx

@ -0,0 +1,79 @@ @@ -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 }

120
src/renderer/src/components/ui/dialog.tsx

@ -0,0 +1,120 @@ @@ -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,
}

183
src/renderer/src/components/ui/dropdown-menu.tsx

@ -0,0 +1,183 @@ @@ -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
}

28
src/renderer/src/components/ui/hover-card.tsx

@ -0,0 +1,28 @@ @@ -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 }

24
src/renderer/src/components/ui/input.tsx

@ -0,0 +1,24 @@ @@ -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 }

29
src/renderer/src/components/ui/popover.tsx

@ -0,0 +1,29 @@ @@ -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 }

42
src/renderer/src/components/ui/radio-group.tsx

@ -0,0 +1,42 @@ @@ -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 }

43
src/renderer/src/components/ui/resizable.tsx

@ -0,0 +1,43 @@ @@ -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 }

40
src/renderer/src/components/ui/scroll-area.tsx

@ -0,0 +1,40 @@ @@ -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 }

158
src/renderer/src/components/ui/select.tsx

@ -0,0 +1,158 @@ @@ -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,
}

29
src/renderer/src/components/ui/separator.tsx

@ -0,0 +1,29 @@ @@ -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 }

15
src/renderer/src/components/ui/skeleton.tsx

@ -0,0 +1,15 @@ @@ -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 }

127
src/renderer/src/components/ui/toast.tsx

@ -0,0 +1,127 @@ @@ -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,
}

31
src/renderer/src/components/ui/toaster.tsx

@ -0,0 +1,31 @@ @@ -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>
)
}

9
src/renderer/src/embedded/EmbeddedHashtag.tsx

@ -0,0 +1,9 @@ @@ -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} />
}
}

9
src/renderer/src/embedded/EmbeddedNormalUrl.tsx

@ -0,0 +1,9 @@ @@ -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} />
}
}

10
src/renderer/src/embedded/EmbeddedNostrNpub.tsx

@ -0,0 +1,10 @@ @@ -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} />
}
}

10
src/renderer/src/embedded/EmbeddedNostrProfile.tsx

@ -0,0 +1,10 @@ @@ -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} />
}
}

9
src/renderer/src/embedded/EmbeddedNpub.tsx

@ -0,0 +1,9 @@ @@ -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} />
}
}

17
src/renderer/src/embedded/index.tsx

@ -0,0 +1,17 @@ @@ -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
}

4
src/renderer/src/embedded/types.tsx

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

2
src/renderer/src/hooks/index.tsx

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

194
src/renderer/src/hooks/use-toast.ts

@ -0,0 +1,194 @@ @@ -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 }

50
src/renderer/src/hooks/useFetchEvent.tsx

@ -0,0 +1,50 @@ @@ -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
}

29
src/renderer/src/hooks/useFetchEventStats.tsx

@ -0,0 +1,29 @@ @@ -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 }
}

19
src/renderer/src/hooks/useFetchNip05.tsx

@ -0,0 +1,19 @@ @@ -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 }
}

69
src/renderer/src/hooks/useFetchProfile.tsx

@ -0,0 +1,69 @@ @@ -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
}

24
src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx

@ -0,0 +1,24 @@ @@ -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>
)
}

14
src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx

@ -0,0 +1,14 @@ @@ -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>
)
}

56
src/renderer/src/layouts/PrimaryPageLayout/index.tsx

@ -0,0 +1,56 @@ @@ -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>
)
}

17
src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx

@ -0,0 +1,17 @@ @@ -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>
)}
</>
)
}

25
src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx

@ -0,0 +1,25 @@ @@ -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>
)}
</>
)
}

50
src/renderer/src/layouts/SecondaryPageLayout/index.tsx

@ -0,0 +1,50 @@ @@ -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>
)
}

23
src/renderer/src/lib/event.ts

@ -0,0 +1,23 @@ @@ -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]
}

41
src/renderer/src/lib/nip05.ts

@ -0,0 +1,41 @@ @@ -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 }
}

3
src/renderer/src/lib/platform.ts

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

91
src/renderer/src/lib/pubkey.ts

@ -0,0 +1,91 @@ @@ -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
}

28
src/renderer/src/lib/timestamp.ts

@ -0,0 +1,28 @@ @@ -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'
}

7
src/renderer/src/lib/url.ts

@ -0,0 +1,7 @@ @@ -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 } })

10
src/renderer/src/pages/primary/NoteListPage/index.tsx

@ -0,0 +1,10 @@ @@ -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>
)
}

11
src/renderer/src/pages/secondary/BlankPage/index.tsx

@ -0,0 +1,11 @@ @@ -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>
)
}

15
src/renderer/src/pages/secondary/HashtagPage/index.tsx

@ -0,0 +1,15 @@ @@ -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>
)
}

19
src/renderer/src/pages/secondary/NotePage/index.tsx

@ -0,0 +1,19 @@ @@ -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>
)
}

93
src/renderer/src/pages/secondary/ProfilePage/index.tsx

@ -0,0 +1,93 @@ @@ -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)}
/>
)
}

225
src/renderer/src/services/client.service.ts

@ -0,0 +1,225 @@ @@ -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

43
src/renderer/src/services/event-bus.service.ts

@ -0,0 +1,43 @@ @@ -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()

26
src/renderer/src/services/storage.service.ts

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

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

Loading…
Cancel
Save