feat: add relay selector for posting
This commit is contained in:
@@ -2,22 +2,12 @@ import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
|
||||
import { toNjump } from '@/lib/link'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Code,
|
||||
Copy,
|
||||
Link,
|
||||
Mail,
|
||||
SatelliteDish,
|
||||
Server,
|
||||
Trash2,
|
||||
TriangleAlert
|
||||
} from 'lucide-react'
|
||||
import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -59,7 +49,11 @@ export function useMenuActions({
|
||||
}: UseMenuActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, attemptDelete } = useNostr()
|
||||
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
|
||||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
const relayUrls = useMemo(() => {
|
||||
return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
|
||||
}, [currentBrowsingRelayUrls, favoriteRelays])
|
||||
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
|
||||
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
|
||||
|
||||
@@ -67,12 +61,7 @@ export function useMenuActions({
|
||||
const items = []
|
||||
if (pubkey && event.pubkey === pubkey) {
|
||||
items.push({
|
||||
label: (
|
||||
<div className="flex items-center gap-2 w-full pl-1">
|
||||
<Mail />
|
||||
<div className="flex-1 truncate text-left">{t('Suitable Relays')}</div>
|
||||
</div>
|
||||
),
|
||||
label: <div className="text-left"> {t('Write relays')}</div>,
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
const relays = await client.determineTargetRelays(event)
|
||||
@@ -97,12 +86,7 @@ export function useMenuActions({
|
||||
...relaySets
|
||||
.filter((set) => set.relayUrls.length)
|
||||
.map((set, index) => ({
|
||||
label: (
|
||||
<div className="flex items-center gap-2 w-full pl-1">
|
||||
<Server />
|
||||
<div className="flex-1 truncate text-left">{set.name}</div>
|
||||
</div>
|
||||
),
|
||||
label: <div className="text-left truncate">{set.name}</div>,
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
await client
|
||||
@@ -126,9 +110,9 @@ export function useMenuActions({
|
||||
)
|
||||
}
|
||||
|
||||
if (favoriteRelays.length) {
|
||||
if (relayUrls.length) {
|
||||
items.push(
|
||||
...favoriteRelays.map((relay, index) => ({
|
||||
...relayUrls.map((relay, index) => ({
|
||||
label: (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<RelayIcon url={relay} />
|
||||
@@ -159,7 +143,7 @@ export function useMenuActions({
|
||||
}
|
||||
|
||||
return items
|
||||
}, [pubkey, favoriteRelays, relaySets])
|
||||
}, [pubkey, relayUrls, relaySets])
|
||||
|
||||
const menuActions: MenuAction[] = useMemo(() => {
|
||||
const actions: MenuAction[] = [
|
||||
|
||||
@@ -14,15 +14,15 @@ import postEditorCache from '@/services/post-editor-cache.service'
|
||||
import { TPollCreateData } from '@/types'
|
||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import EmojiPickerDialog from '../EmojiPickerDialog'
|
||||
import Mentions from './Mentions'
|
||||
import PollEditor from './PollEditor'
|
||||
import PostOptions from './PostOptions'
|
||||
import PostRelaySelector from './PostRelaySelector'
|
||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||
import SendOnlyToSwitch from './SendOnlyToSwitch'
|
||||
import Uploader from './Uploader'
|
||||
|
||||
export default function PostContent({
|
||||
@@ -47,10 +47,11 @@ export default function PostContent({
|
||||
>([])
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
const [isNsfw, setIsNsfw] = useState(false)
|
||||
const [isPoll, setIsPoll] = useState(false)
|
||||
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
|
||||
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
|
||||
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
|
||||
isMultipleChoice: false,
|
||||
options: ['', ''],
|
||||
@@ -59,12 +60,25 @@ export default function PostContent({
|
||||
})
|
||||
const [minPow, setMinPow] = useState(0)
|
||||
const isFirstRender = useRef(true)
|
||||
const canPost =
|
||||
!!pubkey &&
|
||||
!!text &&
|
||||
!posting &&
|
||||
!uploadProgresses.length &&
|
||||
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2)
|
||||
const canPost = useMemo(() => {
|
||||
return (
|
||||
!!pubkey &&
|
||||
!!text &&
|
||||
!posting &&
|
||||
!uploadProgresses.length &&
|
||||
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
|
||||
(!isProtectedEvent || additionalRelayUrls.length > 0)
|
||||
)
|
||||
}, [
|
||||
pubkey,
|
||||
text,
|
||||
posting,
|
||||
uploadProgresses,
|
||||
isPoll,
|
||||
pollCreateData,
|
||||
isProtectedEvent,
|
||||
additionalRelayUrls
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
@@ -97,15 +111,7 @@ export default function PostContent({
|
||||
addClientTag
|
||||
}
|
||||
)
|
||||
}, [
|
||||
defaultContent,
|
||||
parentEvent,
|
||||
isNsfw,
|
||||
isPoll,
|
||||
pollCreateData,
|
||||
specifiedRelayUrls,
|
||||
addClientTag
|
||||
])
|
||||
}, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag])
|
||||
|
||||
const post = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
@@ -118,24 +124,24 @@ export default function PostContent({
|
||||
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||
? await createCommentDraftEvent(text, parentEvent, mentions, {
|
||||
addClientTag,
|
||||
protectedEvent: !!specifiedRelayUrls,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
})
|
||||
: isPoll
|
||||
? await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
|
||||
? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, {
|
||||
addClientTag,
|
||||
isNsfw
|
||||
})
|
||||
: await createShortTextNoteDraftEvent(text, mentions, {
|
||||
parentEvent,
|
||||
addClientTag,
|
||||
protectedEvent: !!specifiedRelayUrls,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
})
|
||||
|
||||
const newEvent = await publish(draftEvent, {
|
||||
specifiedRelayUrls,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : [],
|
||||
specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls,
|
||||
minPow
|
||||
})
|
||||
postEditorCache.clearPostCache({ defaultContent, parentEvent })
|
||||
@@ -233,10 +239,10 @@ export default function PostContent({
|
||||
</div>
|
||||
))}
|
||||
{!isPoll && (
|
||||
<SendOnlyToSwitch
|
||||
<PostRelaySelector
|
||||
setIsProtectedEvent={setIsProtectedEvent}
|
||||
setAdditionalRelayUrls={setAdditionalRelayUrls}
|
||||
parentEvent={parentEvent}
|
||||
specifiedRelayUrls={specifiedRelayUrls}
|
||||
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
||||
openFrom={openFrom}
|
||||
/>
|
||||
)}
|
||||
|
||||
209
src/components/PostEditor/PostRelaySelector.tsx
Normal file
209
src/components/PostEditor/PostRelaySelector.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { isProtectedEvent } from '@/lib/event'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
type TPostTargetItem =
|
||||
| {
|
||||
type: 'writeRelays'
|
||||
}
|
||||
| {
|
||||
type: 'relay'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'relaySet'
|
||||
id: string
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
export default function PostRelaySelector({
|
||||
parentEvent,
|
||||
openFrom,
|
||||
setIsProtectedEvent,
|
||||
setAdditionalRelayUrls
|
||||
}: {
|
||||
parentEvent?: NostrEvent
|
||||
openFrom?: string[]
|
||||
setIsProtectedEvent: Dispatch<SetStateAction<boolean>>
|
||||
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { relayUrls } = useCurrentRelays()
|
||||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
const [postTargetItems, setPostTargetItems] = useState<TPostTargetItem[]>([])
|
||||
const parentEventSeenOnRelays = useMemo(() => {
|
||||
if (!parentEvent || !isProtectedEvent(parentEvent)) {
|
||||
return []
|
||||
}
|
||||
return client.getSeenEventRelayUrls(parentEvent.id)
|
||||
}, [parentEvent])
|
||||
const selectableRelays = useMemo(() => {
|
||||
return Array.from(new Set(parentEventSeenOnRelays.concat(relayUrls).concat(favoriteRelays)))
|
||||
}, [parentEventSeenOnRelays, relayUrls, favoriteRelays])
|
||||
const description = useMemo(() => {
|
||||
if (postTargetItems.length === 0) {
|
||||
return t('No relays selected')
|
||||
}
|
||||
if (postTargetItems.length === 1) {
|
||||
const item = postTargetItems[0]
|
||||
if (item.type === 'writeRelays') {
|
||||
return t('Write relays')
|
||||
}
|
||||
if (item.type === 'relay') {
|
||||
return simplifyUrl(item.url)
|
||||
}
|
||||
if (item.type === 'relaySet') {
|
||||
return item.urls.length > 1
|
||||
? t('{{count}} relays', { count: item.urls.length })
|
||||
: simplifyUrl(item.urls[0])
|
||||
}
|
||||
}
|
||||
const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays')
|
||||
const relayCount = postTargetItems.reduce((count, item) => {
|
||||
if (item.type === 'relay') {
|
||||
return count + 1
|
||||
}
|
||||
if (item.type === 'relaySet') {
|
||||
return count + item.urls.length
|
||||
}
|
||||
return count
|
||||
}, 0)
|
||||
if (hasWriteRelays) {
|
||||
return t('Write relays and {{count}} other relays', { count: relayCount })
|
||||
}
|
||||
return t('{{count}} relays', { count: relayCount })
|
||||
}, [postTargetItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (openFrom && openFrom.length) {
|
||||
setPostTargetItems(Array.from(new Set(openFrom)).map((url) => ({ type: 'relay', url })))
|
||||
return
|
||||
}
|
||||
if (parentEventSeenOnRelays && parentEventSeenOnRelays.length) {
|
||||
setPostTargetItems(parentEventSeenOnRelays.map((url) => ({ type: 'relay', url })))
|
||||
return
|
||||
}
|
||||
setPostTargetItems([{ type: 'writeRelays' }])
|
||||
}, [openFrom, parentEventSeenOnRelays])
|
||||
|
||||
useEffect(() => {
|
||||
const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays')
|
||||
const relayUrls = postTargetItems.flatMap((item) => {
|
||||
if (item.type === 'relay') {
|
||||
return [item.url]
|
||||
}
|
||||
if (item.type === 'relaySet') {
|
||||
return item.urls
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
setIsProtectedEvent(isProtectedEvent)
|
||||
setAdditionalRelayUrls(relayUrls)
|
||||
}, [postTargetItems])
|
||||
|
||||
const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }])
|
||||
} else {
|
||||
setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev) => [...prev, { type: 'relay', url }])
|
||||
} else {
|
||||
setPostTargetItems((prev) =>
|
||||
prev.filter((item) => !(item.type === 'relay' && item.url === url))
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRelaySetCheckedChange = useCallback(
|
||||
(checked: boolean, id: string, urls: string[]) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }])
|
||||
} else {
|
||||
setPostTargetItems((prev) =>
|
||||
prev.filter((item) => !(item.type === 'relaySet' && item.id === id))
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
{t('Post to')}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="px-2">
|
||||
{description}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="start" className="max-w-96">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={postTargetItems.some((item) => item.type === 'writeRelays')}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={handleWriteRelaysCheckedChange}
|
||||
>
|
||||
{t('Write relays')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{relaySets.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{relaySets
|
||||
.filter(({ relayUrls }) => relayUrls.length)
|
||||
.map(({ id, name, relayUrls }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={id}
|
||||
checked={postTargetItems.some(
|
||||
(item) => item.type === 'relaySet' && item.id === id
|
||||
)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)}
|
||||
>
|
||||
<div className="truncate">
|
||||
{name} ({relayUrls.length})
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{selectableRelays.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{selectableRelays.map((url) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={url}
|
||||
checked={postTargetItems.some((item) => item.type === 'relay' && item.url === url)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RelayIcon url={url} />
|
||||
<div className="truncate">{simplifyUrl(url)}</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { isProtectedEvent } from '@/lib/event'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SendOnlyToSwitch({
|
||||
parentEvent,
|
||||
specifiedRelayUrls,
|
||||
setSpecifiedRelayUrls,
|
||||
openFrom
|
||||
}: {
|
||||
parentEvent?: Event
|
||||
specifiedRelayUrls?: string[]
|
||||
setSpecifiedRelayUrls: Dispatch<SetStateAction<string[] | undefined>>
|
||||
openFrom?: string[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRelayUrls } = useCurrentRelays()
|
||||
const [urls, setUrls] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (openFrom?.length) {
|
||||
setUrls(openFrom)
|
||||
setSpecifiedRelayUrls(openFrom)
|
||||
return
|
||||
}
|
||||
if (!parentEvent) {
|
||||
setUrls(currentRelayUrls)
|
||||
return
|
||||
}
|
||||
const isProtected = isProtectedEvent(parentEvent)
|
||||
const seenOn = client.getSeenEventRelayUrls(parentEvent.id)
|
||||
if (isProtected && seenOn.length) {
|
||||
setSpecifiedRelayUrls(seenOn)
|
||||
setUrls(seenOn)
|
||||
} else {
|
||||
setUrls(currentRelayUrls)
|
||||
}
|
||||
}, [parentEvent, currentRelayUrls, openFrom])
|
||||
|
||||
if (!urls.length) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<Label htmlFor="send-only-to-current-relays" className="truncate">
|
||||
{urls.length === 1
|
||||
? t('Send only to r', { r: simplifyUrl(urls[0]) })
|
||||
: t('Send only to these relays')}
|
||||
</Label>
|
||||
{urls.length > 1 && (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Info size={14} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit text-sm">
|
||||
{urls.map((url) => (
|
||||
<div key={url}>{simplifyUrl(url)}</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
className="shrink-0"
|
||||
id="send-only-to-current-relays"
|
||||
checked={!!specifiedRelayUrls}
|
||||
onCheckedChange={(checked) => setSpecifiedRelayUrls(checked ? urls : undefined)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,17 +3,28 @@ import RelayInfo from '@/components/RelayInfo'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotFound from '../NotFound'
|
||||
|
||||
export default function Relay({ url, className }: { url?: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
|
||||
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
|
||||
const { relayInfo } = useFetchRelayInfo(normalizedUrl)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [debouncedInput, setDebouncedInput] = useState(searchInput)
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedUrl) {
|
||||
addRelayUrls([normalizedUrl])
|
||||
return () => {
|
||||
removeRelayUrls([normalizedUrl])
|
||||
}
|
||||
}
|
||||
}, [normalizedUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(searchInput)
|
||||
|
||||
Reference in New Issue
Block a user