104 changed files with 5663 additions and 161 deletions
@ -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 }} |
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 816 KiB |
@ -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' |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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()) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1 @@ |
|||||||
|
export type TSendToRenderer = (channel: string, ...args: any[]) => void |
||||||
@ -1,8 +1,20 @@ |
|||||||
|
import { TRelayGroup, TTheme, TThemeSetting } from '@common/types' |
||||||
import { ElectronAPI } from '@electron-toolkit/preload' |
import { ElectronAPI } from '@electron-toolkit/preload' |
||||||
|
|
||||||
declare global { |
declare global { |
||||||
interface Window { |
interface Window { |
||||||
electron: ElectronAPI |
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> |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,5 +1,29 @@ |
|||||||
function App(): JSX.Element { |
import 'yet-another-react-lightbox/styles.css' |
||||||
return <div>Hello</div> |
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> |
||||||
|
) |
||||||
|
} |
||||||
|
|||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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" /> |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
export * from './EmbeddedHashtag' |
||||||
|
export * from './EmbeddedMention' |
||||||
|
export * from './EmbeddedNormalUrl' |
||||||
|
export * from './EmbeddedNote' |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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)} |
||||||
|
/> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
@ -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> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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)} |
||||||
|
/> |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
@ -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}</> |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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() |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
export type TRelayGroup = { |
||||||
|
groupName: string |
||||||
|
relayUrls: string[] |
||||||
|
isActive: boolean |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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, |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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, |
||||||
|
} |
||||||
@ -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 } |
||||||
@ -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 } |
||||||
@ -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, |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
export type TEmbeddedRenderer = { |
||||||
|
regex: RegExp |
||||||
|
render: (match: string, index: number) => JSX.Element |
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export * from './useFetchEvent' |
||||||
|
export * from './useFetchProfile' |
||||||
@ -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 } |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 } |
||||||
|
} |
||||||
@ -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 } |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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] |
||||||
|
} |
||||||
@ -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 } |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export function isMacOS() { |
||||||
|
return window.electron.process.platform === 'darwin' |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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' |
||||||
|
} |
||||||
@ -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 } }) |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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)} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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() |
||||||
@ -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…
Reference in new issue