diff --git a/src/App.tsx b/src/App.tsx index f539496..50b7afc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,25 +8,25 @@ import { FeedProvider } from './providers/FeedProvider' import { FollowListProvider } from './providers/FollowListProvider' import { NostrProvider } from './providers/NostrProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider' -import { RelaySettingsProvider } from './providers/RelaySettingsProvider' +import { RelaySetsProvider } from './providers/RelaySetsProvider' import { ScreenSizeProvider } from './providers/ScreenSizeProvider' export default function App(): JSX.Element { return ( - - - + + + - - - + + + ) diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx index fffcad9..ec44dca 100644 --- a/src/components/FeedSwitcher/index.tsx +++ b/src/components/FeedSwitcher/index.tsx @@ -3,15 +3,16 @@ import { simplifyUrl } from '@/lib/url' import { SecondaryPageLink } from '@/PageManager' import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { useRelaySets } from '@/providers/RelaySetsProvider' import { Circle, CircleCheck } from 'lucide-react' import { useTranslation } from 'react-i18next' +import RelaySetCard from '../RelaySetCard' export default function FeedSwitcher({ close }: { close?: () => void }) { const { t } = useTranslation() - const { feedType, setFeedType } = useFeed() + const { feedType, switchFeed, activeRelaySetId, temporaryRelayUrls } = useFeed() const { pubkey } = useNostr() - const { relayGroups, temporaryRelayUrls, switchRelayGroup } = useRelaySettings() + const { relaySets } = useRelaySets() return (
@@ -20,14 +21,14 @@ export default function FeedSwitcher({ close }: { close?: () => void }) { itemName={t('Following')} isActive={feedType === 'following'} onClick={() => { - setFeedType('following') + switchFeed('following') close?.() }} /> )}
-
{t('relay feeds')}
+
{t('relay sets')}
void }) { itemName={ temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary') } - isActive={feedType === 'relays'} + isActive={feedType === 'temporary'} temporary onClick={() => { - setFeedType('relays') + switchFeed('temporary') close?.() }} /> )} - {relayGroups - .filter((group) => group.relayUrls.length > 0) - .map((group) => ( - { - switchRelayGroup(group.groupName) + {relaySets + .filter((set) => set.relayUrls.length > 0) + .map((set) => ( + { + if (!select) return + switchFeed('relays', { activeRelaySetId: set.id }) close?.() }} /> diff --git a/src/components/LoginDialog/index.tsx b/src/components/LoginDialog/index.tsx index 42a99a1..cd90bc4 100644 --- a/src/components/LoginDialog/index.tsx +++ b/src/components/LoginDialog/index.tsx @@ -22,12 +22,7 @@ export default function LoginDialog({ if (isSmallScreen) { return ( - +
setOpen(false)} />
diff --git a/src/components/RelaySetCard/index.tsx b/src/components/RelaySetCard/index.tsx new file mode 100644 index 0000000..7597b36 --- /dev/null +++ b/src/components/RelaySetCard/index.tsx @@ -0,0 +1,124 @@ +import client from '@/services/client.service' +import { TRelaySet } from '@/types' +import { ChevronDown, Circle, CircleCheck } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function RelaySetCard({ + relaySet, + select, + onSelectChange, + showConnectionStatus = false +}: { + relaySet: TRelaySet + select: boolean + onSelectChange: (select: boolean) => void + showConnectionStatus?: boolean +}) { + const { t } = useTranslation() + const [expand, setExpand] = useState(false) + + return ( +
+
+
onSelectChange(!select)} + > + +
{relaySet.name}
+
+
+ + {t('n relays', { n: relaySet.relayUrls.length })} + +
+
+ {expand && ( + + )} +
+ ) +} + +function RelaySetActiveToggle({ select }: { select: boolean }) { + return select ? ( + + ) : ( + + ) +} + +function RelayUrlsExpandToggle({ + children, + expand, + onExpandChange +}: { + children: React.ReactNode + expand: boolean + onExpandChange: (expand: boolean) => void +}) { + return ( +
onExpandChange(!expand)} + > +
{children}
+ +
+ ) +} + +function RelayUrls({ + showConnectionStatus = false, + urls +}: { + showConnectionStatus?: boolean + urls: string[] +}) { + const [relays, setRelays] = useState< + { + url: string + isConnected: boolean + }[] + >(urls.map((url) => ({ url, isConnected: false })) ?? []) + + useEffect(() => { + if (!showConnectionStatus || urls.length === 0) return + + 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) + }, [showConnectionStatus, urls]) + + if (!urls) return null + + return ( +
+ {relays.map(({ url, isConnected: isConnected }, index) => ( +
+ {showConnectionStatus && + (isConnected ? ( +
+ ) : ( +
+ ))} +
{url}
+
+ ))} +
+ ) +} diff --git a/src/components/RelaySetsSetting/PullFromRelaysButton.tsx b/src/components/RelaySetsSetting/PullFromRelaysButton.tsx new file mode 100644 index 0000000..2b0514f --- /dev/null +++ b/src/components/RelaySetsSetting/PullFromRelaysButton.tsx @@ -0,0 +1,168 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger +} from '@/components/ui/drawer' +import { BIG_RELAY_URLS } from '@/constants' +import { tagNameEquals } from '@/lib/tag' +import { isWebsocketUrl, simplifyUrl } from '@/lib/url' +import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import client from '@/services/client.service' +import { TRelaySet } from '@/types' +import { CloudDownload } from 'lucide-react' +import { kinds } from 'nostr-tools' +import { useEffect, useState } from 'react' +import RelaySetCard from '../RelaySetCard' +import { useRelaySets } from '@/providers/RelaySetsProvider' + +export default function PullFromRelaysButton() { + const { pubkey } = useNostr() + const { isSmallScreen } = useScreenSize() + const [open, setOpen] = useState(false) + + const trigger = ( + + ) + + if (isSmallScreen) { + return ( + + {trigger} + +
+ + Select the relay sets you want to pull + + + setOpen(false)} /> +
+
+
+ ) + } + + return ( + + {trigger} + + + Select the relay sets you want to pull + + + setOpen(false)} /> + + + ) +} + +function RemoteRelaySets({ close }: { close?: () => void }) { + const { pubkey, relayList } = useNostr() + const { mergeRelaySets } = useRelaySets() + const [initialed, setInitialed] = useState(false) + const [relaySets, setRelaySets] = useState([]) + const [selectedRelaySetIds, setSelectedRelaySetIds] = useState([]) + + useEffect(() => { + if (!pubkey) return + + const init = async () => { + setInitialed(false) + const events = await client.fetchEvents( + (relayList?.write ?? []).concat(BIG_RELAY_URLS).slice(0, 4), + { + kinds: [kinds.Relaysets], + authors: [pubkey], + limit: 50 + } + ) + setRelaySets( + events + .map((evt) => { + const id = evt.tags.find(tagNameEquals('d'))?.[1] + if (!id) return null + + const relayUrls = evt.tags + .filter(tagNameEquals('relay')) + .map((tag) => tag[1]) + .filter((url) => url && isWebsocketUrl(url)) + if (!relayUrls.length) return null + + let title = evt.tags.find(tagNameEquals('title'))?.[1] + if (!title) { + title = relayUrls.length === 1 ? simplifyUrl(relayUrls[0]) : id + } + return { id, name: title, relayUrls } + }) + .filter(Boolean) as TRelaySet[] + ) + setInitialed(true) + } + init() + }, [pubkey]) + + if (!pubkey) return null + if (!initialed) return
Loading...
+ if (!relaySets.length) { + return
No relay sets found
+ } + + return ( +
+
+ {relaySets.map((relaySet) => ( + { + if (select) { + setSelectedRelaySetIds([...selectedRelaySetIds, relaySet.id]) + } else { + setSelectedRelaySetIds(selectedRelaySetIds.filter((id) => id !== relaySet.id)) + } + }} + /> + ))} +
+
+ + +
+
+ ) +} diff --git a/src/components/RelaySetsSetting/PushToRelaysButton.tsx b/src/components/RelaySetsSetting/PushToRelaysButton.tsx new file mode 100644 index 0000000..c83e4ca --- /dev/null +++ b/src/components/RelaySetsSetting/PushToRelaysButton.tsx @@ -0,0 +1,43 @@ +import { Button } from '@/components/ui/button' +import { useToast } from '@/hooks' +import { createRelaySetDraftEvent } from '@/lib/draft-event' +import { useNostr } from '@/providers/NostrProvider' +import { useRelaySets } from '@/providers/RelaySetsProvider' +import { CloudUpload, Loader } from 'lucide-react' +import { useState } from 'react' +import { useRelaySetsSettingComponent } from './provider' + +export default function PushToRelaysButton() { + const { toast } = useToast() + const { pubkey, publish } = useNostr() + const { relaySets } = useRelaySets() + const { selectedRelaySetIds } = useRelaySetsSettingComponent() + const [pushing, setPushing] = useState(false) + + const push = async () => { + const selectedRelaySets = relaySets.filter((r) => selectedRelaySetIds.includes(r.id)) + if (!selectedRelaySets.length) return + + setPushing(true) + 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' + }) + setPushing(false) + } + + return ( + + ) +} diff --git a/src/components/RelaySetsSetting/RelaySet.tsx b/src/components/RelaySetsSetting/RelaySet.tsx new file mode 100644 index 0000000..b148cdd --- /dev/null +++ b/src/components/RelaySetsSetting/RelaySet.tsx @@ -0,0 +1,176 @@ +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { useRelaySets } from '@/providers/RelaySetsProvider' +import { TRelaySet } from '@/types' +import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import RelayUrls from './RelayUrl' +import { useRelaySetsSettingComponent } from './provider' + +export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) { + const { t } = useTranslation() + const { expandedRelaySetId, selectedRelaySetIds } = useRelaySetsSettingComponent() + const isSelected = useMemo( + () => selectedRelaySetIds.includes(relaySet.id), + [selectedRelaySetIds, relaySet.id] + ) + + return ( +
+
+
+ + +
+
+ + {t('n relays', { n: relaySet.relayUrls.length })} + + +
+
+ {expandedRelaySetId === relaySet.id && } +
+ ) +} + +function RelaySetActiveToggle({ relaySetId }: { relaySetId: string }) { + const { selectedRelaySetIds, toggleSelectedRelaySetId } = useRelaySetsSettingComponent() + const isSelected = useMemo( + () => selectedRelaySetIds.includes(relaySetId), + [selectedRelaySetIds, relaySetId] + ) + + const handleClick = () => { + toggleSelectedRelaySetId(relaySetId) + } + + return isSelected ? ( + + ) : ( + + ) +} + +function RelaySetName({ relaySet }: { relaySet: TRelaySet }) { + const [newSetName, setNewSetName] = useState(relaySet.name) + const { updateRelaySet } = useRelaySets() + const { renamingRelaySetId, setRenamingRelaySetId, toggleSelectedRelaySetId } = + useRelaySetsSettingComponent() + + const saveNewRelaySetName = () => { + if (relaySet.name === newSetName) { + return setRenamingRelaySetId(null) + } + updateRelaySet({ ...relaySet, name: newSetName }) + setRenamingRelaySetId(null) + } + + const handleRenameInputChange = (e: React.ChangeEvent) => { + setNewSetName(e.target.value) + } + + const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + saveNewRelaySetName() + } + } + + return renamingRelaySetId === relaySet.id ? ( +
+ + +
+ ) : ( +
toggleSelectedRelaySetId(relaySet.id)} + > + {relaySet.name} +
+ ) +} + +function RelayUrlsExpandToggle({ + relaySetId, + children +}: { + relaySetId: string + children: React.ReactNode +}) { + const { expandedRelaySetId, setExpandedRelaySetId } = useRelaySetsSettingComponent() + return ( +
setExpandedRelaySetId((pre) => (pre === relaySetId ? null : relaySetId))} + > +
{children}
+ +
+ ) +} + +function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) { + const { t } = useTranslation() + const { deleteRelaySet } = useRelaySets() + const { setRenamingRelaySetId } = useRelaySetsSettingComponent() + + return ( + + + + + + setRenamingRelaySetId(relaySet.id)}> + {t('Rename')} + + { + navigator.clipboard.writeText( + `https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}` + ) + }} + > + {t('Copy share link')} + + deleteRelaySet(relaySet.id)} + > + {t('Delete')} + + + + ) +} diff --git a/src/components/RelaySettings/RelayUrl.tsx b/src/components/RelaySetsSetting/RelayUrl.tsx similarity index 84% rename from src/components/RelaySettings/RelayUrl.tsx rename to src/components/RelaySetsSetting/RelayUrl.tsx index f052a52..57dd5e2 100644 --- a/src/components/RelaySettings/RelayUrl.tsx +++ b/src/components/RelaySetsSetting/RelayUrl.tsx @@ -2,31 +2,30 @@ 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 { useFeed } from '@/providers/FeedProvider' +import { useRelaySets } from '@/providers/RelaySetsProvider' 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 }) { +export default function RelayUrls({ relaySetId }: { relaySetId: string }) { const { t } = useTranslation() - const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings() - const isActive = useMemo( - () => relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false, - [relayGroups, groupName] - ) + const { relaySets, updateRelaySet } = useRelaySets() + const { activeRelaySetId } = useFeed() const [newRelayUrl, setNewRelayUrl] = useState('') const [newRelayUrlError, setNewRelayUrlError] = useState(null) + const relaySet = useMemo( + () => relaySets.find((r) => r.id === relaySetId), + [relaySets, relaySetId] + ) const [relays, setRelays] = useState< { url: string isConnected: boolean }[] - >( - relayGroups - .find((group) => group.groupName === groupName) - ?.relayUrls.map((url) => ({ url, isConnected: false })) ?? [] - ) + >(relaySet?.relayUrls.map((url) => ({ url, isConnected: false })) ?? []) + const isActive = relaySet?.id === activeRelaySetId useEffect(() => { const interval = setInterval(() => { @@ -42,12 +41,14 @@ export default function RelayUrls({ groupName }: { groupName: string }) { return () => clearInterval(interval) }, []) + if (!relaySet) return null + const removeRelayUrl = (url: string) => { setRelays((relays) => relays.filter((relay) => relay.url !== url)) - updateRelayGroupRelayUrls( - groupName, - relays.map(({ url }) => url).filter((u) => u !== url) - ) + updateRelaySet({ + ...relaySet, + relayUrls: relays.map(({ url }) => url).filter((u) => u !== url) + }) } const saveNewRelayUrl = () => { @@ -61,7 +62,7 @@ export default function RelayUrls({ groupName }: { groupName: string }) { } setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }]) const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl] - updateRelayGroupRelayUrls(groupName, newRelayUrls) + updateRelaySet({ ...relaySet, relayUrls: newRelayUrls }) setNewRelayUrl('') } diff --git a/src/components/RelaySettings/TemporaryRelayGroup.tsx b/src/components/RelaySetsSetting/TemporaryRelaySet.tsx similarity index 77% rename from src/components/RelaySettings/TemporaryRelayGroup.tsx rename to src/components/RelaySetsSetting/TemporaryRelaySet.tsx index 7cf81fc..5e03a22 100644 --- a/src/components/RelaySettings/TemporaryRelayGroup.tsx +++ b/src/components/RelaySetsSetting/TemporaryRelaySet.tsx @@ -1,14 +1,17 @@ import { Button } from '@/components/ui/button' import { useFetchRelayInfos } from '@/hooks' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { simplifyUrl } from '@/lib/url' +import { useFeed } from '@/providers/FeedProvider' +import { useRelaySets } from '@/providers/RelaySetsProvider' 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() { +export default function TemporaryRelaySet() { const { t } = useTranslation() - const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings() + const { temporaryRelayUrls, switchFeed } = useFeed() + const { addRelaySet } = useRelaySets() const [relays, setRelays] = useState< { url: string @@ -40,15 +43,10 @@ export default function TemporaryRelayGroup() { } 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) + const relaySetName = + temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : 'Temporary' + const id = addRelaySet(relaySetName, temporaryRelayUrls) + switchFeed('relays', { activeRelaySetId: id }) } return ( diff --git a/src/components/RelaySetsSetting/index.tsx b/src/components/RelaySetsSetting/index.tsx new file mode 100644 index 0000000..5b9b2ea --- /dev/null +++ b/src/components/RelaySetsSetting/index.tsx @@ -0,0 +1,76 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { useRelaySets } from '@/providers/RelaySetsProvider' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RelaySetsSettingComponentProvider } from './provider' +import RelaySet from './RelaySet' +import TemporaryRelaySet from './TemporaryRelaySet' +import PushToRelaysButton from './PushToRelaysButton' +import PullFromRelaysButton from './PullFromRelaysButton' + +export default function RelaySetsSetting() { + const { t } = useTranslation() + const { relaySets, addRelaySet } = useRelaySets() + const [newRelaySetName, setNewRelaySetName] = useState('') + const dummyRef = useRef(null) + + useEffect(() => { + if (dummyRef.current) { + dummyRef.current.focus() + } + }, []) + + const saveRelaySet = () => { + if (!newRelaySetName) return + addRelaySet(newRelaySetName) + } + + const handleNewRelaySetNameChange = (e: React.ChangeEvent) => { + setNewRelaySetName(e.target.value) + } + + const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + saveRelaySet() + } + } + + return ( + +
+
+ + +
+
+ + {relaySets.map((relaySet) => ( + + ))} +
+ {relaySets.length < 10 && ( + <> + +
+
+
{t('Add a new relay set')}
+
+
+ + +
+
+ + )} +
+ ) +} diff --git a/src/components/RelaySetsSetting/provider.tsx b/src/components/RelaySetsSetting/provider.tsx new file mode 100644 index 0000000..4a154b7 --- /dev/null +++ b/src/components/RelaySetsSetting/provider.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useState } from 'react' + +type TRelaySetsSettingComponentContext = { + renamingRelaySetId: string | null + setRenamingRelaySetId: React.Dispatch> + expandedRelaySetId: string | null + setExpandedRelaySetId: React.Dispatch> + selectedRelaySetIds: string[] + toggleSelectedRelaySetId: (relaySetId: string) => void +} + +export const RelaySetsSettingComponentContext = createContext< + TRelaySetsSettingComponentContext | undefined +>(undefined) + +export const useRelaySetsSettingComponent = () => { + const context = useContext(RelaySetsSettingComponentContext) + if (!context) { + throw new Error( + 'useRelaySetsSettingComponent must be used within a RelaySetsSettingComponentProvider' + ) + } + return context +} + +export function RelaySetsSettingComponentProvider({ children }: { children: React.ReactNode }) { + const [renamingRelaySetId, setRenamingRelaySetId] = useState(null) + const [expandedRelaySetId, setExpandedRelaySetId] = useState(null) + const [selectedRelaySetIds, setSelectedRelaySetIds] = useState([]) + + return ( + { + setSelectedRelaySetIds((pre) => { + if (pre.includes(relaySetId)) { + return pre.filter((id) => id !== relaySetId) + } + return [...pre, relaySetId] + }) + } + }} + > + {children} + + ) +} diff --git a/src/components/RelaySettings/RelayGroup.tsx b/src/components/RelaySettings/RelayGroup.tsx deleted file mode 100644 index 9a10815..0000000 --- a/src/components/RelaySettings/RelayGroup.tsx +++ /dev/null @@ -1,195 +0,0 @@ -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 ( -
-
-
- 0} - /> - -
-
- - {t('n relays', { n: relayUrls.length })} - - -
-
- {expandedRelayGroup === groupName && } -
- ) -} - -function RelayGroupActiveToggle({ - groupName, - isActive, - canActive -}: { - groupName: string - isActive: boolean - canActive: boolean -}) { - const { switchRelayGroup } = useRelaySettings() - - return isActive ? ( - - ) : ( - { - if (canActive) { - switchRelayGroup(groupName) - } - }} - /> - ) -} - -function RelayGroupName({ groupName }: { groupName: string }) { - const { t } = useTranslation() - const [newGroupName, setNewGroupName] = useState(groupName) - const [newNameError, setNewNameError] = useState(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) => { - setNewGroupName(e.target.value) - setNewNameError(null) - } - - const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault() - saveNewGroupName() - } - } - - return renamingGroup === groupName ? ( -
- - - {newNameError &&
{newNameError}
} -
- ) : ( -
{ - if (hasRelayUrls) { - switchRelayGroup(groupName) - } - }} - > - {groupName} -
- ) -} - -function RelayUrlsExpandToggle({ - groupName, - children -}: { - groupName: string - children: React.ReactNode -}) { - const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent() - return ( -
setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))} - > -
{children}
- -
- ) -} - -function RelayGroupOptions({ group }: { group: TRelayGroup }) { - const { t } = useTranslation() - const { deleteRelayGroup } = useRelaySettings() - const { setRenamingGroup } = useRelaySettingsComponent() - - return ( - - - - - - setRenamingGroup(group.groupName)}> - {t('Rename')} - - { - navigator.clipboard.writeText( - `https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}` - ) - }} - > - {t('Copy share link')} - - deleteRelayGroup(group.groupName)} - > - {t('Delete')} - - - - ) -} diff --git a/src/components/RelaySettings/index.tsx b/src/components/RelaySettings/index.tsx deleted file mode 100644 index 0f0ba4a..0000000 --- a/src/components/RelaySettings/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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(null) - const dummyRef = useRef(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) => { - setNewGroupName(e.target.value) - setNewNameError(null) - } - - const handleNewGroupNameKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault() - saveRelayGroup() - } - } - - return ( - -
- {!hideTitle &&
{t('Relay Settings')}
} -
- - {relayGroups.map((group, index) => ( - - ))} -
- {relayGroups.length < 10 && ( - <> - -
-
-
{t('Add a new relay collection')}
-
-
- - -
- {newNameError &&
{newNameError}
} -
- - )} -
- ) -} diff --git a/src/components/RelaySettings/provider.tsx b/src/components/RelaySettings/provider.tsx deleted file mode 100644 index 81346df..0000000 --- a/src/components/RelaySettings/provider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { createContext, useContext, useState } from 'react' - -type TRelaySettingsComponentContext = { - renamingGroup: string | null - setRenamingGroup: React.Dispatch> - expandedRelayGroup: string | null - setExpandedRelayGroup: React.Dispatch> -} - -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(null) - const [expandedRelayGroup, setExpandedRelayGroup] = useState(null) - - return ( - - {children} - - ) -} diff --git a/src/components/RelaySettings/types.ts b/src/components/RelaySettings/types.ts deleted file mode 100644 index 41fd389..0000000 --- a/src/components/RelaySettings/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type TRelayGroup = { - groupName: string - relayUrls: string[] - isActive: boolean -} diff --git a/src/components/SearchDialog/index.tsx b/src/components/SearchDialog/index.tsx index 1d8c93c..a833e3f 100644 --- a/src/components/SearchDialog/index.tsx +++ b/src/components/SearchDialog/index.tsx @@ -4,7 +4,6 @@ import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/compone 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' @@ -83,12 +82,6 @@ export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispat } function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { - const { searchableRelayUrls } = useRelaySettings() - - if (searchableRelayUrls.length === 0) { - return null - } - return ( diff --git a/src/constants.ts b/src/constants.ts index 65c945a..3bb1820 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ export const StorageKey = { THEME_SETTING: 'themeSetting', - RELAY_GROUPS: 'relayGroups', + RELAY_SETS: 'relaySets', + ACTIVE_RELAY_SET_ID: 'activeRelaySetId', ACCOUNTS: 'accounts', CURRENT_ACCOUNT: 'currentAccount', ADD_CLIENT_TAG: 'addClientTag' diff --git a/src/hooks/useFetchFollowings.tsx b/src/hooks/useFetchFollowings.tsx index 0a6f6ce..a3fc0be 100644 --- a/src/hooks/useFetchFollowings.tsx +++ b/src/hooks/useFetchFollowings.tsx @@ -1,4 +1,4 @@ -import { tagNameEquals } from '@/lib/tag' +import { getFollowingsFromFollowListEvent } from '@/lib/event' import client from '@/services/client.service' import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' @@ -18,13 +18,7 @@ export function useFetchFollowings(pubkey?: string | null) { if (!event) return setFollowListEvent(event) - setFollowings( - event.tags - .filter(tagNameEquals('p')) - .map(([, pubkey]) => pubkey) - .filter(Boolean) - .reverse() - ) + setFollowings(getFollowingsFromFollowListEvent(event)) } finally { setIsFetching(false) } diff --git a/src/hooks/useFetchRelayInfos.tsx b/src/hooks/useFetchRelayInfos.tsx index f7cc43f..bc9d61d 100644 --- a/src/hooks/useFetchRelayInfos.tsx +++ b/src/hooks/useFetchRelayInfos.tsx @@ -7,6 +7,7 @@ export function useFetchRelayInfos(urls: string[]) { const [isFetching, setIsFetching] = useState(true) const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([]) const [areAlgoRelays, setAreAlgoRelays] = useState(false) + const [searchableRelayUrls, setSearchableRelayUrls] = useState([]) const urlsString = JSON.stringify(urls) useEffect(() => { @@ -22,6 +23,15 @@ export function useFetchRelayInfos(urls: string[]) { const relayInfos = await client.fetchRelayInfos(urls) setRelayInfos(relayInfos) setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) + setSearchableRelayUrls( + relayInfos + .map((relayInfo, index) => ({ + url: urls[index], + searchable: relayInfo?.supported_nips?.includes(50) + })) + .filter((relayInfo) => relayInfo.searchable) + .map((relayInfo) => relayInfo.url) + ) } catch (err) { console.error(err) } finally { @@ -33,5 +43,5 @@ export function useFetchRelayInfos(urls: string[]) { fetchRelayInfos() }, [urlsString]) - return { relayInfos, isFetching, areAlgoRelays } + return { relayInfos, isFetching, areAlgoRelays, searchableRelayUrls } } diff --git a/src/hooks/useSearchProfiles.tsx b/src/hooks/useSearchProfiles.tsx index 4bf2170..5a5cc1c 100644 --- a/src/hooks/useSearchProfiles.tsx +++ b/src/hooks/useSearchProfiles.tsx @@ -1,10 +1,12 @@ -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { useFeed } from '@/providers/FeedProvider' import client from '@/services/client.service' import { TProfile } from '@/types' import { useEffect, useState } from 'react' +import { useFetchRelayInfos } from './useFetchRelayInfos' export function useSearchProfiles(search: string, limit: number) { - const { searchableRelayUrls } = useRelaySettings() + const { relayUrls } = useFeed() + const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const [isFetching, setIsFetching] = useState(true) const [error, setError] = useState(null) const [profiles, setProfiles] = useState([]) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3934516..efc2c25 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -50,10 +50,9 @@ export default { "username's following": "{{username}}'s following", Login: 'Login', 'Follows you': 'Follows you', - 'relay collection name already exists': 'relay collection name already exists', 'Relay Settings': 'Relay Settings', - 'Relay collection name': 'Relay collection name', - 'Add a new relay collection': 'Add a new relay collection', + 'Relay set name': 'Relay set name', + 'Add a new relay set': 'Add a new relay set', Add: 'Add', 'n relays': '{{n}} relays', Rename: 'Rename', @@ -90,7 +89,7 @@ export default { 'Add client tag': 'Add client tag', 'Show others this was sent via Jumble': 'Show others this was sent via Jumble', 'Are you sure you want to logout?': 'Are you sure you want to logout?', - 'relay feeds': 'relay feeds', + 'relay sets': 'relay sets', edit: 'edit', Languages: 'Languages', Theme: 'Theme', @@ -98,7 +97,7 @@ export default { Light: 'Light', Dark: 'Dark', Temporary: 'Temporary', - 'Choose a relay collection': 'Choose a relay collection', + 'Choose a relay set': 'Choose a relay set', 'Switch account': 'Switch account', Pictures: 'Pictures', 'Picture note': 'Picture note', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c26b50e..7bc2da7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -50,10 +50,9 @@ export default { "username's following": '{{username}} 的关注', Login: '登录', 'Follows you': '关注了你', - 'relay collection name already exists': '服务器组名已存在', 'Relay Settings': '服务器设置', - 'Relay collection name': '服务器组名', - 'Add a new relay collection': '添加新的服务器组', + 'Relay set name': '服务器组名', + 'Add a new relay set': '添加新的服务器组', Add: '添加', 'n relays': '{{n}} 个服务器', Rename: '重命名', @@ -89,7 +88,7 @@ export default { 'Add client tag': '添加客户端标签', 'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的', 'Are you sure you want to logout?': '确定要退出登录吗?', - 'relay feeds': '服务器信息流', + 'relay sets': '服务器组', edit: '编辑', Languages: '语言', Theme: '主题', @@ -97,7 +96,7 @@ export default { Light: '浅色', Dark: '深色', Temporary: '临时', - 'Choose a relay collection': '选择一个服务器组', + 'Choose a relay set': '选择一个服务器组', 'Switch account': '切换账户', Pictures: '图片', 'Picture note': '图片笔记', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index fefe220..a6b927c 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1,5 +1,5 @@ import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' -import { TDraftEvent } from '@/types' +import { TDraftEvent, TRelaySet } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { @@ -82,6 +82,20 @@ export async function createShortTextNoteDraftEvent( } } +// https://github.com/nostr-protocol/nips/blob/master/51.md +export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent { + return { + kind: kinds.Relaysets, + content: '', + tags: [ + ['d', relaySet.id], + ['title', relaySet.name], + ...relaySet.relayUrls.map((url) => ['relay', url]) + ], + created_at: dayjs().unix() + } +} + export async function createPictureNoteDraftEvent( content: string, options: { diff --git a/src/lib/event.ts b/src/lib/event.ts index 523fcf8..23059fc 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -64,6 +64,18 @@ export function getUsingClient(event: Event) { return event.tags.find(tagNameEquals('client'))?.[1] } +export function getFollowingsFromFollowListEvent(event: Event) { + return Array.from( + new Set( + event.tags + .filter(tagNameEquals('p')) + .map(([, pubkey]) => pubkey) + .filter(Boolean) + .reverse() + ) + ) +} + export async function extractMentions(content: string, parentEvent?: Event) { const pubkeySet = new Set() const relatedEventIdSet = new Set() diff --git a/src/lib/random.ts b/src/lib/random.ts new file mode 100644 index 0000000..a8f76f6 --- /dev/null +++ b/src/lib/random.ts @@ -0,0 +1,9 @@ +const SEED = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +export function randomString(len = 32) { + let str = '' + for (let i = 0; i < len; i++) { + str += SEED[Math.floor(Math.random() * SEED.length)] + } + return str +} diff --git a/src/pages/primary/NoteListPage/FeedButton.tsx b/src/pages/primary/NoteListPage/FeedButton.tsx index 1f63bca..6ae32a4 100644 --- a/src/pages/primary/NoteListPage/FeedButton.tsx +++ b/src/pages/primary/NoteListPage/FeedButton.tsx @@ -3,7 +3,7 @@ import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { simplifyUrl } from '@/lib/url' import { useFeed } from '@/providers/FeedProvider' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { useRelaySets } from '@/providers/RelaySetsProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { ChevronDown, Server, UsersRound } from 'lucide-react' import { forwardRef, HTMLAttributes, useState } from 'react' @@ -43,21 +43,21 @@ export default function FeedButton() { const FeedSwitcherTrigger = forwardRef>( (props, ref) => { const { t } = useTranslation() - const { feedType } = useFeed() - const { relayGroups, temporaryRelayUrls } = useRelaySettings() - const activeGroup = relayGroups.find((group) => group.isActive) + const { feedType, relayUrls, activeRelaySetId } = useFeed() + const { relaySets } = useRelaySets() + const activeRelaySet = activeRelaySetId + ? relaySets.find((set) => set.id === activeRelaySetId) + : undefined const title = feedType === 'following' ? t('Following') - : temporaryRelayUrls.length > 0 - ? temporaryRelayUrls.length === 1 - ? simplifyUrl(temporaryRelayUrls[0]) - : t('Temporary') - : activeGroup - ? activeGroup.relayUrls.length === 1 - ? simplifyUrl(activeGroup.relayUrls[0]) - : activeGroup.groupName - : t('Choose a relay collection') + : relayUrls.length > 0 + ? relayUrls.length === 1 + ? simplifyUrl(relayUrls[0]) + : activeRelaySet + ? activeRelaySet.name + : t('Temporary') + : t('Choose a relay set') return (
void }>(null) - const { feedType } = useFeed() - const { relayUrls, temporaryRelayUrls } = useRelaySettings() - const { pubkey, relayList, followings } = useNostr() - const urls = useMemo(() => { - return feedType === 'following' - ? relayList?.read.length - ? relayList.read.slice(0, 4) - : BIG_RELAY_URLS - : temporaryRelayUrls.length > 0 - ? temporaryRelayUrls - : relayUrls - }, [feedType, relayUrls, relayList, temporaryRelayUrls]) + const { feedType, relayUrls, isReady, filter } = useFeed() useEffect(() => { if (layoutRef.current) { @@ -38,20 +24,8 @@ export default function NoteListPage() { titlebar={} displayScrollToTopButton > - {!!urls.length && (feedType === 'relays' || (relayList && followings)) ? ( - + {isReady ? ( + ) : (
{t('loading...')}
)} diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 807b635..742bc58 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -1,16 +1,17 @@ import NoteList from '@/components/NoteList' import { SEARCHABLE_RELAY_URLS } from '@/constants' -import { useSearchParams } from '@/hooks' +import { useFetchRelayInfos, useSearchParams } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { isWebsocketUrl, simplifyUrl } from '@/lib/url' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { useFeed } from '@/providers/FeedProvider' import { Filter } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' export default function NoteListPage({ index }: { index?: number }) { const { t } = useTranslation() - const { relayUrls, searchableRelayUrls } = useRelaySettings() + const { relayUrls } = useFeed() + const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const { searchParams } = useSearchParams() const relayUrlsString = JSON.stringify(relayUrls) const { @@ -31,10 +32,7 @@ export default function NoteListPage({ index }: { index?: number }) { return { title: `${t('Search')}: ${search}`, filter: { search }, - urls: - searchableRelayUrls.length < 4 - ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) - : searchableRelayUrls + urls: searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) } } const relayUrl = searchParams.get('relay') @@ -44,16 +42,6 @@ export default function NoteListPage({ index }: { index?: number }) { return { urls: relayUrls } }, [searchParams, relayUrlsString]) - if (filter?.search && searchableRelayUrls.length === 0) { - return ( - -
- {t('The relays you are connected to do not support search')} -
-
- ) - } - return ( diff --git a/src/pages/secondary/ProfileListPage/index.tsx b/src/pages/secondary/ProfileListPage/index.tsx index 1ce0906..d15eea6 100644 --- a/src/pages/secondary/ProfileListPage/index.tsx +++ b/src/pages/secondary/ProfileListPage/index.tsx @@ -1,7 +1,8 @@ import UserItem from '@/components/UserItem' -import { useSearchParams } from '@/hooks' +import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { useFetchRelayInfos, useSearchParams } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' +import { useFeed } from '@/providers/FeedProvider' import client from '@/services/client.service' import dayjs from 'dayjs' import { Filter } from 'nostr-tools' @@ -13,7 +14,8 @@ const LIMIT = 50 export default function ProfileListPage({ index }: { index?: number }) { const { t } = useTranslation() const { searchParams } = useSearchParams() - const { relayUrls, searchableRelayUrls } = useRelaySettings() + const { relayUrls } = useFeed() + const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const [until, setUntil] = useState(() => dayjs().unix()) const [hasMore, setHasMore] = useState(true) const [pubkeySet, setPubkeySet] = useState(new Set()) @@ -27,7 +29,7 @@ export default function ProfileListPage({ index }: { index?: number }) { return f }, [searchParams, until]) const urls = useMemo(() => { - return filter.search ? searchableRelayUrls : relayUrls + return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls }, [relayUrls, searchableRelayUrls, filter]) const title = useMemo(() => { return filter.search ? `${t('Search')}: ${filter.search}` : t('All users') diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index a3e732d..1d98de4 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -13,9 +13,9 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toFollowingList } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' import { SecondaryPageLink } from '@/PageManager' +import { useFeed } from '@/providers/FeedProvider' import { useFollowList } from '@/providers/FollowListProvider' import { useNostr } from '@/providers/NostrProvider' -import { useRelaySettings } from '@/providers/RelaySettingsProvider' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' @@ -24,9 +24,12 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number const { t } = useTranslation() const { profile, isFetching } = useFetchProfile(id) const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey) - const { relayUrls: currentRelayUrls } = useRelaySettings() + const { relayUrls: currentRelayUrls } = useFeed() const relayUrls = useMemo( - () => relayList.write.slice(0, 4).concat(currentRelayUrls.slice(0, 1)), + () => + relayList.write.length < 4 + ? relayList.write.concat(currentRelayUrls).slice(0, 4) + : relayList.write.slice(0, 4), [relayList, currentRelayUrls] ) const { pubkey: accountPubkey } = useNostr() diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 9aa5421..3d35eb3 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,4 +1,4 @@ -import RelaySettings from '@/components/RelaySettings' +import RelaySetsSetting from '@/components/RelaySetsSetting' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useTranslation } from 'react-i18next' @@ -8,7 +8,7 @@ export default function RelaySettingsPage({ index }: { index?: number }) { return (
- +
) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 5eb955c..b2b5753 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,9 +1,20 @@ +import { isWebsocketUrl, normalizeUrl } from '@/lib/url' +import client from '@/services/client.service' +import storage from '@/services/storage.service' import { TFeedType } from '@/types' -import { createContext, useContext, useState } from 'react' +import { Filter } from 'nostr-tools' +import { createContext, useContext, useEffect, useState } from 'react' +import { useNostr } from './NostrProvider' +import { useRelaySets } from './RelaySetsProvider' type TFeedContext = { feedType: TFeedType - setFeedType: (feedType: TFeedType) => void + relayUrls: string[] + temporaryRelayUrls: string[] + filter: Filter + isReady: boolean + activeRelaySetId: string | null + switchFeed: (feedType: TFeedType, options?: { activeRelaySetId?: string }) => Promise } const FeedContext = createContext(undefined) @@ -17,7 +28,113 @@ export const useFeed = () => { } export function FeedProvider({ children }: { children: React.ReactNode }) { + const { pubkey } = useNostr() + const { relaySets } = useRelaySets() const [feedType, setFeedType] = useState('relays') + const [relayUrls, setRelayUrls] = useState([]) + const [temporaryRelayUrls, setTemporaryRelayUrls] = useState([]) + const [filter, setFilter] = useState({}) + const [isReady, setIsReady] = useState(false) + const [activeRelaySetId, setActiveRelaySetId] = useState(() => + storage.getActiveRelaySetId() + ) - return {children} + useEffect(() => { + const init = async () => { + // temporary relay urls from query params + const searchParams = new URLSearchParams(window.location.search) + const tempRelays = searchParams + .getAll('r') + .map((url) => + !url.startsWith('ws://') && !url.startsWith('wss://') ? `wss://${url}` : url + ) + .filter((url) => isWebsocketUrl(url)) + .map((url) => normalizeUrl(url)) + if (tempRelays.length) { + setTemporaryRelayUrls(tempRelays) + return await switchFeed('temporary') + } + + await switchFeed('relays', { activeRelaySetId }) + } + + init() + }, []) + + useEffect(() => { + if (feedType !== 'following') return + + switchFeed('following') + }, [pubkey]) + + useEffect(() => { + if (feedType !== 'relays') return + + const relaySet = relaySets.find((set) => set.id === activeRelaySetId) + if (!relaySet) return + + setRelayUrls(relaySet.relayUrls) + }, [relaySets]) + + const switchFeed = async ( + feedType: TFeedType, + options: { activeRelaySetId?: string | null } = {} + ) => { + setIsReady(false) + if (feedType === 'relays') { + const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null) + if (!relaySetId) return + + const relaySet = + relaySets.find((set) => set.id === options.activeRelaySetId) ?? + (relaySets.length > 0 ? relaySets[0] : null) + if (relaySet) { + setFeedType(feedType) + setRelayUrls(relaySet.relayUrls) + setActiveRelaySetId(relaySet.id) + setFilter({}) + setIsReady(true) + storage.setActiveRelaySetId(relaySet.id) + } + return + } + if (feedType === 'following') { + if (!pubkey) return + setFeedType(feedType) + setActiveRelaySetId(null) + const [relayList, followings] = await Promise.all([ + client.fetchRelayList(pubkey), + client.fetchFollowings(pubkey) + ]) + setRelayUrls(relayList.read.slice(0, 4)) + setFilter({ authors: followings.includes(pubkey) ? followings : [...followings, pubkey] }) + setIsReady(true) + return + } + if (feedType === 'temporary') { + setFeedType(feedType) + setRelayUrls(temporaryRelayUrls) + setActiveRelaySetId(null) + setFilter({}) + setIsReady(true) + return + } + setIsReady(true) + } + + return ( + + {children} + + ) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 0eaed72..90cb9e5 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -7,7 +7,6 @@ import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/t import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' -import { useRelaySettings } from '../RelaySettingsProvider' import { BunkerSigner } from './bunker.signer' import { Nip07Signer } from './nip-07.signer' import { NsecSigner } from './nsec.signer' @@ -47,7 +46,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [account, setAccount] = useState(null) const [signer, setSigner] = useState(null) const [openLoginDialog, setOpenLoginDialog] = useState(false) - const { relayUrls: currentRelayUrls } = useRelaySettings() const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey) const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey) @@ -196,10 +194,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { const event = await signEvent(draftEvent) - await client.publishEvent( - relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls), - event - ) + await client.publishEvent(relayList.write.concat(additionalRelayUrls), event) return event } diff --git a/src/providers/RelaySetsProvider.tsx b/src/providers/RelaySetsProvider.tsx new file mode 100644 index 0000000..48718da --- /dev/null +++ b/src/providers/RelaySetsProvider.tsx @@ -0,0 +1,80 @@ +import { randomString } from '@/lib/random' +import { isWebsocketUrl, normalizeUrl } from '@/lib/url' +import storage from '@/services/storage.service' +import { TRelaySet } from '@/types' +import { createContext, useContext, useEffect, useState } from 'react' + +type TRelaySetsContext = { + relaySets: TRelaySet[] + addRelaySet: (relaySetName: string, relayUrls?: string[]) => string + deleteRelaySet: (id: string) => void + updateRelaySet: (newSet: TRelaySet) => void + mergeRelaySets: (newSets: TRelaySet[]) => void +} + +const RelaySetsContext = createContext(undefined) + +export const useRelaySets = () => { + const context = useContext(RelaySetsContext) + if (!context) { + throw new Error('useRelaySets must be used within a RelaySetsProvider') + } + return context +} + +export function RelaySetsProvider({ children }: { children: React.ReactNode }) { + const [relaySets, setRelaySets] = useState(() => storage.getRelaySets()) + + useEffect(() => { + storage.setRelaySets(relaySets) + }, [relaySets]) + + const deleteRelaySet = (id: string) => { + setRelaySets((pre) => pre.filter((set) => set.id !== id)) + } + + const updateRelaySet = (newSet: TRelaySet) => { + setRelaySets((pre) => { + return pre.map((set) => (set.id === newSet.id ? newSet : set)) + }) + } + + const addRelaySet = (relaySetName: string, relayUrls: string[] = []) => { + const normalizedUrls = relayUrls + .filter((url) => isWebsocketUrl(url)) + .map((url) => normalizeUrl(url)) + const id = randomString() + setRelaySets((pre) => { + return [ + ...pre, + { + id, + name: relaySetName, + relayUrls: normalizedUrls + } + ] + }) + return id + } + + const mergeRelaySets = (newSets: TRelaySet[]) => { + setRelaySets((pre) => { + const newIds = newSets.map((set) => set.id) + return pre.filter((set) => !newIds.includes(set.id)).concat(newSets) + }) + } + + return ( + + {children} + + ) +} diff --git a/src/providers/RelaySettingsProvider.tsx b/src/providers/RelaySettingsProvider.tsx deleted file mode 100644 index 7b5fbd4..0000000 --- a/src/providers/RelaySettingsProvider.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { SEARCHABLE_RELAY_URLS } from '@/constants' -import { checkAlgoRelay, checkSearchRelay } from '@/lib/relay' -import { isWebsocketUrl, normalizeUrl } from '@/lib/url' -import client from '@/services/client.service' -import storage from '@/services/storage.service' -import { TRelayGroup } from '@/types' -import { createContext, Dispatch, useContext, useEffect, useState } from 'react' -import { useFeed } from './FeedProvider' - -type TRelaySettingsContext = { - relayGroups: TRelayGroup[] - temporaryRelayUrls: string[] - relayUrls: string[] - searchableRelayUrls: string[] - areAlgoRelays: boolean - switchRelayGroup: (groupName: string) => void - renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null - deleteRelayGroup: (groupName: string) => void - addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null - updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void - setTemporaryRelayUrls: Dispatch -} - -const RelaySettingsContext = createContext(undefined) - -export const useRelaySettings = () => { - const context = useContext(RelaySettingsContext) - if (!context) { - throw new Error('useRelaySettings must be used within a RelaySettingsProvider') - } - return context -} - -export function RelaySettingsProvider({ children }: { children: React.ReactNode }) { - const { setFeedType } = useFeed() - const [relayGroups, setRelayGroups] = useState([]) - const [temporaryRelayUrls, setTemporaryRelayUrls] = useState([]) - const [relayUrls, setRelayUrls] = useState( - temporaryRelayUrls.length - ? temporaryRelayUrls - : (relayGroups.find((group) => group.isActive)?.relayUrls ?? []) - ) - const [searchableRelayUrls, setSearchableRelayUrls] = useState(SEARCHABLE_RELAY_URLS) - const [areAlgoRelays, setAreAlgoRelays] = useState(false) - - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search) - const tempRelays = searchParams - .getAll('r') - .map((url) => (url.startsWith('wss://') || url.startsWith('ws://') ? url : `wss://${url}`)) - .filter((url) => isWebsocketUrl(url)) - .map((url) => normalizeUrl(url)) - if (tempRelays.length) { - setTemporaryRelayUrls(tempRelays) - setFeedType('relays') - } - const storedGroups = storage.getRelayGroups() - setRelayGroups(storedGroups) - }, []) - - useEffect(() => { - const handler = async () => { - const newRelayUrls = temporaryRelayUrls.length - ? temporaryRelayUrls - : (relayGroups.find((group) => group.isActive)?.relayUrls ?? []) - - if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) { - setRelayUrls(newRelayUrls) - } - const relayInfos = await client.fetchRelayInfos(newRelayUrls) - const searchableRelayUrls = newRelayUrls.filter((_, index) => - checkSearchRelay(relayInfos[index]) - ) - setSearchableRelayUrls( - searchableRelayUrls.length ? searchableRelayUrls : SEARCHABLE_RELAY_URLS - ) - const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index])) - setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0) - client.setCurrentRelayUrls(nonAlgoRelayUrls) - } - handler() - }, [relayGroups, temporaryRelayUrls, relayUrls]) - - const updateGroups = (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => { - let newGroups = relayGroups - setRelayGroups((pre) => { - newGroups = fn(pre) - return newGroups - }) - storage.setRelayGroups(newGroups) - } - - const switchRelayGroup = (groupName: string) => { - updateGroups((pre) => - pre.map((group) => ({ - ...group, - isActive: group.groupName === groupName - })) - ) - setFeedType('relays') - setTemporaryRelayUrls([]) - } - - const deleteRelayGroup = (groupName: string) => { - updateGroups((pre) => pre.filter((group) => group.groupName !== groupName)) - } - - const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => { - updateGroups((pre) => - pre.map((group) => ({ - ...group, - relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls - })) - ) - } - - const renameRelayGroup = (oldGroupName: string, newGroupName: string) => { - if (newGroupName === '') { - return null - } - if (oldGroupName === newGroupName) { - return null - } - updateGroups((pre) => { - if (pre.some((group) => group.groupName === newGroupName)) { - return pre - } - return pre.map((group) => ({ - ...group, - groupName: group.groupName === oldGroupName ? newGroupName : group.groupName - })) - }) - return null - } - - const addRelayGroup = (groupName: string, relayUrls: string[] = []) => { - if (groupName === '') { - return null - } - const normalizedUrls = relayUrls - .filter((url) => isWebsocketUrl(url)) - .map((url) => normalizeUrl(url)) - updateGroups((pre) => { - if (pre.some((group) => group.groupName === groupName)) { - return pre - } - return [ - ...pre, - { - groupName, - relayUrls: normalizedUrls, - isActive: false - } - ] - }) - return null - } - - return ( - - {children} - - ) -} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9355db4..0b48dcb 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,5 @@ import { BIG_RELAY_URLS } from '@/constants' +import { getFollowingsFromFollowListEvent } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' import { tagNameEquals } from '@/lib/tag' import { isWebsocketUrl, normalizeUrl } from '@/lib/url' @@ -100,7 +101,9 @@ class ClientService extends EventTarget { } async publishEvent(relayUrls: string[], event: NEvent) { - const result = await Promise.any(this.pool.publish(relayUrls, event)) + const result = await Promise.any( + this.pool.publish(relayUrls.concat(this.defaultRelayUrls), event) + ) this.dispatchEvent(new CustomEvent('eventPublished', { detail: event })) return result } @@ -417,6 +420,11 @@ class ClientService extends EventTarget { return this.followListCache.fetch(pubkey) } + async fetchFollowings(pubkey: string) { + const followListEvent = await this.fetchFollowListEvent(pubkey) + return followListEvent ? getFollowingsFromFollowListEvent(followListEvent) : [] + } + updateFollowListCache(pubkey: string, event: NEvent) { this.followListCache.set(pubkey, Promise.resolve(event)) } diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index a1ddfc3..e951ef9 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -1,19 +1,26 @@ import { StorageKey } from '@/constants' import { isSameAccount } from '@/lib/account' -import { TAccount, TRelayGroup, TAccountPointer, TThemeSetting } from '@/types' +import { randomString } from '@/lib/random' +import { TAccount, TAccountPointer, TRelaySet, TThemeSetting } from '@/types' -const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [ +const DEFAULT_RELAY_SETS: TRelaySet[] = [ { - groupName: 'Global', - relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/'], - isActive: true + id: randomString(), + name: 'Global', + relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/'] + }, + { + id: randomString(), + name: 'Algo', + relayUrls: ['wss://algo.utxo.one'] } ] class StorageService { static instance: StorageService - private relayGroups: TRelayGroup[] = [] + private relaySets: TRelaySet[] = [] + private activeRelaySetId: string | null = null private themeSetting: TThemeSetting = 'system' private accounts: TAccount[] = [] private currentAccount: TAccount | null = null @@ -27,23 +34,64 @@ class StorageService { } init() { - const relayGroupsStr = window.localStorage.getItem(StorageKey.RELAY_GROUPS) - this.relayGroups = relayGroupsStr ? JSON.parse(relayGroupsStr) : DEFAULT_RELAY_GROUPS this.themeSetting = (window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system' const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS) this.accounts = accountsStr ? JSON.parse(accountsStr) : [] const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT) this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null + + const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS) + if (!relaySetsStr) { + let relaySets: TRelaySet[] = [] + const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups') + if (legacyRelayGroupsStr) { + const legacyRelayGroups = JSON.parse(legacyRelayGroupsStr) + relaySets = legacyRelayGroups.map((group: any) => { + return { + id: randomString(), + name: group.groupName, + relayUrls: group.relayUrls + } + }) + } + if (!relaySets.length) { + relaySets = DEFAULT_RELAY_SETS + } + const activeRelaySetId = relaySets[0].id + window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(relaySets)) + window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, activeRelaySetId) + this.relaySets = relaySets + this.activeRelaySetId = activeRelaySetId + } else { + this.relaySets = JSON.parse(relaySetsStr) + this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null + } + } + + getRelaySets() { + return this.relaySets + } + + setRelaySets(relaySets: TRelaySet[]) { + this.relaySets = relaySets + window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets)) } - getRelayGroups() { - return this.relayGroups + getActiveRelaySetId() { + return this.activeRelaySetId } - setRelayGroups(relayGroups: TRelayGroup[]) { - window.localStorage.setItem(StorageKey.RELAY_GROUPS, JSON.stringify(relayGroups)) - this.relayGroups = relayGroups + setActiveRelaySetId(id: string | null) { + this.activeRelaySetId = id + if (id) { + window.localStorage.setItem( + StorageKey.ACTIVE_RELAY_SET_ID, + JSON.stringify(this.activeRelaySetId) + ) + } else { + window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID) + } } getThemeSetting() { diff --git a/src/types.ts b/src/types.ts index dc2bf8c..7c7d169 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,14 +26,14 @@ export type TWebMetadata = { image?: string | null } -export type TRelayGroup = { - groupName: string +export type TRelaySet = { + id: string + name: string relayUrls: string[] - isActive: boolean } export type TConfig = { - relayGroups: TRelayGroup[] + relayGroups: TRelaySet[] theme: TThemeSetting } @@ -64,6 +64,6 @@ export type TAccount = { export type TAccountPointer = Pick -export type TFeedType = 'following' | 'relays' +export type TFeedType = 'following' | 'relays' | 'temporary' export type TLanguage = 'en' | 'zh'