add relay selection to global feed

This commit is contained in:
hzrd149 2023-02-07 17:04:18 -06:00
parent 14851eaafe
commit 2fda4b2e9e
16 changed files with 295 additions and 57 deletions

View File

@ -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<Relay, SubscriptionLike>();
/** 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();
}
}

View File

@ -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();
}

View File

@ -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();

View File

@ -0,0 +1,24 @@
import { Input, InputProps } from "@chakra-ui/react";
import { useAsync } from "react-use";
export type RelayUrlInputProps = Omit<InputProps, "type">;
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 (
<>
<Input list="relay-suggestions" type="url" isDisabled={props.isDisabled ?? loadingRelaysJson} {...props} />
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
</>
);
};

View File

@ -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();
}

View File

@ -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<TimelineLoader | null>(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) {

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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 (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(customRelay);
setCustomRelay("");
}}
>
<Flex gap="2">
<RelayUrlInput
size="sm"
placeholder="wss://relay.example.com"
value={customRelay}
onChange={(e) => setCustomRelay(e.target.value)}
/>
<Button size="sm" type="submit">
Add
</Button>
</Flex>
</form>
);
};
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 (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Filters</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Relays
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
{availableRelays.map((url) => (
<Box key={url}>
<FormLabel>
<Switch
size="sm"
mr="2"
isChecked={selectedRelays.includes(url)}
onChange={() =>
selectedRelays.includes(url)
? relayActions.removeAt(selectedRelays.indexOf(url))
: relayActions.push(url)
}
/>
{url}
</FormLabel>
</Box>
))}
<Flex gap="2">
<Button size="sm" onClick={() => relayActions.set(defaultRelays)}>
Select All
</Button>
<CustomRelayForm onSubmit={(url) => relayActions.push(url)} />
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Add Custom
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
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.
</AccordionPanel>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="blue" variant="solid">
Save
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@ -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<string[]>([]);
@ -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 }
);

View File

@ -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 }
);

View File

@ -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 (
<Flex direction="column" overflow="auto" gap="2">
{timeline.map((event) => (
<Note key={event.id} event={event} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
<>
<Flex direction="column" overflow="auto" gap="2">
<Flex gap="2">
<Select
placeholder="All Relays"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
loader.forgetEvents();
}}
>
{availableRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
{timeline.map((event) => (
<Note key={event.id} event={event} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
) : (
<Button onClick={() => loadMore()}>Load More</Button>
)}
</Flex>
</>
);
};

View File

@ -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<HTMLFormElement>) => {
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 = () => {
<FormControl>
<FormLabel htmlFor="relay-url-input">Add Relay</FormLabel>
<Flex gap="2">
<Input
<RelayUrlInput
id="relay-url-input"
value={relayInputValue}
onChange={(e) => setRelayInputValue(e.target.value)}
required
list="relay-suggestions"
type="url"
isDisabled={loadingRelaysJson}
isRequired
/>
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
<Button type="submit">Add</Button>
</Flex>
</FormControl>

View File

@ -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() }
);

View File

@ -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() }
);