feat: add mailbox configuration

This commit is contained in:
codytseng 2025-01-13 16:53:07 +08:00
parent 9de3d4ed5b
commit 0f8a5403cd
20 changed files with 525 additions and 62 deletions

View file

@ -0,0 +1,62 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { CircleX, Server } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { TMailboxRelay, TMailboxRelayScope } from './types'
export default function MailboxRelay({
mailboxRelay,
changeMailboxRelayScope,
removeMailboxRelay
}: {
mailboxRelay: TMailboxRelay
changeMailboxRelayScope: (url: string, scope: TMailboxRelayScope) => void
removeMailboxRelay: (url: string) => void
}) {
const { t } = useTranslation()
const relayIcon = useMemo(() => {
const url = new URL(mailboxRelay.url)
return `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}/favicon.ico`
}, [mailboxRelay.url])
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 w-0">
<Avatar className="w-6 h-6">
<AvatarImage src={relayIcon} />
<AvatarFallback>
<Server size={14} />
</AvatarFallback>
</Avatar>
<div className="truncate">{mailboxRelay.url}</div>
</div>
<div className="flex items-center gap-4">
<Select
value={mailboxRelay.scope}
onValueChange={(v: TMailboxRelayScope) => changeMailboxRelayScope(mailboxRelay.url, v)}
>
<SelectTrigger className="w-24 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">{t('R & W')}</SelectItem>
<SelectItem value="read">{t('Read')}</SelectItem>
<SelectItem value="write">{t('Write')}</SelectItem>
</SelectContent>
</Select>
<CircleX
size={16}
onClick={() => removeMailboxRelay(mailboxRelay.url)}
className="text-muted-foreground hover:text-destructive clickable"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function NewMailboxRelayInput({
saveNewMailboxRelay
}: {
saveNewMailboxRelay: (url: string) => string | null
}) {
const { t } = useTranslation()
const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
const save = () => {
const error = saveNewMailboxRelay(newRelayUrl)
if (error) {
setNewRelayUrlError(error)
} else {
setNewRelayUrl('')
}
}
const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
save()
}
}
const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewRelayUrl(e.target.value)
setNewRelayUrlError(null)
}
return (
<div>
<div className="flex gap-4">
<Input
className={newRelayUrlError ? 'border-destructive' : ''}
placeholder={t('Add a new relay')}
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={save}
/>
<Button onClick={save}>{t('Add')}</Button>
</div>
{newRelayUrlError && <div className="text-destructive text-xs mt-1">{newRelayUrlError}</div>}
</div>
)
}

View file

@ -0,0 +1,52 @@
import { useToast } from '@/hooks'
import { useNostr } from '@/providers/NostrProvider'
import dayjs from 'dayjs'
import { CloudUpload, Loader } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useState } from 'react'
import { Button } from '../ui/button'
import { TMailboxRelay } from './types'
export default function SaveButton({
mailboxRelays,
hasChange,
setHasChange
}: {
mailboxRelays: TMailboxRelay[]
hasChange: boolean
setHasChange: (hasChange: boolean) => void
}) {
const { toast } = useToast()
const { pubkey, publish, updateRelayList } = useNostr()
const [pushing, setPushing] = useState(false)
const save = async () => {
setPushing(true)
const event = {
kind: kinds.RelayList,
content: '',
tags: mailboxRelays.map(({ url, scope }) =>
scope === 'both' ? ['r', url] : ['r', url, scope]
),
created_at: dayjs().unix()
}
await publish(event)
updateRelayList({
write: mailboxRelays.filter(({ scope }) => scope !== 'read').map(({ url }) => url),
read: mailboxRelays.filter(({ scope }) => scope !== 'write').map(({ url }) => url)
})
toast({
title: 'Save Successful',
description: 'Successfully saved mailbox relays'
})
setHasChange(false)
setPushing(false)
}
return (
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />}
Save
</Button>
)
}

View file

@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button'
import { normalizeUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MailboxRelay from './MailboxRelay'
import NewMailboxRelayInput from './NewMailboxRelayInput'
import SaveButton from './SaveButton'
import { TMailboxRelay, TMailboxRelayScope } from './types'
export default function MailboxSetting() {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const [relays, setRelays] = useState<TMailboxRelay[]>([])
const [hasChange, setHasChange] = useState(false)
useEffect(() => {
if (!relayList) return
const mailboxRelays: TMailboxRelay[] = relayList.read.map((url) => ({ url, scope: 'read' }))
relayList.write.forEach((url) => {
const item = mailboxRelays.find((r) => r.url === url)
if (item) {
item.scope = 'both'
} else {
mailboxRelays.push({ url, scope: 'write' })
}
})
setRelays(mailboxRelays)
}, [relayList])
if (!pubkey) {
return <Button size="lg">Login to set</Button>
}
if (!relayList) {
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
}
const changeMailboxRelayScope = (url: string, scope: TMailboxRelayScope) => {
setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r)))
setHasChange(true)
}
const removeMailboxRelay = (url: string) => {
setRelays((prev) => prev.filter((r) => r.url !== url))
setHasChange(true)
}
const saveNewMailboxRelay = (url: string) => {
if (url === '') return null
const normalizedUrl = normalizeUrl(url)
if (relays.some((r) => r.url === normalizedUrl)) {
return t('Relay already exists')
}
setRelays([...relays, { url: normalizedUrl, scope: 'both' }])
setHasChange(true)
return null
}
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('read relays description')}</div>
<div>{t('write relays description')}</div>
<div>{t('read & write relays notice')}</div>
</div>
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
<div className="space-y-2">
{relays.map((relay) => (
<MailboxRelay
key={relay.url}
mailboxRelay={relay}
changeMailboxRelayScope={changeMailboxRelayScope}
removeMailboxRelay={removeMailboxRelay}
/>
))}
</div>
<NewMailboxRelayInput saveNewMailboxRelay={saveNewMailboxRelay} />
</div>
)
}

View file

@ -0,0 +1,5 @@
export type TMailboxRelayScope = 'read' | 'write' | 'both'
export type TMailboxRelay = {
url: string
scope: TMailboxRelayScope
}

View file

@ -1,5 +1,6 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
@ -11,16 +12,15 @@ import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded'
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
const LIMIT = 100
export default function NotificationList() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { pubkey, relayList } = useNostr()
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshing, setRefreshing] = useState(true)
@ -29,14 +29,13 @@ export default function NotificationList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!pubkey) {
if (!pubkey || !relayList) {
setUntil(undefined)
return
}
const init = async () => {
setRefreshing(true)
const relayList = await client.fetchRelayList(pubkey)
let eventCount = 0
const { closer, timelineKey } = await client.subscribeTimeline(
relayList.read.length >= 4
@ -71,7 +70,7 @@ export default function NotificationList() {
return () => {
promise.then((closer) => closer?.())
}
}, [pubkey, refreshCount])
}, [pubkey, refreshCount, relayList])
useEffect(() => {
if (refreshing) return

View file

@ -26,9 +26,11 @@ import { TRelaySet } from '@/types'
import { CloudDownload } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySetCard from '../RelaySetCard'
export default function PullFromRelaysButton() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
@ -36,7 +38,7 @@ export default function PullFromRelaysButton() {
const trigger = (
<Button variant="secondary" className="w-full" disabled={!pubkey}>
<CloudDownload />
Pull from relays
{t('Pull from relays')}
</Button>
)
@ -47,7 +49,7 @@ export default function PullFromRelaysButton() {
<DrawerContent className="max-h-[90vh]">
<div className="flex flex-col p-4 gap-4 overflow-auto">
<DrawerHeader>
<DrawerTitle>Select the relay sets you want to pull</DrawerTitle>
<DrawerTitle>{t('Select the relay sets you want to pull')}</DrawerTitle>
<DrawerDescription className="hidden" />
</DrawerHeader>
<RemoteRelaySets close={() => setOpen(false)} />
@ -62,7 +64,7 @@ export default function PullFromRelaysButton() {
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>Select the relay sets you want to pull</DialogTitle>
<DialogTitle>{t('Select the relay sets you want to pull')}</DialogTitle>
<DialogDescription className="hidden" />
</DialogHeader>
<RemoteRelaySets close={() => setOpen(false)} />
@ -72,6 +74,7 @@ export default function PullFromRelaysButton() {
}
function RemoteRelaySets({ close }: { close?: () => void }) {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const { mergeRelaySets } = useRelaySets()
const [initialed, setInitialed] = useState(false)
@ -117,9 +120,9 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
}, [pubkey])
if (!pubkey) return null
if (!initialed) return <div className="text-center text-muted-foreground">Loading...</div>
if (!initialed) return <div className="text-center text-muted-foreground">{t('loading...')}</div>
if (!relaySets.length) {
return <div className="text-center text-muted-foreground">No relay sets found</div>
return <div className="text-center text-muted-foreground">{t('No relay sets found')}</div>
}
return (
@ -146,7 +149,7 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
variant="secondary"
onClick={() => setSelectedRelaySetIds(relaySets.map((r) => r.id))}
>
All
{t('Select all')}
</Button>
<Button
className="w-full"
@ -159,8 +162,8 @@ function RemoteRelaySets({ close }: { close?: () => void }) {
}}
>
{selectedRelaySetIds.length > 0
? `Pull ${selectedRelaySetIds.length} relay sets`
: 'Pull'}
? t('Pull n relay sets', { n: selectedRelaySetIds.length })
: t('Pull')}
</Button>
</div>
</div>

View file

@ -5,9 +5,11 @@ import { useNostr } from '@/providers/NostrProvider'
import { useRelaySets } from '@/providers/RelaySetsProvider'
import { CloudUpload, Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRelaySetsSettingComponent } from './provider'
export default function PushToRelaysButton() {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey, publish } = useNostr()
const { relaySets } = useRelaySets()
@ -22,8 +24,8 @@ export default function PushToRelaysButton() {
const draftEvents = selectedRelaySets.map((relaySet) => createRelaySetDraftEvent(relaySet))
await Promise.allSettled(draftEvents.map((event) => publish(event)))
toast({
title: 'Push Successful',
description: 'Successfully pushed relay sets to relays'
title: t('Push Successful'),
description: t('Successfully pushed relay sets to relays')
})
setPushing(false)
}
@ -36,7 +38,7 @@ export default function PushToRelaysButton() {
onClick={push}
>
<CloudUpload />
Push to relays
{t('Push to relays')}
{pushing && <Loader className="animate-spin" />}
</Button>
)

View file

@ -25,6 +25,7 @@ export default function RelaySetsSetting() {
const saveRelaySet = () => {
if (!newRelaySetName) return
addRelaySet(newRelaySetName)
setNewRelaySetName('')
}
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View file

@ -6,7 +6,6 @@ import {
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'
@ -30,9 +29,8 @@ export default function AccountButton() {
function ProfileButton() {
const { t } = useTranslation()
const { account } = useNostr()
const { account, profile } = useNostr()
const pubkey = account?.pubkey
const { profile } = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)