mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-20 04:20:39 +02:00
add multi relay selection to hashtag view
This commit is contained in:
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 {
|
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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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";
|
} 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
|
||||||
|
Reference in New Issue
Block a user