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 {
Button,
ButtonGroup,
Editable,
EditableInput,
EditablePreview,
Flex,
FormControl,
FormLabel,
Heading,
Select,
IconButton,
Input,
Spinner,
Switch,
useDisclosure,
useEditableControls,
} 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 { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { unique } from "../../helpers/array";
import { isReply } from "../../helpers/nostr-event";
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() {
const navigate = useNavigate();
const { hashtag } = useParams() as { hashtag: string };
const [editableHashtag, setEditableHashtag] = useState(hashtag);
useEffect(() => setEditableHashtag(hashtag), [hashtag]);
useAppTitle("#" + hashtag);
const defaultRelays = useReadRelayUrls();
const [searchParams, setSearchParams] = useSearchParams();
const selectedRelay = searchParams.get("relay") ?? "";
const setSelectedRelay = (url: string) => {
if (url) {
setSearchParams({ relay: url });
} else setSearchParams({});
};
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
const [selectedRelays, setSelectedRelays] = useState(defaultRelays);
const relaysModal = useDisclosure();
const { isOpen: showReplies, onToggle } = useDisclosure();
const { events, loading, loadMore, loader } = useTimelineLoader(
`${hashtag}-hashtag`,
selectedRelay ? [selectedRelay] : defaultRelays,
selectedRelays,
{ kinds: [1], "#t": [hashtag] },
{ pageSize: 60*10 }
{ pageSize: 60 * 10 }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1">
<Heading>#{hashtag}</Heading>
<Flex gap="2">
<Select
placeholder="All Relays"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
<>
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1">
<Flex gap="4" alignItems="center" wrap="wrap">
<Editable
value={editableHashtag}
onChange={(v) => setEditableHashtag(v)}
fontSize="3xl"
fontWeight="bold"
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();
}}
>
{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>
onClose={relaysModal.onClose}
/>
)}
</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";
import { useFormContext } from "react-hook-form";
import { AppSettings } from "../../services/user-app-settings";
import { safeUrl } from "../../helpers/parse";
async function validateInvidiousUrl(url?: string) {
if (!url) return true;
try {
const res = await fetch(new URL("/api/v1/stats", url));
return res.ok || "Catch reach instance";
return res.ok || "Cant reach instance";
} 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">
<FormControl isInvalid={!!formState.errors.twitterRedirect}>
<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 && (
<FormErrorMessage>{formState.errors.twitterRedirect.message}</FormErrorMessage>
)}
@ -61,6 +76,7 @@ export default function PrivacySettings() {
placeholder="Invidious instance url"
{...register("youtubeRedirect", {
validate: validateInvidiousUrl,
setValueAs: safeUrl,
})}
/>
{formState.errors.youtubeRedirect && (
@ -76,7 +92,11 @@ export default function PrivacySettings() {
<FormControl isInvalid={!!formState.errors.redditRedirect}>
<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 && (
<FormErrorMessage>{formState.errors.redditRedirect.message}</FormErrorMessage>
)}
@ -98,7 +118,11 @@ export default function PrivacySettings() {
<FormControl isInvalid={!!formState.errors.corsProxy}>
<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>}
<FormHelperText>
This is used as a fallback when verifying NIP-05 ids and fetching open-graph metadata. URL to an instance