diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index 796ec619b..94c6d8948 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -29,13 +29,7 @@ export class NostrSubscription { this.relays = relayUrls.map((url) => relayPool.requestRelay(url)); } - handleOpen(relay: Relay) { - if (this.query) { - // when the relay connects send the req event - relay.send(["REQ", this.id, this.query]); - } - } - handleEvent(event: IncomingEvent) { + private handleEvent(event: IncomingEvent) { if (this.state === NostrSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) { this.onEvent.next(event.body); this.seenEvents.add(event.body.id); @@ -47,12 +41,13 @@ export class NostrSubscription { } } - cleanup: SubscriptionLike[] = []; + private cleanup = new Map(); /** listen for event and open events from relays */ private subscribeToRelays() { for (const relay of this.relays) { - this.cleanup.push(relay.onEvent.subscribe(this.handleEvent.bind(this))); - this.cleanup.push(relay.onOpen.subscribe(this.handleOpen.bind(this))); + if (!this.cleanup.has(relay)) { + this.cleanup.set(relay, relay.onEvent.subscribe(this.handleEvent.bind(this))); + } } for (const url of this.relayUrls) { @@ -60,8 +55,9 @@ export class NostrSubscription { } } /** listen for event and open events from relays */ - private unsubscribeToRelays() { + private unsubscribeFromRelays() { this.cleanup.forEach((sub) => sub.unsubscribe()); + this.cleanup.clear(); for (const url of this.relayUrls) { relayPool.removeClaim(url, this); @@ -83,7 +79,7 @@ export class NostrSubscription { return this; } - update(query: NostrQuery) { + setQuery(query: NostrQuery) { this.query = query; if (this.state === NostrSubscription.OPEN) { this.send(["REQ", this.id, this.query]); @@ -91,13 +87,35 @@ export class NostrSubscription { return this; } setRelays(relays: string[]) { - this.unsubscribeToRelays(); + this.unsubscribeFromRelays(); + const newRelays = relays.map((url) => relayPool.requestRelay(url)); - // get new relays + for (const relay of this.relays) { + if (!newRelays.includes(relay)) { + // if the subscription is open and the relay is connected + if (this.state === NostrSubscription.OPEN && relay.connected) { + // close the connection to this relay + relay.send(["CLOSE", this.id]); + } + } + } + for (const relay of newRelays) { + if (!this.relays.includes(relay)) { + // if the subscription is open and it has a query + if (this.state === NostrSubscription.OPEN && this.query) { + // open a connection to this relay + relay.send(["REQ", this.id, this.query]); + } + } + } + + // set new relays this.relayUrls = relays; - this.relays = relays.map((url) => relayPool.requestRelay(url)); + this.relays = newRelays; - this.subscribeToRelays(); + if (this.state === NostrSubscription.OPEN) { + this.subscribeToRelays(); + } } close() { if (this.state !== NostrSubscription.OPEN) return this; @@ -109,7 +127,7 @@ export class NostrSubscription { // forget all seen events this.seenEvents.clear(); // unsubscribe from relay messages - this.unsubscribeToRelays(); + this.unsubscribeFromRelays(); if (import.meta.env.DEV) { console.info(`Subscription: "${this.name || this.id}" closed`); @@ -117,4 +135,8 @@ export class NostrSubscription { return this; } + forgetEvents() { + // forget all seen events + this.seenEvents.clear(); + } } diff --git a/src/classes/thread-loader.ts b/src/classes/thread-loader.ts index 92e8144a1..62a1cafa2 100644 --- a/src/classes/thread-loader.ts +++ b/src/classes/thread-loader.ts @@ -68,7 +68,7 @@ export class ThreadLoader { private updateSubscription() { if (this.rootId.value) { - this.subscription.update({ "#e": [this.rootId.value], kinds: [1] }); + this.subscription.setQuery({ "#e": [this.rootId.value], kinds: [1] }); if (this.subscription.state !== NostrSubscription.OPEN) { this.subscription.open(); } diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index 29a4a518a..8a34d7345 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -39,7 +39,12 @@ export class TimelineLoader { if (!query.since) throw new Error('Timeline requires "since" to be set in query'); this.query = query; - this.subscription.update(query); + this.subscription.setQuery(query); + } + + setRelays(relays: string[]) { + this.relays = relays; + this.subscription.setRelays(relays); } private handleEvent(event: NostrEvent) { @@ -78,9 +83,10 @@ export class TimelineLoader { this.page.next(this.page.value + 1); } - reset() { + forgetEvents() { this.events.next([]); this.seenEvents.clear(); + this.subscription.forgetEvents(); } open() { this.subscription.open(); diff --git a/src/components/relay-url-input.tsx b/src/components/relay-url-input.tsx new file mode 100644 index 000000000..849f75e52 --- /dev/null +++ b/src/components/relay-url-input.tsx @@ -0,0 +1,24 @@ +import { Input, InputProps } from "@chakra-ui/react"; +import { useAsync } from "react-use"; + +export type RelayUrlInputProps = Omit; + +export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => { + const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () => + fetch("/relays.json").then((res) => res.json() as Promise<{ relays: string[] }>) + ); + const relaySuggestions = relaysJson?.relays ?? []; + + return ( + <> + + + {relaySuggestions.map((url) => ( + + ))} + + + ); +}; diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index d45e33af1..d453be832 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -17,7 +17,7 @@ export function useSubscription(query: NostrQuery, opts?: Options) { useDeepCompareEffect(() => { if (sub.current) { - sub.current.update(query); + sub.current.setQuery(query); if (opts?.enabled ?? true) sub.current.open(); else sub.current.close(); } diff --git a/src/hooks/use-timeline-loader.ts b/src/hooks/use-timeline-loader.ts index 9383cbd70..2d4d2e01f 100644 --- a/src/hooks/use-timeline-loader.ts +++ b/src/hooks/use-timeline-loader.ts @@ -1,25 +1,27 @@ import { useCallback, useEffect, useRef } from "react"; -import { useUnmount } from "react-use"; +import { useDeepCompareEffect, useUnmount } from "react-use"; import { NostrQueryWithStart, TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader"; -import settings from "../services/settings"; import useSubject from "./use-subject"; type Options = TimelineLoaderOptions & { enabled?: boolean; }; -export function useTimelineLoader(key: string, query: NostrQueryWithStart, opts?: Options) { - const relays = useSubject(settings.relays); +export function useTimelineLoader(key: string, relays: string[], query: NostrQueryWithStart, opts?: Options) { if (opts && !opts.name) opts.name = key; const ref = useRef(null); const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts)); useEffect(() => { - loader.reset(); + loader.forgetEvents(); loader.setQuery(query); }, [key]); + useDeepCompareEffect(() => { + loader.setRelays(relays); + }, [relays]); + const enabled = opts?.enabled ?? true; useEffect(() => { if (enabled) { diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts index 267e95e1c..80773bc26 100644 --- a/src/services/user-contacts.ts +++ b/src/services/user-contacts.ts @@ -71,7 +71,7 @@ function flushRequests() { const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [3] }; subscription.setRelays(Array.from(relays)); - subscription.update(query); + subscription.setQuery(query); if (subscription.state !== NostrSubscription.OPEN) { subscription.open(); } diff --git a/src/services/user-followers.ts b/src/services/user-followers.ts index 865d01a2a..1b248751b 100644 --- a/src/services/user-followers.ts +++ b/src/services/user-followers.ts @@ -55,7 +55,7 @@ function flushRequests() { const query: NostrQuery = { kinds: [3], "#p": Array.from(pubkeys) }; subscription.setRelays(Array.from(relays)); - subscription.update(query); + subscription.setQuery(query); if (subscription.state !== NostrSubscription.OPEN) { subscription.open(); } diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index 67261d3f4..33e82b4a1 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -56,7 +56,7 @@ function flushRequests() { const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [0] }; subscription.setRelays(Array.from(relays)); - subscription.update(query); + subscription.setQuery(query); if (subscription.state !== NostrSubscription.OPEN) { subscription.open(); } diff --git a/src/views/home/components/feed-filters.tsx b/src/views/home/components/feed-filters.tsx new file mode 100644 index 000000000..42abd6e03 --- /dev/null +++ b/src/views/home/components/feed-filters.tsx @@ -0,0 +1,145 @@ +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Button, + Flex, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + Switch, +} from "@chakra-ui/react"; +import { useState } from "react"; +import { useList } from "react-use"; +import { RelayUrlInput } from "../../../components/relay-url-input"; +import { unique } from "../../../helpers/array"; +import useSubject from "../../../hooks/use-subject"; +import settings from "../../../services/settings"; + +const CustomRelayForm = ({ onSubmit }: { onSubmit: (url: string) => void }) => { + const [customRelay, setCustomRelay] = useState(""); + + return ( +
{ + e.preventDefault(); + onSubmit(customRelay); + setCustomRelay(""); + }} + > + + setCustomRelay(e.target.value)} + /> + + +
+ ); +}; + +export type FilterValues = { + relays: string[]; +}; + +export type FeedFiltersProps = { + isOpen: boolean; + onClose: ModalProps["onClose"]; + values: FilterValues; + onSave: (values: FilterValues) => void; +}; + +export const FeedFilters = ({ isOpen, onClose, values }: FeedFiltersProps) => { + const defaultRelays = useSubject(settings.relays); + + const [selectedRelays, relayActions] = useList(values.relays); + const availableRelays = unique([...defaultRelays, ...selectedRelays]); + + return ( + + + + Filters + + + + +

+ + + Relays + + + +

+ + {availableRelays.map((url) => ( + + + + selectedRelays.includes(url) + ? relayActions.removeAt(selectedRelays.indexOf(url)) + : relayActions.push(url) + } + /> + {url} + + + ))} + + + relayActions.push(url)} /> + + +
+ + +

+ + + Add Custom + + + +

+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip + ex ea commodo consequat. + +
+
+
+ + + + + +
+
+ ); +}; diff --git a/src/views/home/discover-tab.tsx b/src/views/home/discover-tab.tsx index d4f25b498..e97bb9d6a 100644 --- a/src/views/home/discover-tab.tsx +++ b/src/views/home/discover-tab.tsx @@ -9,6 +9,7 @@ import identity from "../../services/identity"; import userContactsService from "../../services/user-contacts"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { isNote } from "../../helpers/nostr-event"; +import settings from "../../services/settings"; function useExtendedContacts(pubkey: string) { const [extendedContacts, setExtendedContacts] = useState([]); @@ -39,10 +40,12 @@ function useExtendedContacts(pubkey: string) { export const DiscoverTab = () => { const pubkey = useSubject(identity.pubkey); + const relays = useSubject(settings.relays); const contactsOfContacts = useExtendedContacts(pubkey); const { events, loading, loadMore } = useTimelineLoader( `discover`, + relays, { authors: contactsOfContacts, kinds: [1], since: moment().subtract(1, "hour").unix() }, { pageSize: moment.duration(1, "hour").asSeconds(), enabled: contactsOfContacts.length > 0 } ); diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index c1658ead7..897aaf6f9 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -7,9 +7,11 @@ import useSubject from "../../hooks/use-subject"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useUserContacts } from "../../hooks/use-user-contacts"; import identity from "../../services/identity"; +import settings from "../../services/settings"; export const FollowingTab = () => { const pubkey = useSubject(identity.pubkey); + const relays = useSubject(settings.relays); const contacts = useUserContacts(pubkey); const [search, setSearch] = useSearchParams(); const showReplies = search.has("replies"); @@ -20,6 +22,7 @@ export const FollowingTab = () => { const following = contacts?.contacts || []; const { events, loading, loadMore } = useTimelineLoader( `following-posts`, + relays, { authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() }, { pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 } ); diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index 0f664b3af..564b0d26d 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -1,24 +1,60 @@ -import { Button, Flex, Spinner } from "@chakra-ui/react"; +import { Button, Flex, FormControl, FormLabel, Select, Spinner, Switch, useDisclosure } from "@chakra-ui/react"; import moment from "moment"; +import { useState } from "react"; import { Note } from "../../components/note"; import { isNote } from "../../helpers/nostr-event"; +import useSubject from "../../hooks/use-subject"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import settings from "../../services/settings"; export const GlobalTab = () => { - const { events, loading, loadMore } = useTimelineLoader( + const availableRelays = useSubject(settings.relays); + const [selectedRelay, setSelectedRelay] = useState(""); + const { isOpen: showReplies, onToggle } = useDisclosure(); + const { events, loading, loadMore, loader } = useTimelineLoader( `global`, + selectedRelay ? [selectedRelay] : availableRelays, { kinds: [1], since: moment().subtract(5, "minutes").unix() }, { pageSize: moment.duration(5, "minutes").asSeconds() } ); - const timeline = events.filter(isNote); + const timeline = showReplies ? events : events.filter(isNote); return ( - - {timeline.map((event) => ( - - ))} - {loading ? : } - + <> + + + + + + + Show Replies + + + + {timeline.map((event) => ( + + ))} + {loading ? ( + + ) : ( + + )} + + ); }; diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx index 954e4eab3..8cbc56cb9 100644 --- a/src/views/settings/index.tsx +++ b/src/views/settings/index.tsx @@ -28,16 +28,12 @@ import { RelayStatus } from "./relay-status"; import useSubject from "../../hooks/use-subject"; import settings from "../../services/settings"; import { clearData } from "../../services/db"; +import { RelayUrlInput } from "../../components/relay-url-input"; export const SettingsView = () => { const relays = useSubject(settings.relays); const [relayInputValue, setRelayInputValue] = useState(""); - const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () => - fetch("/relays.json").then((res) => res.json() as Promise<{ relays: string[] }>) - ); - const relaySuggestions = relaysJson?.relays.filter((url) => !relays.includes(url)) ?? []; - const { colorMode, setColorMode } = useColorMode(); const handleRemoveRelay = (url: string) => { @@ -46,8 +42,10 @@ export const SettingsView = () => { const handleAddRelay = (event: SyntheticEvent) => { event.preventDefault(); - settings.relays.next([...relays, relayInputValue]); - setRelayInputValue(""); + if (!relays.includes(relayInputValue)) { + settings.relays.next([...relays, relayInputValue]); + setRelayInputValue(""); + } }; const [clearing, setClearing] = useState(false); @@ -104,22 +102,12 @@ export const SettingsView = () => { Add Relay - setRelayInputValue(e.target.value)} - required - list="relay-suggestions" - type="url" - isDisabled={loadingRelaysJson} + isRequired /> - - {relaySuggestions.map((url) => ( - - ))} - diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index 9443fb530..aed0ad969 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -3,13 +3,17 @@ import moment from "moment"; import { useOutletContext } from "react-router-dom"; import { Note } from "../../components/note"; import { isNote } from "../../helpers/nostr-event"; +import useSubject from "../../hooks/use-subject"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import settings from "../../services/settings"; const UserNotesTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; + const relays = useSubject(settings.relays); const { events, loading, loadMore } = useTimelineLoader( `${pubkey} notes`, + relays, { authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() }, { pageSize: moment.duration(1, "day").asSeconds() } ); diff --git a/src/views/user/replies.tsx b/src/views/user/replies.tsx index e4ee2c631..953c2296b 100644 --- a/src/views/user/replies.tsx +++ b/src/views/user/replies.tsx @@ -3,12 +3,17 @@ import moment from "moment"; import { useOutletContext } from "react-router-dom"; import { Note } from "../../components/note"; import { isReply } from "../../helpers/nostr-event"; +import useSubject from "../../hooks/use-subject"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import settings from "../../services/settings"; const UserRepliesTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; + const relays = useSubject(settings.relays); + const { events, loading, loadMore } = useTimelineLoader( `${pubkey} replies`, + relays, { authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() }, { pageSize: moment.duration(1, "day").asSeconds() } );