refactor: remove electron-related code

This commit is contained in:
codytseng 2024-12-21 23:20:30 +08:00
parent bed8df06e8
commit 2b1e6fe8f5
200 changed files with 2771 additions and 8432 deletions

View file

@ -0,0 +1,48 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import Username from '../Username'
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Jumble</DialogTitle>
<DialogDescription>
A beautiful nostr client focused on browsing relay feeds
</DialogDescription>
</DialogHeader>
<div>
Made by{' '}
<Username
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
className="inline-block text-primary"
showAt
/>
</div>
<div>
Source code:{' '}
<a
href="https://github.com/CodyTseng/jumble"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
GitHub
</a>
</div>
<div>
If you like this project, you can buy me a coffee <br />
<div className="font-semibold"> codytseng@getalby.com </div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,29 @@
import { Button } from '@/components/ui/button'
import { useNostr } from '@/providers/NostrProvider'
import { LogIn } from 'lucide-react'
export default function LoginButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { checkLogin } = useNostr()
let triggerComponent: React.ReactNode
if (variant === 'titlebar' || variant === 'small-screen-titlebar') {
triggerComponent = <LogIn />
} else {
triggerComponent = (
<>
<LogIn size={16} />
<div>Login</div>
</>
)
}
return (
<Button variant={variant} size={variant} onClick={() => checkLogin()}>
{triggerComponent}
</Button>
)
}

View file

@ -0,0 +1,81 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next'
export default function ProfileButton({
pubkey,
variant = 'titlebar'
}: {
pubkey: string
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { logout } = useNostr()
const { profile } = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
let triggerComponent: React.ReactNode
if (variant === 'titlebar') {
triggerComponent = (
<button>
<Avatar className="w-7 h-7 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</button>
)
} else if (variant === 'small-screen-titlebar') {
triggerComponent = (
<button>
<Avatar className="w-8 h-8 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</button>
)
} else {
triggerComponent = (
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2">
<div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-10 h-10">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
<div className="truncate font-semibold text-lg">{username}</div>
</div>
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,17 @@
import { useNostr } from '@/providers/NostrProvider'
import LoginButton from './LoginButton'
import ProfileButton from './ProfileButton'
export default function AccountButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { pubkey } = useNostr()
if (pubkey) {
return <ProfileButton variant={variant} pubkey={pubkey} />
} else {
return <LoginButton variant={variant} />
}
}

View file

@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button'
import { useSecondaryPage } from '@/PageManager'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function BackButton({
hide = false,
variant = 'titlebar'
}: {
hide?: boolean
variant?: 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { pop } = useSecondaryPage()
return (
<>
{!hide && (
<Button variant={variant} size={variant} title={t('back')} onClick={() => pop()}>
<ChevronLeft />
</Button>
)}
</>
)
}

View file

@ -0,0 +1,145 @@
import { isNsfwEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { memo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
EmbeddedNote,
embeddedWebsocketUrlRenderer
} from '../Embedded'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
import WebPreview from '../WebPreview'
const Content = memo(
({
event,
className,
size = 'normal'
}: {
event: Event
className?: string
size?: 'normal' | 'small'
}) => {
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content)
const isNsfw = isNsfwEvent(event)
const nodes = embedded(content, [
embeddedNormalUrlRenderer,
embeddedWebsocketUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
])
// Add images
if (images.length) {
nodes.push(
<ImageGallery
className={`w-fit ${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={`image-gallery-${event.id}`}
images={images}
isNsfw={isNsfw}
size={size}
/>
)
}
// Add videos
if (videos.length) {
videos.forEach((src, index) => {
nodes.push(
<VideoPlayer
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`video-${index}-${src}`}
src={src}
isNsfw={isNsfw}
size={size}
/>
)
})
}
// Add website preview
if (lastNonMediaUrl) {
nodes.push(
<WebPreview
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`web-preview-${event.id}`}
url={lastNonMediaUrl}
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}
className={size === 'small' ? 'mt-1' : 'mt-2'}
/>
)
})
}
return <div className={cn('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 lastNonMediaUrl: string | undefined
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)
} else {
lastNonMediaUrl = url
}
})
const embeddedNotes: string[] = []
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
c = c.replace(note, '').trim()
embeddedNotes.push(note)
})
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
}
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', '.mov']
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}

View file

@ -0,0 +1,22 @@
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { TEmbeddedRenderer } from './types'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-highlight hover:underline"
to={toNoteList({ hashtag })}
onClick={(e) => e.stopPropagation()}
>
#{hashtag}
</SecondaryPageLink>
)
}
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([\p{L}\p{N}\p{M}]+)/gu,
render: (hashtag: string, index: number) => {
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
}
}

View file

@ -0,0 +1,29 @@
import Username from '../Username'
import { TEmbeddedRenderer } from './types'
export function EmbeddedMention({ userId }: { userId: string }) {
return <Username userId={userId} showAt className="text-highlight font-normal inline-block" />
}
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}-${npub1}`} userId={npub1} />
}
}
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}-${nprofile}`} userId={nprofile} />
}
}
export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => {
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
}
}

View file

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

View file

@ -0,0 +1,29 @@
import { useFetchEvent } from '@/hooks'
import { toNoStrudelArticle, toNoStrudelNote, toNoStrudelStream } from '@/lib/link'
import { cn } from '@/lib/utils'
import { kinds } from 'nostr-tools'
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event } = useFetchEvent(noteId)
return event && event.kind === kinds.ShortTextNote ? (
<ShortTextNoteCard className={cn('w-full', className)} event={event} embedded />
) : (
<a
href={
event?.kind === kinds.LongFormArticle
? toNoStrudelArticle(noteId)
: event?.kind === kinds.LiveEvent
? toNoStrudelStream(noteId)
: toNoStrudelNote(noteId)
}
target="_blank"
className="text-highlight hover:underline"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
>
{noteId}
</a>
)
}

View file

@ -0,0 +1,26 @@
import { useSecondaryPage } from '@/PageManager'
import { toNoteList } from '@/lib/link'
import { TEmbeddedRenderer } from './types'
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
const { push } = useSecondaryPage()
return (
<span
className="cursor-pointer px-1 text-highlight hover:bg-highlight/20"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ relay: url }))
}}
>
[ {url} ]
<span className="w-2 h-1 bg-highlight" />
</span>
)
}
export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
regex: /(wss?:\/\/[^\s]+)/g,
render: (url: string, index: number) => {
return <EmbeddedWebsocketUrl key={`websocket-url-${index}-${url}`} url={url} />
}
}

View file

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

View file

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

View file

@ -0,0 +1,73 @@
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks'
import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
const [updating, setUpdating] = useState(false)
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isFollowing) return
setUpdating(true)
try {
await follow(pubkey)
} catch (error) {
toast({
title: t('Follow failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
const handleUnfollow = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isFollowing || !followListEvent) return
setUpdating(true)
try {
await unfollow(pubkey)
} catch (error) {
toast({
title: t('Unfollow failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
return isFollowing ? (
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={handleUnfollow}
disabled={updating}
>
{updating ? <Loader className="animate-spin" /> : t('Unfollow')}
</Button>
) : (
<Button className="w-20 min-w-20 rounded-full" onClick={handleFollow} disabled={updating}>
{updating ? <Loader className="animate-spin" /> : t('Follow')}
</Button>
)
}

View file

@ -0,0 +1,36 @@
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
export function FormattedTimestamp({
timestamp,
short = false
}: {
timestamp: number
short?: boolean
}) {
const { t } = useTranslation()
const time = dayjs(timestamp * 1000)
const now = dayjs()
const diffMonth = now.diff(time, 'month')
if (diffMonth >= 2) {
return t('date', { timestamp: time.valueOf() })
}
const diffDay = now.diff(time, 'day')
if (diffDay >= 1) {
return short ? t('n d', { n: diffDay }) : t('n days ago', { n: diffDay })
}
const diffHour = now.diff(time, 'hour')
if (diffHour >= 1) {
return short ? t('n h', { n: diffHour }) : t('n hours ago', { n: diffHour })
}
const diffMinute = now.diff(time, 'minute')
if (diffMinute >= 1) {
return short ? t('n m', { n: diffMinute }) : t('n minutes ago', { n: diffMinute })
}
return t('just now')
}

View file

@ -0,0 +1,62 @@
import { Image } from '@nextui-org/image'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import NsfwOverlay from '../NsfwOverlay'
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-full">
<div className="flex space-x-2">
{images.map((src, index) => (
<Image
key={index}
className={cn(
'rounded-lg cursor-pointer z-0 object-cover',
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
)}
src={src}
onClick={(e) => handlePhotoClick(e, index)}
removeWrapper
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Lightbox
index={index}
slides={images.map((src) => ({ src }))}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View file

@ -0,0 +1,47 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
const { t } = useTranslation()
const { bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (bunkerInput === '') return
setPending(true)
bunkerLogin(bunkerInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
</>
)
}

View file

@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsec(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (nsec === '') return
nsecLogin(nsec)
.then(() => onLoginSuccess())
.catch((err) => {
setErrMsg(err.message)
})
}
return (
<>
<div className="text-orange-400">
{t(
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
)}
</div>
<div className="space-y-1">
<Input
type="password"
placeholder="nsec1.."
value={nsec}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin}>{t('Login')}</Button>
</>
)
}

View file

@ -0,0 +1,72 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowLeft } from 'lucide-react'
import { Dispatch, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BunkerLogin from './BunkerLogin'
import PrivateKeyLogin from './NsecLogin'
export default function LoginDialog({
open,
setOpen
}: {
open: boolean
setOpen: Dispatch<boolean>
}) {
const { t } = useTranslation()
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
const { nip07Login } = useNostr()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-96">
<DialogHeader>
<DialogTitle className="hidden" />
<DialogDescription className="hidden" />
</DialogHeader>
{loginMethod === 'nsec' ? (
<>
<div
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
onClick={() => setLoginMethod(null)}
>
<ArrowLeft className="h-4 w-4" />
</div>
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
</>
) : loginMethod === 'bunker' ? (
<>
<div
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
onClick={() => setLoginMethod(null)}
>
<ArrowLeft className="h-4 w-4" />
</div>
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
</>
) : (
<>
{!!window.nostr && (
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
</>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,26 @@
import { useFetchNip05 } from '@/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 (
nip05Name &&
nip05Domain && (
<div className="flex items-center space-x-1">
{nip05Name !== '_' ? (
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
) : null}
<a
href={`https://${nip05Domain}`}
target="_blank"
className={`flex items-center space-x-1 hover:underline ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`}
rel="noreferrer"
>
{nip05IsVerified ? <BadgeCheck size={16} /> : <BadgeAlert size={16} />}
<div className="text-sm">{nip05Domain}</div>
</a>
</div>
)
)
}

View file

@ -0,0 +1,61 @@
import { useSecondaryPage } from '@/PageManager'
import { toNote } from '@/lib/link'
import { Event } from 'nostr-tools'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function Note({
event,
parentEvent,
size = 'normal',
className,
hideStats = false,
fetchNoteStats = false
}: {
event: Event
parentEvent?: Event
size?: 'normal' | 'small'
className?: string
hideStats?: boolean
fetchNoteStats?: boolean
}) {
const { push } = useSecondaryPage()
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 ${size === 'small' ? 'flex space-x-2 items-end overflow-hidden' : ''}`}
>
<Username
userId={event.pubkey}
className={`font-semibold flex ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
{parentEvent && (
<ParentNotePreview
event={parentEvent}
className="mt-2"
onClick={(e) => {
e.stopPropagation()
push(toNote(parentEvent))
}}
/>
)}
<Content className="mt-2" event={event} />
{!hideStats && (
<NoteStats className="mt-3 sm:mt-4" event={event} fetchIfNotExisting={fetchNoteStats} />
)}
</div>
)
}

View file

@ -0,0 +1,23 @@
import client from '@/services/client.service'
import { Event, kinds, verifyEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ShortTextNoteCard from './ShortTextNoteCard'
export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) {
const targetEvent = useMemo(() => {
const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null
try {
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
return null
}
client.addEventToCache(targetEvent)
} catch {
return null
}
return targetEvent
}, [event])
if (!targetEvent) return null
return <ShortTextNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
}

View file

@ -0,0 +1,68 @@
import { useFetchEvent } from '@/hooks'
import { getParentEventId, getRootEventId } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Repeat2 } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Note from '../Note'
import Username from '../Username'
export default function ShortTextNoteCard({
event,
className,
reposter,
embedded
}: {
event: Event
className?: string
reposter?: string
embedded?: boolean
}) {
const { push } = useSecondaryPage()
const { event: rootEvent } = useFetchEvent(getRootEventId(event))
const { event: parentEvent } = useFetchEvent(getParentEventId(event))
return (
<div
className={className}
onClick={(e) => {
e.stopPropagation()
push(toNote(event))
}}
>
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
<div
className={`hover:bg-muted/50 text-left cursor-pointer ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3 sm:py-4 sm:border sm:rounded-lg max-sm:border-b'}`}
>
<RepostDescription reposter={reposter} className="sm:hidden" />
<Note
size={embedded ? 'small' : 'normal'}
event={event}
parentEvent={parentEvent ?? rootEvent}
hideStats={embedded}
/>
</div>
</div>
)
}
function RepostDescription({
reposter,
className
}: {
reposter?: string | null
className?: string
}) {
const { t } = useTranslation()
if (!reposter) return null
return (
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
<div>{t('reposted')}</div>
</div>
)
}

View file

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

View file

@ -0,0 +1,226 @@
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useFetchRelayInfos } from '@/hooks'
import { isReplyNoteEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard from '../NoteCard'
const NORMAL_RELAY_LIMIT = 100
const ALGO_RELAY_LIMIT = 500
export default function NoteList({
relayUrls,
filter = {},
className
}: {
relayUrls: string[]
filter?: Filter
className?: string
}) {
const { t } = useTranslation()
const { signEvent, checkLogin } = useNostr()
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos(relayUrls)
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [initialized, setInitialized] = useState(false)
const [displayReplies, setDisplayReplies] = useState(false)
const bottomRef = useRef<HTMLDivElement | null>(null)
const noteFilter = useMemo(() => {
return {
kinds: [kinds.ShortTextNote, kinds.Repost],
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
...filter
}
}, [JSON.stringify(filter), areAlgoRelays])
useEffect(() => {
if (isFetchingRelayInfo) return
async function init() {
setInitialized(false)
setEvents([])
setNewEvents([])
setHasMore(true)
const { closer, timelineKey } = await client.subscribeTimeline(
relayUrls,
noteFilter,
{
onEvents: (events, eosed) => {
if (events.length > 0) {
setEvents(events)
} else {
setHasMore(false)
}
if (areAlgoRelays) {
setHasMore(false)
}
if (eosed) {
setInitialized(true)
}
},
onNew: (event) => {
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
},
{
signer: async (evt) => {
const signedEvt = await checkLogin(() => signEvent(evt))
return signedEvt ?? null
},
needSort: !areAlgoRelays
}
)
setTimelineKey(timelineKey)
return closer
}
const promise = init()
return () => {
promise.then((closer) => closer())
}
}, [
JSON.stringify(relayUrls),
JSON.stringify(noteFilter),
isFetchingRelayInfo,
areAlgoRelays,
refreshCount
])
useEffect(() => {
if (!initialized) return
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [initialized, hasMore, events, timelineKey])
const loadMore = async () => {
if (!timelineKey) return
const newEvents = await client.loadMoreTimeline(
timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
noteFilter.limit
)
if (newEvents.length === 0) {
setHasMore(false)
return
}
setEvents((oldEvents) => [...oldEvents, ...newEvents])
}
const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents])
setNewEvents([])
}
return (
<div className={cn('space-y-2 sm:space-y-4', className)}>
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
{newEvents.length > 0 && (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={showNewEvents}>
{t('show new notes')}
</Button>
</div>
)}
<div className="flex flex-col sm:gap-4">
{events
.filter((event) => displayReplies || !isReplyNoteEvent(event))
.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} />
))}
</div>
<div className="text-center text-sm text-muted-foreground">
{hasMore ? (
<div ref={bottomRef}>{t('loading...')}</div>
) : events.length ? (
t('no more notes')
) : (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
{t('reload notes')}
</Button>
</div>
)}
</div>
</div>
)
}
function DisplayRepliesSwitch({
displayReplies,
setDisplayReplies
}: {
displayReplies: boolean
setDisplayReplies: (value: boolean) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<div>
<div className="flex">
<div
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
onClick={() => setDisplayReplies(false)}
>
{t('Notes')}
</div>
<div
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
onClick={() => setDisplayReplies(true)}
>
{t('Notes & Replies')}
</div>
</div>
<div
className={`w-1/2 px-4 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
>
<div className="w-full h-1 bg-primary rounded-full" />
</div>
</div>
)
}
return (
<div className="flex justify-end gap-2">
<div>{t('Display replies')}</div>
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} />
</div>
)
}

View file

@ -0,0 +1,89 @@
import { createReactionDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Heart, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { formatCount } from './utils'
import { useTranslation } from 'react-i18next'
export default function LikeButton({
event,
variant = 'normal',
canFetch = false
}: {
event: Event
variant?: 'normal' | 'reply'
canFetch?: boolean
}) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
const [liking, setLiking] = useState(false)
const { likeCount, hasLiked } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const canLike = !hasLiked && !liking
useEffect(() => {
if (!canFetch) return
if (likeCount === undefined) {
fetchNoteLikeCount(event)
}
if (hasLiked === undefined) {
fetchNoteLikedStatus(event)
}
}, [canFetch, event])
const like = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canLike) return
setLiking(true)
const timer = setTimeout(() => setLiking(false), 5000)
try {
const [liked] = await Promise.all([
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
])
if (liked) return
const targetRelayList = await client.fetchRelayList(event.pubkey)
const reaction = createReactionDraftEvent(event)
await publish(reaction, targetRelayList.read.slice(0, 3))
markNoteAsLiked(event.id)
} catch (error) {
console.error('like failed', error)
} finally {
setLiking(false)
clearTimeout(timer)
}
})
}
return (
<button
className={cn(
'flex items-center enabled:hover:text-red-400',
variant === 'normal' ? 'gap-1' : 'flex-col',
hasLiked ? 'text-red-400' : 'text-muted-foreground'
)}
onClick={like}
disabled={!canLike}
title={t('Like')}
>
{liking ? (
<Loader className="animate-spin" size={16} />
) : (
<Heart size={16} className={hasLiked ? 'fill-red-400' : ''} />
)}
<div className="text-sm">{formatCount(likeCount)}</div>
</button>
)
}

View file

@ -0,0 +1,34 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { ScrollArea, ScrollBar } from '@/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>
<DialogDescription className="hidden" />
</DialogHeader>
<ScrollArea className="h-full">
<pre className="text-sm text-muted-foreground">{JSON.stringify(event, null, 2)}</pre>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,47 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { getSharableEventId } from '@/lib/event'
import { Code, Copy, Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import RawEventDialog from './RawEventDialog'
export default function NoteOptions({ event }: { event: Event }) {
const { t } = useTranslation()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
return (
<div className="h-4" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger>
<Ellipsis
size={16}
className="text-muted-foreground hover:text-foreground cursor-pointer"
/>
</DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={8}>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + getSharableEventId(event))}
>
<Copy />
{t('copy embedded code')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code />
{t('raw event')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RawEventDialog
event={event}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
</div>
)
}

View file

@ -0,0 +1,34 @@
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import PostDialog from '../PostDialog'
import { formatCount } from './utils'
import { useTranslation } from 'react-i18next'
export default function ReplyButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { noteStatsMap } = useNoteStats()
const { pubkey } = useNostr()
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
const [open, setOpen] = useState(false)
return (
<>
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
disabled={!pubkey}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
title={t('Reply')}
>
<MessageCircle size={16} />
<div className="text-sm">{formatCount(replyCount)}</div>
</button>
<PostDialog parentEvent={event} open={open} setOpen={setOpen} />
</>
)
}

View file

@ -0,0 +1,121 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { createRepostDraftEvent } from '@/lib/draft-event'
import { getSharableEventId } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import PostDialog from '../PostDialog'
import { formatCount } from './utils'
import { useTranslation } from 'react-i18next'
export default function RepostButton({
event,
canFetch = false
}: {
event: Event
canFetch?: boolean
}) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
useNoteStats()
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const canRepost = !hasReposted && !reposting
useEffect(() => {
if (!canFetch) return
if (repostCount === undefined) {
fetchNoteRepostCount(event)
}
if (hasReposted === undefined) {
fetchNoteRepostedStatus(event)
}
}, [canFetch, event])
const repost = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canRepost) return
setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000)
try {
const [reposted] = await Promise.all([
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
])
if (reposted) return
const targetRelayList = await client.fetchRelayList(event.pubkey)
const repost = createRepostDraftEvent(event)
await publish(repost, targetRelayList.read.slice(0, 5))
markNoteAsReposted(event.id)
} catch (error) {
console.error('repost failed', error)
} finally {
setReposting(false)
clearTimeout(timer)
}
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex gap-1 items-center enabled:hover:text-lime-500',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
onClick={(e) => e.stopPropagation()}
disabled={!canRepost}
title={t('Repost')}
>
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
<div className="text-sm">{formatCount(repostCount)}</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<DropdownMenuItem onClick={repost}>
<Repeat /> {t('Repost')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setIsPostDialogOpen(true)
}}
>
<PencilLine /> {t('Quote')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PostDialog
open={isPostDialogOpen}
setOpen={setIsPostDialogOpen}
defaultContent={'\nnostr:' + getSharableEventId(event)}
/>
</>
)
}

View file

@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import LikeButton from './LikeButton'
import NoteOptions from './NoteOptions'
import ReplyButton from './ReplyButton'
import RepostButton from './RepostButton'
export default function NoteStats({
event,
className,
fetchIfNotExisting = false
}: {
event: Event
className?: string
fetchIfNotExisting?: boolean
}) {
return (
<div className={cn('flex justify-between', className)}>
<div className="flex gap-4 h-4 items-center">
<ReplyButton event={event} />
<RepostButton event={event} canFetch={fetchIfNotExisting} />
<LikeButton event={event} canFetch={fetchIfNotExisting} />
</div>
<NoteOptions event={event} />
</div>
)
}

View file

@ -0,0 +1,4 @@
export function formatCount(count?: number) {
if (count === undefined || count <= 0) return ''
return count >= 100 ? '99+' : count
}

View file

@ -0,0 +1,39 @@
import { Button } from '@/components/ui/button'
import { toNotifications } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { Bell } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NotificationButton({
variant = 'titlebar'
}: {
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
if (variant === 'sidebar') {
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
{t('Notifications')}
</Button>
)
}
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
</Button>
)
}

View file

@ -0,0 +1,217 @@
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Heart, MessageCircle, Repeat } from 'lucide-react'
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
const LIMIT = 50
export default function NotificationList() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [initialized, setInitialized] = useState(false)
const [notifications, setNotifications] = useState<Event[]>([])
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!pubkey) {
setUntil(undefined)
return
}
const init = async () => {
const relayList = await client.fetchRelayList(pubkey)
const { closer, timelineKey } = await client.subscribeTimeline(
relayList.read.length >= 4
? relayList.read
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
{
'#p': [pubkey],
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
limit: LIMIT
},
{
onEvents: (events, eosed) => {
setNotifications(events.filter((event) => event.pubkey !== pubkey))
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
if (eosed) {
setInitialized(true)
}
},
onNew: (event) => {
if (event.pubkey === pubkey) return
setNotifications((oldEvents) => [event, ...oldEvents])
}
}
)
setTimelineKey(timelineKey)
return closer
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [pubkey])
useEffect(() => {
if (!initialized) return
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [until, initialized, timelineKey])
const loadMore = async () => {
if (!pubkey || !timelineKey || !until) return
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
if (notifications.length === 0) {
setUntil(undefined)
return
}
if (notifications.length > 0) {
setNotifications((oldNotifications) => [...oldNotifications, ...notifications])
}
setUntil(notifications[notifications.length - 1].created_at - 1)
}
return (
<div>
{notifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
</div>
</div>
)
}
function NotificationItem({ notification }: { notification: Event }) {
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} />
}
if (notification.kind === kinds.ShortTextNote) {
return <ReplyNotification notification={notification} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} />
}
return null
}
function ReactionNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const bech32Id = useMemo(() => {
const eTag = notification.tags.findLast(tagNameEquals('e'))
const pTag = notification.tags.find(tagNameEquals('p'))
const eventId = eTag?.[1]
const author = pTag?.[1]
return eventId
? nip19.neventEncode(author ? { id: eventId, author } : { id: eventId })
: undefined
}, [notification])
const { event } = useFetchEvent(bech32Id)
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => push(toNote(bech32Id))}
>
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" />
<ContentPreview event={event} />
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ReplyNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(notification))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function RepostNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
const isValid = validateEvent(event)
if (!isValid) return null
client.addEventToCache(event)
return event
} catch {
return null
}
}, [notification.content])
if (!event) return null
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview event={event} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ContentPreview({ event }: { event?: Event }) {
if (!event || event.kind !== kinds.ShortTextNote) return null
return <div className="truncate flex-1 w-0">{event.content}</div>
}

View file

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

View file

@ -0,0 +1,29 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
export default function ParentNotePreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
return (
<div
className={cn(
'flex space-x-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
className
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
<UserAvatar userId={event.pubkey} size="tiny" />
<div className="truncate">{event.content}</div>
</div>
)
}

View file

@ -0,0 +1,32 @@
import PostDialog from '@/components/PostDialog'
import { Button } from '@/components/ui/button'
import { PencilLine } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PostButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<Button
variant={variant}
size={variant}
title={t('New post')}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<PencilLine />
{variant === 'sidebar' && <div>{t('Post')}</div>}
</Button>
<PostDialog open={open} setOpen={setOpen} />
</>
)
}

View file

@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { extractMentions } from '@/lib/event'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import { useTranslation } from 'react-i18next'
export default function Mentions({
content,
parentEvent
}: {
content: string
parentEvent?: Event
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [pubkeys, setPubkeys] = useState<string[]>([])
useEffect(() => {
extractMentions(content, parentEvent).then(({ pubkeys }) =>
setPubkeys(pubkeys.filter((p) => p !== pubkey))
)
}, [content, parentEvent, pubkey])
return (
<Popover>
<PopoverTrigger asChild>
<Button
className="px-3"
variant="ghost"
disabled={pubkeys.length === 0}
onClick={(e) => e.stopPropagation()}
>
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48">
<div className="space-y-2">
<div className="text-sm font-semibold">{t('Mentions')}:</div>
{pubkeys.map((pubkey, index) => (
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
<UserAvatar userId={pubkey} size="small" />
<Username
userId={pubkey}
className="font-semibold text-sm truncate"
skeletonClassName="h-3"
/>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,21 @@
import { Card } from '@/components/ui/card'
import dayjs from 'dayjs'
import Content from '../Content'
export default function Preview({ content }: { content: string }) {
return (
<Card className="p-3">
<Content
event={{
content,
kind: 1,
tags: [],
created_at: dayjs().unix(),
id: '',
pubkey: '',
sig: ''
}}
/>
</Card>
)
}

View file

@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { useNostr } from '@/providers/NostrProvider'
import { ImageUp, LoaderCircle } from 'lucide-react'
import { useRef, useState } from 'react'
import { z } from 'zod'
export default function Uploader({
setContent
}: {
setContent: React.Dispatch<React.SetStateAction<string>>
}) {
const [uploading, setUploading] = useState(false)
const { signHttpAuth } = useNostr()
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
setUploading(true)
const url = 'https://nostr.build/api/v2/nip96/upload'
const auth = await signHttpAuth(url, 'POST')
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
Authorization: auth
}
})
if (!response.ok) {
throw new Error(response.status.toString())
}
const data = await response.json()
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
if (imageUrl) {
setContent((prevContent) => `${prevContent}\n${imageUrl}`)
} else {
throw new Error('No image url found')
}
} catch (error) {
console.error('Error uploading file', error)
toast({
variant: 'destructive',
title: 'Failed to upload file',
description: (error as Error).message
})
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} finally {
setUploading(false)
}
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
return (
<>
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept="image/*,video/*,audio/*"
/>
</>
)
}

View file

@ -0,0 +1,143 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { LoaderCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { Dispatch, useState } from 'react'
import UserAvatar from '../UserAvatar'
import Mentions from './Metions'
import Preview from './Preview'
import Uploader from './Uploader'
import { useTranslation } from 'react-i18next'
export default function PostDialog({
defaultContent = '',
parentEvent,
open,
setOpen
}: {
defaultContent?: string
parentEvent?: Event
open: boolean
setOpen: Dispatch<boolean>
}) {
const { t } = useTranslation()
const { toast } = useToast()
const { publish, checkLogin } = useNostr()
const [content, setContent] = useState(defaultContent)
const [posting, setPosting] = useState(false)
const canPost = !!content && !posting
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
}
const post = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canPost) {
setOpen(false)
return
}
setPosting(true)
try {
const additionalRelayUrls: string[] = []
if (parentEvent) {
const relayList = await client.fetchRelayList(parentEvent.pubkey)
additionalRelayUrls.push(...relayList.read.slice(0, 5))
}
const draftEvent = await createShortTextNoteDraftEvent(content, parentEvent)
await publish(draftEvent, additionalRelayUrls)
setContent('')
setOpen(false)
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((e) =>
toast({
variant: 'destructive',
title: t('Failed to post'),
description: e.message
})
)
} else if (error instanceof Error) {
toast({
variant: 'destructive',
title: t('Failed to post'),
description: error.message
})
}
console.error(error)
return
} finally {
setPosting(false)
}
toast({
title: t('Post successful'),
description: t('Your post has been published')
})
})
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0" withoutClose>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<DialogHeader>
<DialogTitle>
{parentEvent ? (
<div className="flex gap-2 items-center max-w-full">
<div className="shrink-0">{t('Reply to')}</div>
<UserAvatar userId={parentEvent.pubkey} size="tiny" />
<div className="truncate">{parentEvent.content}</div>
</div>
) : (
t('New post')
)}
</DialogTitle>
<DialogDescription className="hidden" />
</DialogHeader>
<Textarea
className="h-32"
onChange={handleTextareaChange}
value={content}
placeholder={t('Write something...')}
/>
{content && <Preview content={content} />}
<div className="flex items-center justify-between">
<Uploader setContent={setContent} />
<div className="flex gap-2">
<Mentions content={content} parentEvent={parentEvent} />
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
setOpen(false)
}}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
</Button>
</div>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,25 @@
import { useMemo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNpubRenderer,
embeddedWebsocketUrlRenderer
} from '../Embedded'
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const nodes = useMemo(() => {
return about
? embedded(about, [
embeddedWebsocketUrlRenderer,
embeddedNormalUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNpubRenderer
])
: null
}, [about])
return <div className={className}>{nodes}</div>
}

View file

@ -0,0 +1,35 @@
import { Image } from '@nextui-org/image'
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
export default function ProfileBanner({
pubkey,
banner,
className
}: {
pubkey: string
banner?: string
className?: string
}) {
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
useEffect(() => {
if (banner) {
setBannerUrl(banner)
} else {
setBannerUrl(defaultBanner)
}
}, [defaultBanner, banner])
return (
<Image
src={bannerUrl}
alt={`${pubkey} banner`}
className={cn('z-0', className)}
onError={() => setBannerUrl(defaultBanner)}
removeWrapper
/>
)
}

View file

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

View file

@ -0,0 +1,19 @@
import { Button } from '@/components/ui/button'
import { usePrimaryPage } from '@/PageManager'
import { RefreshCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { t } = useTranslation()
const { refresh } = usePrimaryPage()
return (
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
<RefreshCcw />
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
</Button>
)
}

View file

@ -0,0 +1,195 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
import { useState } from 'react'
import RelayUrls from './RelayUrl'
import { useRelaySettingsComponent } from './provider'
import { TRelayGroup } from './types'
import { useTranslation } from 'react-i18next'
export default function RelayGroup({ group }: { group: TRelayGroup }) {
const { t } = useTranslation()
const { expandedRelayGroup } = useRelaySettingsComponent()
const { temporaryRelayUrls } = useRelaySettings()
const { groupName, relayUrls } = group
const isActive = temporaryRelayUrls.length === 0 && group.isActive
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
groupName={groupName}
isActive={isActive}
canActive={relayUrls.length > 0}
/>
<RelayGroupName groupName={groupName} />
</div>
<div className="flex gap-1">
<RelayUrlsExpandToggle groupName={groupName}>
{t('n relays', { n: relayUrls.length })}
</RelayUrlsExpandToggle>
<RelayGroupOptions group={group} />
</div>
</div>
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
</div>
)
}
function RelayGroupActiveToggle({
groupName,
isActive,
canActive
}: {
groupName: string
isActive: boolean
canActive: boolean
}) {
const { switchRelayGroup } = useRelaySettings()
return isActive ? (
<CircleCheck size={18} className="text-highlight shrink-0" />
) : (
<Circle
size={18}
className={`text-muted-foreground shrink-0 ${canActive ? 'cursor-pointer hover:text-foreground ' : ''}`}
onClick={() => {
if (canActive) {
switchRelayGroup(groupName)
}
}}
/>
)
}
function RelayGroupName({ groupName }: { groupName: string }) {
const { t } = useTranslation()
const [newGroupName, setNewGroupName] = useState(groupName)
const [newNameError, setNewNameError] = useState<string | null>(null)
const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings()
const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent()
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
const saveNewGroupName = () => {
if (groupName === newGroupName) {
return setRenamingGroup(null)
}
if (relayGroups.find((group) => group.groupName === newGroupName)) {
return setNewNameError(t('relay collection name already exists'))
}
const errMsg = renameRelayGroup(groupName, newGroupName)
if (errMsg) {
setNewNameError(errMsg)
return
}
setRenamingGroup(null)
}
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 renamingGroup === groupName ? (
<div className="flex gap-1 items-center">
<Input
value={newGroupName}
onChange={handleRenameInputChange}
onBlur={saveNewGroupName}
onKeyDown={handleRenameInputKeyDown}
className={`font-semibold w-28 ${newNameError ? 'border-destructive' : ''}`}
/>
<Button variant="ghost" size="icon" 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) {
switchRelayGroup(groupName)
}
}}
>
{groupName}
</div>
)
}
function RelayUrlsExpandToggle({
groupName,
children
}: {
groupName: string
children: React.ReactNode
}) {
const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent()
return (
<div
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
onClick={() => setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))}
>
<div className="select-none">{children}</div>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`}
/>
</div>
)
}
function RelayGroupOptions({ group }: { group: TRelayGroup }) {
const { t } = useTranslation()
const { deleteRelayGroup } = useRelaySettings()
const { setRenamingGroup } = useRelaySettingsComponent()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setRenamingGroup(group.groupName)}>
{t('Rename')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}`
)
}}
>
{t('Copy share link')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => deleteRelayGroup(group.groupName)}
>
{t('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,151 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useFetchRelayInfos } from '@/hooks'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import client from '@/services/client.service'
import { CircleX, SearchCheck } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function RelayUrls({ groupName }: { groupName: string }) {
const { t } = useTranslation()
const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
const isActive = useMemo(
() => relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false,
[relayGroups, groupName]
)
const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
const [relays, setRelays] = useState<
{
url: string
isConnected: boolean
}[]
>(
relayGroups
.find((group) => group.groupName === groupName)
?.relayUrls.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))
updateRelayGroupRelayUrls(
groupName,
relays.map(({ url }) => url).filter((u) => u !== url)
)
}
const saveNewRelayUrl = () => {
if (newRelayUrl === '') return
const normalizedUrl = normalizeUrl(newRelayUrl)
if (relays.some(({ url }) => url === normalizedUrl)) {
return setNewRelayUrlError(t('Relay already exists'))
}
if (!isWebsocketUrl(normalizedUrl)) {
return setNewRelayUrlError(t('invalid relay URL'))
}
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
updateRelayGroupRelayUrls(groupName, 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={newRelayUrlError ? 'border-destructive' : ''}
placeholder={t('Add a new relay')}
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl}
/>
<Button onClick={saveNewRelayUrl}>{t('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
}) {
const { t } = useTranslation()
const {
relayInfos: [relayInfo]
} = useFetchRelayInfos([url])
return (
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
{!isActive ? (
<div className="text-muted-foreground text-xs"></div>
) : isConnected ? (
<div className="text-green-500 text-xs"></div>
) : (
<div className="text-red-500 text-xs"></div>
)}
<div className="text-muted-foreground text-sm">{url}</div>
{relayInfo?.supported_nips?.includes(50) && (
<div title={t('supports search')} className="text-highlight">
<SearchCheck size={14} />
</div>
)}
</div>
<div>
<CircleX
size={16}
onClick={onRemove}
className="text-muted-foreground hover:text-destructive cursor-pointer"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,81 @@
import { Button } from '@/components/ui/button'
import { useFetchRelayInfos } from '@/hooks'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import client from '@/services/client.service'
import { Save, SearchCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function TemporaryRelayGroup() {
const { t } = useTranslation()
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
const [relays, setRelays] = useState<
{
url: string
isConnected: boolean
}[]
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
const { relayInfos } = useFetchRelayInfos(relays.map((relay) => relay.url))
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)
}, [])
useEffect(() => {
setRelays(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
}, [temporaryRelayUrls])
if (!relays.length) {
return null
}
const handleSave = () => {
const existingTemporaryIndexes = relayGroups
.filter((group) => /^Temporary \d+$/.test(group.groupName))
.map((group) => group.groupName.split(' ')[1])
.map(Number)
.filter((index) => !isNaN(index))
const nextIndex = Math.max(...existingTemporaryIndexes, 0) + 1
const groupName = `Temporary ${nextIndex}`
addRelayGroup(groupName, temporaryRelayUrls)
switchRelayGroup(groupName)
}
return (
<div className={`w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5`}>
<div className="flex justify-between items-center">
<div className="h-8 font-semibold">Temporary</div>
<Button title="save" size="icon" variant="ghost" onClick={handleSave}>
<Save />
</Button>
</div>
{relays.map((relay, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex gap-2 items-center">
{relay.isConnected ? (
<div className="text-green-500 text-xs"></div>
) : (
<div className="text-red-500 text-xs"></div>
)}
<div className="text-muted-foreground text-sm">{relay.url}</div>
{relayInfos[index]?.supported_nips?.includes(50) && (
<div title={t('supports search')} className="text-highlight">
<SearchCheck size={14} />
</div>
)}
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,81 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { useEffect, useRef, useState } from 'react'
import { RelaySettingsComponentProvider } from './provider'
import RelayGroup from './RelayGroup'
import TemporaryRelayGroup from './TemporaryRelayGroup'
import { useTranslation } from 'react-i18next'
export default function RelaySettings({ hideTitle = false }: { hideTitle?: boolean }) {
const { t } = useTranslation()
const { relayGroups, addRelayGroup } = useRelaySettings()
const [newGroupName, setNewGroupName] = useState('')
const [newNameError, setNewNameError] = useState<string | null>(null)
const dummyRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (dummyRef.current) {
dummyRef.current.focus()
}
}, [])
const saveRelayGroup = () => {
if (relayGroups.find((group) => group.groupName === newGroupName)) {
return setNewNameError(t('relay collection name already exists'))
}
const errMsg = addRelayGroup(newGroupName)
if (errMsg) {
return setNewNameError(errMsg)
}
setNewGroupName('')
}
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewGroupName(e.target.value)
setNewNameError(null)
}
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
saveRelayGroup()
}
}
return (
<RelaySettingsComponentProvider>
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
{!hideTitle && <div className="text-lg font-semibold mb-4">{t('Relay Settings')}</div>}
<div className="space-y-2">
<TemporaryRelayGroup />
{relayGroups.map((group, index) => (
<RelayGroup key={index} group={group} />
))}
</div>
{relayGroups.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">{t('Add a new relay collection')}</div>
</div>
<div className="mt-2 flex gap-2">
<Input
className={newNameError ? 'border-destructive' : ''}
placeholder={t('Relay collection name')}
value={newGroupName}
onChange={handleNewGroupNameChange}
onKeyDown={handleNewGroupNameKeyDown}
onBlur={saveRelayGroup}
/>
<Button onClick={saveRelayGroup}>{t('Add')}</Button>
</div>
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
</div>
</>
)}
</RelaySettingsComponentProvider>
)
}

View file

@ -0,0 +1,40 @@
import { createContext, useContext, useState } from 'react'
type TRelaySettingsComponentContext = {
renamingGroup: string | null
setRenamingGroup: React.Dispatch<React.SetStateAction<string | null>>
expandedRelayGroup: string | null
setExpandedRelayGroup: React.Dispatch<React.SetStateAction<string | null>>
}
export const RelaySettingsComponentContext = createContext<
TRelaySettingsComponentContext | undefined
>(undefined)
export const useRelaySettingsComponent = () => {
const context = useContext(RelaySettingsComponentContext)
if (!context) {
throw new Error(
'useRelaySettingsComponent must be used within a RelaySettingsComponentProvider'
)
}
return context
}
export function RelaySettingsComponentProvider({ children }: { children: React.ReactNode }) {
const [renamingGroup, setRenamingGroup] = useState<string | null>(null)
const [expandedRelayGroup, setExpandedRelayGroup] = useState<string | null>(null)
return (
<RelaySettingsComponentContext.Provider
value={{
renamingGroup,
setRenamingGroup,
expandedRelayGroup,
setExpandedRelayGroup
}}
>
{children}
</RelaySettingsComponentContext.Provider>
)
}

View file

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

View file

@ -0,0 +1,50 @@
import RelaySettings from '@/components/RelaySettings'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toRelaySettings } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RelaySettingsButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<SecondaryPageLink to={toRelaySettings()}>
<Button variant={variant} size={variant} title={t('Relay settings')}>
<Server />
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
</Button>
</SecondaryPageLink>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant={variant} size={variant} title={t('Relay settings')}>
<Server />
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-96 h-[450px] p-0"
side={variant === 'titlebar' ? 'bottom' : 'right'}
>
<ScrollArea className="h-full">
<div className="p-4">
<RelaySettings />
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,57 @@
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import LikeButton from '../NoteStats/LikeButton'
import ParentNotePreview from '../ParentNotePreview'
import PostDialog from '../PostDialog'
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
}) {
const { t } = useTranslation()
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
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 space-y-1">
<Username
userId={event.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
{parentEvent && (
<ParentNotePreview event={parentEvent} onClick={() => onClickParent(parentEvent.id)} />
)}
<Content event={event} size="small" />
<div className="flex gap-2 text-xs">
<div className="text-muted-foreground/60">
<FormattedTimestamp timestamp={event.created_at} />
</div>
<div
className="text-muted-foreground hover:text-primary cursor-pointer"
onClick={() => setIsPostDialogOpen(true)}
>
{t('reply')}
</div>
</div>
</div>
<LikeButton event={event} variant="reply" />
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
</div>
)
}

View file

@ -0,0 +1,193 @@
import { Separator } from '@/components/ui/separator'
import { isReplyNoteEvent } from '@/lib/event'
import { isReplyETag, isRootETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event as NEvent, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
const LIMIT = 100
export default function ReplyNoteList({ event, className }: { event: NEvent; className?: string }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
const [replies, setReplies] = useState<NEvent[]>([])
const [replyMap, setReplyMap] = useState<
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
>({})
const [loading, setLoading] = useState<boolean>(false)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const { updateNoteReplyCount } = useNoteStats()
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
useEffect(() => {
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail
if (
isReplyNoteEvent(evt) &&
evt.tags.some(([tagName, tagValue]) => tagName === 'e' && tagValue === event.id)
) {
onNewReply(evt)
}
}
client.addEventListener('eventPublished', handleEventPublished)
return () => {
client.removeEventListener('eventPublished', handleEventPublished)
}
}, [event])
useEffect(() => {
if (loading) return
const init = async () => {
setLoading(true)
setReplies([])
try {
const relayList = await client.fetchRelayList(event.pubkey)
const { closer, timelineKey } = await client.subscribeTimeline(
relayList.read.slice(0, 5),
{
'#e': [event.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
},
{
onEvents: (evts, eosed) => {
setReplies(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
if (eosed) {
setLoading(false)
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
}
},
onNew: (evt) => {
if (!isReplyNoteEvent(evt)) return
onNewReply(evt)
}
}
)
setTimelineKey(timelineKey)
return closer
} catch {
setLoading(false)
}
return
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [event])
useEffect(() => {
updateNoteReplyCount(event.id, replies.length)
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
{}
for (const reply of replies) {
const parentReplyTag = reply.tags.find(isReplyETag)
if (parentReplyTag) {
const parentReplyInfo = replyMap[parentReplyTag[1]]
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
continue
}
const rootReplyTag = reply.tags.find(isRootETag)
if (rootReplyTag) {
replyMap[reply.id] = { event: reply, level: 1 }
continue
}
let level = 0
let parent: NEvent | undefined
for (const [tagName, tagValue] of reply.tags) {
if (tagName === 'e') {
const info = replyMap[tagValue]
if (info && info.level > level) {
level = info.level
parent = info.event
}
}
}
replyMap[reply.id] = { event: reply, level: level + 1, parent }
}
setReplyMap(replyMap)
}, [replies, event.id, updateNoteReplyCount])
const loadMore = async () => {
if (loading || !until || !timelineKey) return
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderReplies = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
if (olderReplies.length > 0) {
setReplies((pre) => [...olderReplies, ...pre])
}
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
}
const onNewReply = (evt: NEvent) => {
setReplies((pre) => {
if (pre.some((reply) => reply.id === evt.id)) return pre
return [...pre, evt]
})
if (evt.pubkey === pubkey) {
setTimeout(() => {
highlightReply(evt.id)
}, 100)
}
}
const highlightReply = (eventId: string) => {
const ref = replyRefs.current[eventId]
if (ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
setHighlightReplyId(eventId)
setTimeout(() => {
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
}, 1500)
}
return (
<>
<div
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{loading ? t('loading...') : until ? t('load more older replies') : null}
</div>
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
<div className={cn('mb-4', className)}>
{replies.map((reply) => {
const info = replyMap[reply.id]
return (
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
<ReplyNote
event={reply}
parentEvent={info?.parent}
onClickParent={highlightReply}
highlight={highlightReplyId === reply.id}
/>
</div>
)
})}
</div>
{replies.length === 0 && !loading && !until && (
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
)}
</>
)
}

View file

@ -0,0 +1,30 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ChevronUp } from 'lucide-react'
export default function ScrollToTopButton({
scrollAreaRef,
className,
visible = true
}: {
scrollAreaRef: React.RefObject<HTMLDivElement>
className?: string
visible?: boolean
}) {
const handleScrollToTop = () => {
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<Button
variant="secondary-2"
className={cn(
`absolute bottom-2 right-2 rounded-full w-11 h-11 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-14'}`,
className
)}
onClick={handleScrollToTop}
>
<ChevronUp />
</Button>
)
}

View file

@ -0,0 +1,24 @@
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchDialog } from '../SearchDialog'
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
<Search />
{variant === 'sidebar' && <div>{t('Search')}</div>}
</Button>
<SearchDialog open={open} setOpen={setOpen} />
</>
)
}

View file

@ -0,0 +1,155 @@
import { SecondaryPageLink } from '@/PageManager'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useSearchProfiles } from '@/hooks'
import { toNote, toNoteList, toProfile, toProfileList } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { TProfile } from '@/types'
import { Hash, Notebook, UserRound } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { Dispatch, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispatch<boolean> }) {
const { t } = useTranslation()
const [input, setInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(input)
const { profiles } = useSearchProfiles(debouncedInput, 10)
const list = useMemo(() => {
const search = input.trim()
if (!search) return
if (/^[0-9a-f]{64}$/.test(search)) {
return (
<>
<NoteItem id={search} onClick={() => setOpen(false)} />
<ProfileIdItem id={search} onClick={() => setOpen(false)} />
</>
)
}
try {
let id = search
if (id.startsWith('nostr:')) {
id = id.slice(6)
}
const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) {
return <ProfileIdItem id={id} onClick={() => setOpen(false)} />
}
if (['nevent', 'naddr', 'note'].includes(type)) {
return <NoteItem id={id} onClick={() => setOpen(false)} />
}
} catch {
// ignore
}
return (
<>
<NormalItem search={search} onClick={() => setOpen(false)} />
<HashtagItem search={search} onClick={() => setOpen(false)} />
{profiles.map((profile) => (
<ProfileItem key={profile.pubkey} profile={profile} onClick={() => setOpen(false)} />
))}
{profiles.length >= 10 && (
<SecondaryPageLink to={toProfileList({ search })} onClick={() => setOpen(false)}>
<CommandItem onClick={() => setOpen(false)} className="text-center">
<div className="font-semibold">{t('Show more...')}</div>
</CommandItem>
</SecondaryPageLink>
)}
</>
)
}, [input, profiles, setOpen])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedInput(input)
}, 500)
return () => {
clearTimeout(handler)
}
}, [input])
return (
<CommandDialog open={open} onOpenChange={setOpen} classNames={{ content: 'max-sm:top-0' }}>
<CommandInput value={input} onValueChange={setInput} />
<CommandList>{list}</CommandList>
</CommandDialog>
)
}
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
const { searchableRelayUrls } = useRelaySettings()
if (searchableRelayUrls.length === 0) {
return null
}
return (
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
<CommandItem>
<Notebook className="text-muted-foreground" />
<div className="font-semibold">{search}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
return (
<SecondaryPageLink to={toNoteList({ hashtag })} onClick={onClick}>
<CommandItem value={`hashtag-${hashtag}`}>
<Hash className="text-muted-foreground" />
<div className="font-semibold">{hashtag}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toNote(id)} onClick={onClick}>
<CommandItem>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toProfile(id)} onClick={onClick}>
<CommandItem>
<UserRound className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</CommandItem>
</SecondaryPageLink>
)
}
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
return (
<SecondaryPageLink to={toProfile(profile.pubkey)} onClick={onClick}>
<CommandItem>
<div className="flex gap-2">
<Avatar>
<AvatarImage src={profile.avatar} alt={profile.username} />
<AvatarFallback>
<img src={generateImageByPubkey(profile.pubkey)} alt={profile.username} />
</AvatarFallback>
</Avatar>
<div>
<div className="font-semibold">{profile.username}</div>
<div className="line-clamp-1 text-muted-foreground">{profile.about}</div>
</div>
</div>
</CommandItem>
</SecondaryPageLink>
)
}

View file

@ -0,0 +1,37 @@
import Logo from '@/assets/Logo'
import { Button } from '@/components/ui/button'
import { Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import AboutInfoDialog from '../AboutInfoDialog'
import AccountButton from '../AccountButton'
import NotificationButton from '../NotificationButton'
import PostButton from '../PostButton'
import RefreshButton from '../RefreshButton'
import RelaySettingsButton from '../RelaySettingsButton'
import SearchButton from '../SearchButton'
export default function PrimaryPageSidebar() {
const { t } = useTranslation()
return (
<div className="w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-10 pl-4 justify-between relative">
<div className="absolute top-0 left-0 h-11 w-full" />
<div className="space-y-2">
<div className="ml-4 mb-8 w-40">
<Logo />
</div>
<PostButton variant="sidebar" />
<RelaySettingsButton variant="sidebar" />
<NotificationButton variant="sidebar" />
<SearchButton variant="sidebar" />
<RefreshButton variant="sidebar" />
<AboutInfoDialog>
<Button variant="sidebar" size="sidebar">
<Info />
{t('About')}
</Button>
</AboutInfoDialog>
</div>
<AccountButton variant="sidebar" />
</div>
)
}

View file

@ -0,0 +1,46 @@
import { Button } from '@/components/ui/button'
import { useTheme } from '@/providers/ThemeProvider'
import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function ThemeToggle({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { themeSetting, setThemeSetting } = useTheme()
return (
<>
{themeSetting === 'system' ? (
<Button
variant={variant}
size={variant}
onClick={() => setThemeSetting('light')}
title={t('switch to light theme')}
>
<SunMoon />
</Button>
) : themeSetting === 'light' ? (
<Button
variant={variant}
size={variant}
onClick={() => setThemeSetting('dark')}
title={t('switch to dark theme')}
>
<Sun />
</Button>
) : (
<Button
variant={variant}
size={variant}
onClick={() => setThemeSetting('system')}
title={t('switch to system theme')}
>
<Moon />
</Button>
)}
</>
)
}

View file

@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
export function Titlebar({
children,
className,
visible = true
}: {
children?: React.ReactNode
className?: string
visible?: boolean
}) {
return (
<div
className={cn(
'absolute top-0 w-full h-9 max-sm:h-11 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold gap-1 px-2 duration-700 transition-transform',
visible ? '' : '-translate-y-full',
className
)}
>
{children}
</div>
)
}

View file

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

View file

@ -0,0 +1,22 @@
import FollowButton from '@/components/FollowButton'
import Nip05 from '@/components/Nip05'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks'
export default function UserItem({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
const { nip05, about } = profile || {}
return (
<div className="flex gap-2 items-start">
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
<Nip05 nip05={nip05} pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{about}</div>
</div>
<FollowButton pubkey={pubkey} />
</div>
)
}

View file

@ -0,0 +1,44 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager'
import ProfileCard from '../ProfileCard'
export default function Username({
userId,
showAt = false,
className,
skeletonClassName
}: {
userId: string
showAt?: boolean
className?: string
skeletonClassName?: string
}) {
const { profile } = useFetchProfile(userId)
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
const { username, pubkey } = profile
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className={cn('max-w-fit', className)}>
<SecondaryPageLink
to={toProfile(pubkey)}
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{showAt && '@'}
{username}
</SecondaryPageLink>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<ProfileCard pubkey={pubkey} />
</HoverCardContent>
</HoverCard>
)
}

View file

@ -0,0 +1,26 @@
import { cn } from '@/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
preload="none"
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
src={src}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View file

@ -0,0 +1,56 @@
import { Image } from '@nextui-org/image'
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
export default function WebPreview({
url,
className,
size = 'normal'
}: {
url: string
className?: string
size?: 'normal' | 'small'
}) {
const { title, description, image } = useFetchWebMetadata(url)
const hostname = useMemo(() => {
try {
return new URL(url).hostname
} catch {
return ''
}
}, [url])
if (!title) {
return null
}
return (
<div
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)}
onClick={(e) => {
e.stopPropagation()
window.open(url, '_blank')
}}
>
{image && (
<Image
src={image}
className={`rounded-l-lg object-cover w-2/5 ${size === 'normal' ? 'h-44' : 'h-24'}`}
removeWrapper
/>
)}
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}>
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
{title}
</div>
<div
className={`text-xs text-muted-foreground ${size === 'normal' ? 'line-clamp-5' : 'line-clamp-2'}`}
>
{description}
</div>
</div>
</div>
)
}

View file

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

View file

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

View file

@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/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', 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<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -0,0 +1,162 @@
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
classNames,
...props
}: DialogProps & { classNames?: { content?: string } }) => {
return (
<Dialog {...props}>
<DialogHeader className="hidden">
<DialogTitle />
<DialogDescription />
</DialogHeader>
<DialogContent
className={cn(
'overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-4 data-[state=open]:slide-in-from-top-4',
classNames?.content
)}
>
<Command
shouldFilter={false}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<ScrollArea className="max-h-[80vh]">
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
</ScrollArea>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
}

View file

@ -0,0 +1,104 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/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> & { withoutClose?: boolean }
>(({ className, children, withoutClose, ...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 sm: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}
{!withoutClose && (
<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,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View file

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

View file

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

View file

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors 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-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View file

@ -0,0 +1,32 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
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}
collisionPadding={10}
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, PopoverAnchor }

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

126
src/components/ui/toast.tsx Normal file
View file

@ -0,0 +1,126 @@
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 '@/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-2 overflow-hidden rounded-md border p-4 pr-6 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 transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring 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-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 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 [&+div]:text-xs', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
}

View file

@ -0,0 +1,31 @@
import { useToast } from '@/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from '@/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>
)
}