add multi relay selection to hashtag view

This commit is contained in:
hzrd149
2023-06-28 07:52:30 +00:00
parent 7f162ac28a
commit 5d19861929
4 changed files with 235 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add multi relay selection to hashtag view

View File

@@ -1,83 +1,119 @@
import { import {
Button, Button,
ButtonGroup,
Editable,
EditableInput,
EditablePreview,
Flex, Flex,
FormControl, FormControl,
FormLabel, FormLabel,
Heading, IconButton,
Select, Input,
Spinner, Spinner,
Switch, Switch,
useDisclosure, useDisclosure,
useEditableControls,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useParams, useSearchParams } from "react-router-dom"; import { CloseIcon } from "@chakra-ui/icons";
import { useNavigate, useParams } from "react-router-dom";
import { useAppTitle } from "../../hooks/use-app-title"; import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { unique } from "../../helpers/array";
import { isReply } from "../../helpers/nostr-event"; import { isReply } from "../../helpers/nostr-event";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
import { useEffect, useState } from "react";
import RelaySelectionModal from "./relay-selection-modal";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
return isEditing ? (
<ButtonGroup justifyContent="center" size="md">
<IconButton icon={<CheckIcon />} {...getSubmitButtonProps()} aria-label="Save" />
<IconButton icon={<CloseIcon />} {...getCancelButtonProps()} aria-label="Cancel" />
</ButtonGroup>
) : (
<IconButton size="md" icon={<EditIcon />} {...getEditButtonProps()} aria-label="Edit" />
);
}
export default function HashTagView() { export default function HashTagView() {
const navigate = useNavigate();
const { hashtag } = useParams() as { hashtag: string }; const { hashtag } = useParams() as { hashtag: string };
const [editableHashtag, setEditableHashtag] = useState(hashtag);
useEffect(() => setEditableHashtag(hashtag), [hashtag]);
useAppTitle("#" + hashtag); useAppTitle("#" + hashtag);
const defaultRelays = useReadRelayUrls(); const defaultRelays = useReadRelayUrls();
const [searchParams, setSearchParams] = useSearchParams(); const [selectedRelays, setSelectedRelays] = useState(defaultRelays);
const selectedRelay = searchParams.get("relay") ?? "";
const setSelectedRelay = (url: string) => {
if (url) {
setSearchParams({ relay: url });
} else setSearchParams({});
};
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
const relaysModal = useDisclosure();
const { isOpen: showReplies, onToggle } = useDisclosure(); const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader( const { events, loading, loadMore, loader } = useTimelineLoader(
`${hashtag}-hashtag`, `${hashtag}-hashtag`,
selectedRelay ? [selectedRelay] : defaultRelays, selectedRelays,
{ kinds: [1], "#t": [hashtag] }, { kinds: [1], "#t": [hashtag] },
{ pageSize: 60*10 } { pageSize: 60 * 10 }
); );
const timeline = showReplies ? events : events.filter((e) => !isReply(e)); const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return ( return (
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1"> <>
<Heading>#{hashtag}</Heading> <Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1">
<Flex gap="2"> <Flex gap="4" alignItems="center" wrap="wrap">
<Select <Editable
placeholder="All Relays" value={editableHashtag}
maxWidth="250" onChange={(v) => setEditableHashtag(v)}
value={selectedRelay} fontSize="3xl"
onChange={(e) => { fontWeight="bold"
setSelectedRelay(e.target.value); display="flex"
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
flexShrink={0}
>
<div>
#<EditablePreview p={0} />
</div>
<Input as={EditableInput} maxW="md" />
<EditableControls />
</Editable>
<Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen}>
{selectedRelays.length} Relays
</Button>
<FormControl display="flex" alignItems="center" w="auto">
<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} maxHeight={600} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)}
</Flex>
{relaysModal.isOpen && (
<RelaySelectionModal
selected={selectedRelays}
onSubmit={(relays) => {
setSelectedRelays(relays);
loader.forgetEvents(); loader.forgetEvents();
}} }}
> onClose={relaysModal.onClose}
{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} maxHeight={600} />
))}
{loading ? (
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
) : (
<Button onClick={() => loadMore()} flexShrink={0}>
Load More
</Button>
)} )}
</Flex> </>
); );
} }

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import {
Button,
ButtonGroup,
Checkbox,
CheckboxGroup,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useToast,
} from "@chakra-ui/react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { RelayFavicon } from "../../components/relay-favicon";
import { RelayUrlInput } from "../../components/relay-url-input";
import { normalizeRelayUrl } from "../../helpers/url";
import { unique } from "../../helpers/array";
import relayScoreboardService from "../../services/relay-scoreboard";
function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
const [url, setUrl] = useState("");
const toast = useToast();
return (
<Flex
as="form"
onSubmit={(e) => {
try {
e.preventDefault();
onSubmit(normalizeRelayUrl(url));
setUrl("");
} catch (err) {
if (err instanceof Error) {
toast({ status: "error", description: err.message });
}
}
}}
gap="2"
mb="4"
>
<RelayUrlInput value={url} onChange={(v) => setUrl(v)} />
<Button type="submit">Add</Button>
</Flex>
);
}
const manuallyAddedRelays = new Set<string>();
export default function RelaySelectionModal({
selected,
onClose,
onSubmit,
}: {
selected: string[];
onSubmit: (relays: string[]) => void;
onClose: () => void;
}) {
const [newSelected, setSelected] = useState<string[]>(selected);
const relays = useReadRelayUrls([...selected, ...newSelected, ...Array.from(manuallyAddedRelays)]);
return (
<Modal isOpen={true} onClose={onClose} closeOnOverlayClick={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Select Relays</ModalHeader>
<ModalCloseButton />
<ModalBody py="0">
<AddRelayForm
onSubmit={(newRelay) => {
setSelected(unique([newRelay, ...newSelected]));
manuallyAddedRelays.add(newRelay);
}}
/>
<CheckboxGroup value={newSelected} onChange={(urls) => setSelected(urls.map(String))}>
<Flex direction="column" gap="2" mb="2">
{relays.map((url) => (
<Checkbox key={url} value={url}>
<RelayFavicon relay={url} size="xs" /> {url}
</Checkbox>
))}
</Flex>
</CheckboxGroup>
<ButtonGroup>
<Button onClick={() => setSelected(relays)} size="sm">
All
</Button>
<Button onClick={() => setSelected([])} size="sm">
None
</Button>
<Button onClick={() => setSelected(relayScoreboardService.getRankedRelays(relays).slice(0, 4))} size="sm">
4 Fastest
</Button>
</ButtonGroup>
</ModalBody>
<ModalFooter>
<Button onClick={onClose} mr="2">
Cancel
</Button>
<Button
colorScheme="brand"
onClick={() => {
onSubmit(newSelected);
onClose();
}}
>
Save
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -14,14 +14,25 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { AppSettings } from "../../services/user-app-settings"; import { AppSettings } from "../../services/user-app-settings";
import { safeUrl } from "../../helpers/parse";
async function validateInvidiousUrl(url?: string) { async function validateInvidiousUrl(url?: string) {
if (!url) return true; if (!url) return true;
try { try {
const res = await fetch(new URL("/api/v1/stats", url)); const res = await fetch(new URL("/api/v1/stats", url));
return res.ok || "Catch reach instance"; return res.ok || "Cant reach instance";
} catch (e) { } catch (e) {
return "Catch reach instance"; return "Cant reach instance";
}
}
async function validateCorsProxy(url?: string) {
if (!url) return true;
try {
const res = await fetch(new URL("/https://example.com", url));
return res.ok || "Cant reach instance";
} catch (e) {
return "Cant reach instance";
} }
} }
@@ -42,7 +53,11 @@ export default function PrivacySettings() {
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<FormControl isInvalid={!!formState.errors.twitterRedirect}> <FormControl isInvalid={!!formState.errors.twitterRedirect}>
<FormLabel>Nitter instance</FormLabel> <FormLabel>Nitter instance</FormLabel>
<Input type="url" placeholder="https://nitter.net/" {...register("twitterRedirect")} /> <Input
type="url"
placeholder="https://nitter.net/"
{...register("twitterRedirect", { setValueAs: safeUrl })}
/>
{formState.errors.twitterRedirect && ( {formState.errors.twitterRedirect && (
<FormErrorMessage>{formState.errors.twitterRedirect.message}</FormErrorMessage> <FormErrorMessage>{formState.errors.twitterRedirect.message}</FormErrorMessage>
)} )}
@@ -61,6 +76,7 @@ export default function PrivacySettings() {
placeholder="Invidious instance url" placeholder="Invidious instance url"
{...register("youtubeRedirect", { {...register("youtubeRedirect", {
validate: validateInvidiousUrl, validate: validateInvidiousUrl,
setValueAs: safeUrl,
})} })}
/> />
{formState.errors.youtubeRedirect && ( {formState.errors.youtubeRedirect && (
@@ -76,7 +92,11 @@ export default function PrivacySettings() {
<FormControl isInvalid={!!formState.errors.redditRedirect}> <FormControl isInvalid={!!formState.errors.redditRedirect}>
<FormLabel>Teddit / Libreddit instance</FormLabel> <FormLabel>Teddit / Libreddit instance</FormLabel>
<Input type="url" placeholder="https://nitter.net/" {...register("redditRedirect")} /> <Input
type="url"
placeholder="https://nitter.net/"
{...register("redditRedirect", { setValueAs: safeUrl })}
/>
{formState.errors.redditRedirect && ( {formState.errors.redditRedirect && (
<FormErrorMessage>{formState.errors.redditRedirect.message}</FormErrorMessage> <FormErrorMessage>{formState.errors.redditRedirect.message}</FormErrorMessage>
)} )}
@@ -98,7 +118,11 @@ export default function PrivacySettings() {
<FormControl isInvalid={!!formState.errors.corsProxy}> <FormControl isInvalid={!!formState.errors.corsProxy}>
<FormLabel>CORS Proxy</FormLabel> <FormLabel>CORS Proxy</FormLabel>
<Input type="url" placeholder="https://cors.example.com/" {...register("corsProxy")} /> <Input
type="url"
placeholder="https://cors.example.com/"
{...register("corsProxy", { setValueAs: safeUrl, validate: validateCorsProxy })}
/>
{formState.errors.corsProxy && <FormErrorMessage>{formState.errors.corsProxy.message}</FormErrorMessage>} {formState.errors.corsProxy && <FormErrorMessage>{formState.errors.corsProxy.message}</FormErrorMessage>}
<FormHelperText> <FormHelperText>
This is used as a fallback when verifying NIP-05 ids and fetching open-graph metadata. URL to an instance This is used as a fallback when verifying NIP-05 ids and fetching open-graph metadata. URL to an instance