feat: NIP-43
This commit is contained in:
parent
6614a615c4
commit
850d92de28
25 changed files with 1132 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
134
src/components/RelayMembershipControl/InviteCodeDialog.tsx
Normal file
134
src/components/RelayMembershipControl/InviteCodeDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/components/RelayMembershipControl/JoinDialog.tsx
Normal file
141
src/components/RelayMembershipControl/JoinDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
src/components/RelayMembershipControl/index.tsx
Normal file
166
src/components/RelayMembershipControl/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue