mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 21:00:17 +02:00
add multi relay selection to hashtag view
This commit is contained in:
parent
7f162ac28a
commit
5d19861929
5
.changeset/orange-tomatoes-provide.md
Normal file
5
.changeset/orange-tomatoes-provide.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add multi relay selection to hashtag view
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
117
src/views/hashtag/relay-selection-modal.tsx
Normal file
117
src/views/hashtag/relay-selection-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user