feat: NIP-43

This commit is contained in:
codytseng 2025-11-09 00:26:16 +08:00
parent 6614a615c4
commit 850d92de28
25 changed files with 1132 additions and 26 deletions

View file

@ -2,15 +2,17 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useFetchRelayInfo } from '@/hooks'
import { checkNip43Support } from '@/lib/relay'
import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import PostEditor from '../PostEditor'
import RelayIcon from '../RelayIcon'
import RelayMembershipControl from '../RelayMembershipControl'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@ -21,6 +23,9 @@ export default function RelayInfo({ url, className }: { url: string; className?:
const { checkLogin } = useNostr()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
const [open, setOpen] = useState(false)
const [isMember, setIsMember] = useState(false)
const supportsNip43 = useMemo(() => checkNip43Support(relayInfo), [relayInfo])
const shouldShowPostButton = useMemo(() => !supportsNip43 || isMember, [supportsNip43, isMember])
if (isFetching || !relayInfo) {
return null
@ -105,14 +110,19 @@ export default function RelayInfo({ url, className }: { url: string; className?:
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Button
variant="secondary"
className="w-full"
onClick={() => checkLogin(() => setOpen(true))}
>
{t('Share something on this Relay')}
</Button>
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
<RelayMembershipControl relayInfo={relayInfo} onMembershipStatusChange={setIsMember} />
{shouldShowPostButton && (
<>
<Button
variant="secondary"
className="w-full"
onClick={() => checkLogin(() => setOpen(true))}
>
{t('Share something on this Relay')}
</Button>
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
</>
)}
</div>
<RelayReviewsPreview relayUrl={url} />
</div>

View file

@ -0,0 +1,134 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import relayMembershipService from '@/services/relay-membership.service'
import { TRelayInfo } from '@/types'
import { Check, Copy } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function InviteCodeDialog({
relayInfo,
showInviteCodeDialog,
setShowInviteCodeDialog
}: {
relayInfo: TRelayInfo
showInviteCodeDialog: boolean
setShowInviteCodeDialog: (open: boolean) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [isFetching, setIsFetching] = useState(false)
const [inviteCode, setInviteCode] = useState('')
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!showInviteCodeDialog) {
setInviteCode('')
return
}
const getInviteCode = async () => {
setIsFetching(true)
try {
if (relayInfo.pubkey) {
const code = await relayMembershipService.requestInviteCode(
relayInfo.url,
relayInfo.pubkey
)
if (code) {
setInviteCode(code)
} else {
toast.error(t('Failed to get invite code from relay'))
}
}
} catch (error: any) {
toast.error(error.message || t('Failed to get invite code'))
} finally {
setIsFetching(false)
}
}
getInviteCode()
}, [showInviteCodeDialog])
const handleCopyInviteCode = () => {
if (!inviteCode) return
navigator.clipboard.writeText(inviteCode)
toast.success(t('Invite code copied to clipboard'))
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 2000)
}
const content = isFetching ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">{t('Loading...')}</div>
</div>
) : inviteCode ? (
<div className="space-y-2">
<Label htmlFor="fetched-invite-code">{t('Invite Code')}</Label>
<div className="flex gap-2">
<Input id="fetched-invite-code" value={inviteCode} readOnly className="font-mono" />
<Button onClick={handleCopyInviteCode} variant="outline">
{copied ? <Check /> : <Copy />}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t('This invite code can be used by others to join the relay.')}
</p>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
{t('No invite code available from this relay.')}
</div>
)
if (isSmallScreen) {
return (
<Drawer open={showInviteCodeDialog} onOpenChange={setShowInviteCodeDialog}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('Get Invite Code')}</DrawerTitle>
<DrawerDescription>
{t('Share this invite code with others to invite them to join this relay.')}
</DrawerDescription>
</DrawerHeader>
<div className="p-4">{content}</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={showInviteCodeDialog} onOpenChange={setShowInviteCodeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Get Invite Code')}</DialogTitle>
<DialogDescription>
{t('Share this invite code with others to invite them to join this relay.')}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,141 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createJoinDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import relayMembershipService from '@/services/relay-membership.service'
import { TRelayInfo } from '@/types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function JoinDialog({
relayInfo,
showJoinDialog,
setShowJoinDialog,
onMembershipStatusChange
}: {
relayInfo: TRelayInfo
showJoinDialog: boolean
setShowJoinDialog: (open: boolean) => void
onMembershipStatusChange: (status: boolean) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish } = useNostr()
const [inviteCode, setInviteCode] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleJoinSubmit = async () => {
setIsLoading(true)
try {
const draftEvent = createJoinDraftEvent(inviteCode)
const joinRequestEvent = await publish(draftEvent, {
specifiedRelayUrls: [relayInfo.url]
})
toast.success(t('Join request sent successfully'))
await relayMembershipService.addNewMember(relayInfo.url, joinRequestEvent.pubkey)
onMembershipStatusChange(true)
setInviteCode('')
setShowJoinDialog(false)
} catch (error) {
const errors = error instanceof AggregateError ? error.errors : [error]
errors.forEach((err) => {
toast.error(
`${t('Failed to send join request')}: ${err instanceof Error ? err.message : String(err)}`,
{ duration: 10_000 }
)
console.error(err)
})
return
} finally {
setIsLoading(false)
}
}
const content = (
<div className="space-y-2">
<Label htmlFor="invite-code">{t('Invite Code')}</Label>
<Input
id="invite-code"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder={t('Enter invite code')}
required
/>
<p className="text-sm text-muted-foreground">
{t('You can get an invite code from a relay member.')}
</p>
</div>
)
if (isSmallScreen) {
return (
<Drawer open={showJoinDialog} onOpenChange={setShowJoinDialog}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('Request to Join Relay')}</DrawerTitle>
<DrawerDescription>
{t('Enter the invite code you received from a relay member.')}
</DrawerDescription>
</DrawerHeader>
<div className="p-4">{content}</div>
<DrawerFooter>
<Button onClick={handleJoinSubmit} disabled={isLoading || !inviteCode.trim()}>
{isLoading ? t('Sending...') : t('Send Request')}
</Button>
<DrawerClose asChild>
<Button variant="outline">{t('Cancel')}</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={showJoinDialog} onOpenChange={setShowJoinDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Request to Join Relay')}</DialogTitle>
<DialogDescription>
{t('Enter the invite code you received from a relay member.')}
</DialogDescription>
</DialogHeader>
{content}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setShowJoinDialog(false)
setInviteCode('')
}}
>
{t('Cancel')}
</Button>
<Button onClick={handleJoinSubmit} disabled={isLoading || !inviteCode.trim()}>
{isLoading ? t('Sending...') : t('Send Request')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,166 @@
import { Button } from '@/components/ui/button'
import { createJoinDraftEvent, createLeaveDraftEvent } from '@/lib/draft-event'
import { checkNip43Support } from '@/lib/relay'
import { useNostr } from '@/providers/NostrProvider'
import relayMembershipService from '@/services/relay-membership.service'
import { TRelayInfo } from '@/types'
import { LogIn, LogOut, Mail } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import InviteCodeDialog from './InviteCodeDialog'
import JoinDialog from './JoinDialog'
interface RelayMembershipControlProps {
relayInfo: TRelayInfo
onMembershipStatusChange?: (status: boolean) => void
}
export default function RelayMembershipControl({
relayInfo,
onMembershipStatusChange
}: RelayMembershipControlProps) {
const { t } = useTranslation()
const { pubkey, checkLogin, publish } = useNostr()
const [isMember, setIsMember] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isChecking, setIsChecking] = useState(false)
const [showJoinDialog, setShowJoinDialog] = useState(false)
const [showInviteCodeDialog, setShowInviteCodeDialog] = useState(false)
const supportsNip43 = useMemo(() => checkNip43Support(relayInfo), [relayInfo])
useEffect(() => {
if (!supportsNip43 || !pubkey) {
setIsMember(false)
return
}
const checkMembership = async () => {
try {
setIsChecking(true)
const status = await relayMembershipService.checkMembership(
relayInfo.url,
pubkey,
relayInfo.pubkey
)
setIsMember(status)
} finally {
setIsChecking(false)
}
}
checkMembership()
}, [relayInfo.url, relayInfo.pubkey, pubkey, supportsNip43])
useEffect(() => {
if (onMembershipStatusChange) {
onMembershipStatusChange(isMember)
}
}, [isMember, onMembershipStatusChange])
if (!supportsNip43 || isChecking) {
return null
}
const submitJoinRequest = async () => {
setIsLoading(true)
try {
const draftEvent = createJoinDraftEvent('')
const joinRequestEvent = await publish(draftEvent, {
specifiedRelayUrls: [relayInfo.url]
})
toast.success(t('Join request sent successfully'))
await relayMembershipService.addNewMember(relayInfo.url, joinRequestEvent.pubkey)
onMembershipStatusChange?.(true)
} catch {
setShowJoinDialog(true)
} finally {
setIsLoading(false)
}
}
const handleGetInviteCodeClick = () => {
setShowInviteCodeDialog(true)
}
const handleLeaveClick = async () => {
if (!confirm(t('Are you sure you want to leave this relay?'))) {
return
}
setIsLoading(true)
try {
const draftEvent = createLeaveDraftEvent()
const leaveRequestEvent = await publish(draftEvent, {
specifiedRelayUrls: [relayInfo.url]
})
toast.success(t('Leave request sent successfully'))
await relayMembershipService.removeMember(relayInfo.url, leaveRequestEvent.pubkey)
setIsMember(false)
} catch (error: any) {
const errors = error instanceof AggregateError ? error.errors : [error]
errors.forEach((err) => {
toast.error(
`${t('Failed to send leave request')}: ${err instanceof Error ? err.message : String(err)}`,
{ duration: 10_000 }
)
console.error(err)
})
return
} finally {
setIsLoading(false)
}
}
return (
<>
{isMember ? (
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
className="w-full"
onClick={handleGetInviteCodeClick}
disabled={isLoading}
>
<Mail className="w-4 h-4 mr-2" />
{t('Get Invite Code')}
</Button>
<Button
variant="outline"
className="w-full"
onClick={handleLeaveClick}
disabled={isLoading}
>
<LogOut className="w-4 h-4 mr-2" />
{t('Leave')}
</Button>
</div>
) : (
<Button
variant="default"
className="w-full"
onClick={() => {
checkLogin(() => submitJoinRequest())
}}
disabled={isLoading}
>
<LogIn className="w-4 h-4 mr-2" />
{t('Request to Join Relay')}
</Button>
)}
<JoinDialog
relayInfo={relayInfo}
showJoinDialog={showJoinDialog}
setShowJoinDialog={setShowJoinDialog}
onMembershipStatusChange={setIsMember}
/>
<InviteCodeDialog
relayInfo={relayInfo}
showInviteCodeDialog={showInviteCodeDialog}
setShowInviteCodeDialog={setShowInviteCodeDialog}
/>
</>
)
}