Merge branch 'next'

This commit is contained in:
hzrd149 2023-04-11 08:02:57 -05:00
commit 568152f427
33 changed files with 458 additions and 126 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for kind 6 reposts

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add copy button to user QrCode modal

View File

@ -21,7 +21,7 @@
"light-bolt11-decoder": "^2.1.0",
"moment": "^2.29.4",
"noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.7.4",
"nostr-tools": "^1.8.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",

View File

@ -119,7 +119,7 @@ const router = createBrowserRouter([
{ path: "dm", element: <DirectMessagesView /> },
{ path: "dm/:key", element: <DirectMessageChatView /> },
{ path: "profile", element: <ProfileView /> },
{ path: "nostr-link", element: <NostrLinkView /> },
{ path: "l/:link", element: <NostrLinkView /> },
{
path: "",
element: <HomeView />,

View File

@ -0,0 +1,27 @@
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react";
import { ModalProps } from "@chakra-ui/react";
import { Bech32Prefix, hexToBech32 } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr-event";
import { NostrEvent } from "../../types/nostr-event";
import RawJson from "./raw-block";
import RawValue from "./raw-value";
export default function NoteDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
return (
<Modal {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Event Debug</ModalHeader>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={event.id} />
<RawValue heading="Encoded id (NIP-19)" value={hexToBech32(event.id, Bech32Prefix.Note) ?? "failed"} />
<RawJson heading="Raw" json={event} />
<RawJson heading="References" json={getReferences(event)} />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,16 @@
import { Box, Code, Flex, Heading } from "@chakra-ui/react";
export default function RawJson({ json, heading }: { heading: string; json: any }) {
return (
<Box>
<Heading size="sm" mb="2">
{heading}
</Heading>
<Flex gap="2">
<Code whiteSpace="pre" overflowX="auto" width="100%">
{JSON.stringify(json, null, 2)}
</Code>
</Flex>
</Box>
);
}

View File

@ -0,0 +1,18 @@
import { Box, Code, Flex, Heading } from "@chakra-ui/react";
import { CopyIconButton } from "../copy-icon-button";
export default function RawValue({ value, heading }: { heading: string; value: string }) {
return (
<Box>
<Heading size="sm" mb="2">
{heading}
</Heading>
<Flex gap="2">
<Code fontSize="md" wordBreak="break-all">
{value}
</Code>
<CopyIconButton text={value} size="xs" aria-label="copy" />
</Flex>
</Box>
);
}

View File

@ -0,0 +1,28 @@
import { useMemo } from "react";
import { Flex, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from "@chakra-ui/react";
import { ModalProps } from "@chakra-ui/react";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import RawValue from "./raw-value";
import RawJson from "./raw-block";
export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } & Omit<ModalProps, "children">) {
const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]);
const metadata = useUserMetadata(pubkey);
return (
<Modal {...props}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
<Flex gap="2" direction="column">
<RawValue heading="Hex pubkey" value={pubkey} />
{npub && <RawValue heading="Encoded pubkey (NIP-19)" value={npub} />}
<RawJson heading="Metadata (kind 0)" json={metadata} />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -2,12 +2,12 @@ import React from "react";
import { ErrorBoundary as ErrorBoundaryHelper, FallbackProps } from "react-error-boundary";
import { Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react";
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
export function ErrorFallback({ error, resetErrorBoundary }: Partial<FallbackProps>) {
return (
<Alert status="error">
<AlertIcon />
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription>{error?.message}</AlertDescription>
</Alert>
);
}

View File

@ -130,15 +130,21 @@ export const SearchIcon = createIcon({
defaultProps,
});
export const ShareIcon = createIcon({
displayName: "ShareIcon",
export const RepostIcon = createIcon({
displayName: "RepostIcon",
d: "M13.12 17.023l-4.199-2.29a4 4 0 1 1 0-5.465l4.2-2.29a4 4 0 1 1 .959 1.755l-4.2 2.29a4.008 4.008 0 0 1 0 1.954l4.199 2.29a4 4 0 1 1-.959 1.755zM6 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm11-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
defaultProps,
});
export const ReplyIcon = createIcon({
displayName: "ReplyIcon",
d: "M5.763 17H20V5H4v13.385L5.763 17zm.692 2L2 22.5V4a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6.455z",
d: "M11 20L1 12L11 4V9C16.5228 9 21 13.4772 21 19C21 19.2727 20.9891 19.5428 20.9677 19.81C19.5055 17.0364 16.6381 15.119 13.313 15.0053L13 15H10.9999L11 20ZM8.99986 13H10.9999L13.0341 13.0003L13.3814 13.0065C14.6657 13.0504 15.9053 13.3165 17.0568 13.7734C15.5898 12.0749 13.4204 11 11 11H9V8.16125L4.20156 12L8.99992 15.8387L8.99986 13Z",
defaultProps,
});
export const QuoteRepostIcon = createIcon({
displayName: "QuoteRepostIcon",
d: "M19.4167 6.67891C20.4469 7.77257 21.0001 9 21.0001 10.9897C21.0001 14.4891 18.5436 17.6263 14.9695 19.1768L14.0768 17.7992C17.4121 15.9946 18.0639 13.6539 18.3245 12.178C17.7875 12.4557 17.0845 12.5533 16.3954 12.4895C14.591 12.3222 13.1689 10.8409 13.1689 9C13.1689 7.067 14.7359 5.5 16.6689 5.5C17.742 5.5 18.7681 5.99045 19.4167 6.67891ZM9.41669 6.67891C10.4469 7.77257 11.0001 9 11.0001 10.9897C11.0001 14.4891 8.54359 17.6263 4.96951 19.1768L4.07682 17.7992C7.41206 15.9946 8.06392 13.6539 8.32447 12.178C7.78747 12.4557 7.08452 12.5533 6.39539 12.4895C4.59102 12.3222 3.16895 10.8409 3.16895 9C3.16895 7.067 4.73595 5.5 6.66895 5.5C7.742 5.5 8.76814 5.99045 9.41669 6.67891Z",
defaultProps,
});

View File

@ -0,0 +1,24 @@
import { useContext } from "react";
import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { QuoteRepostIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildQuoteRepost } from "../../../helpers/nostr-event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
export function QuoteRepostButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const handleClick = () => openModal(buildQuoteRepost(event));
return (
<IconButton
icon={<QuoteRepostIcon />}
onClick={handleClick}
aria-label="Quote repost"
title="Quote repost"
isDisabled={account.readonly}
/>
);
}

View File

@ -1,29 +1,19 @@
import {
Button,
ButtonProps,
Flex,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import { Button, ButtonProps } from "@chakra-ui/react";
import moment from "moment";
import { Kind } from "nostr-tools";
import { useState } from "react";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { random } from "../../helpers/array";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useEventReactions from "../../hooks/use-event-reactions";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import eventReactionsService from "../../services/event-reactions";
import { getEventRelays } from "../../services/event-relays";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DislikeIcon, LikeIcon } from "../icons";
import { nostrPostAction } from "../../../classes/nostr-post-action";
import { random } from "../../../helpers/array";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import eventReactionsService from "../../../services/event-reactions";
import { getEventRelays } from "../../../services/event-relays";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { DislikeIcon, LikeIcon } from "../../icons";
export default function NoteLikeButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
export default function ReactionButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();

View File

@ -0,0 +1,18 @@
import { useContext } from "react";
import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { ReplyIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildReply } from "../../../helpers/nostr-event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
export function ReplyButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const reply = () => openModal(buildReply(event));
return (
<IconButton icon={<ReplyIcon />} title="Reply" aria-label="Reply" onClick={reply} isDisabled={account.readonly} />
);
}

View File

@ -0,0 +1,40 @@
import { useState } from "react";
import { IconButton, useToast } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { RepostIcon } from "../../icons";
import { buildRepost } from "../../../helpers/nostr-event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import { nostrPostAction } from "../../../classes/nostr-post-action";
import clientRelaysService from "../../../services/client-relays";
import signingService from "../../../services/signing";
export function RepostButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const [loading, setLoading] = useState(false);
const toast = useToast();
const handleClick = async () => {
try {
setLoading(true);
const draftRepost = buildRepost(event);
const repost = await signingService.requestSignature(draftRepost, account);
await nostrPostAction(clientRelaysService.getWriteUrls(), repost);
} catch (e) {
if (e instanceof Error) {
toast({ status: "error", description: e.message });
}
}
setLoading(false);
};
return (
<IconButton
icon={<RepostIcon />}
onClick={handleClick}
aria-label="Repost Note"
title="Repost Note"
isDisabled={account.readonly}
isLoading={loading}
/>
);
}

View File

@ -1,4 +1,4 @@
import React, { useContext } from "react";
import React from "react";
import { Link as RouterLink } from "react-router-dom";
import moment from "moment";
import {
@ -11,7 +11,6 @@ import {
CardProps,
Flex,
Heading,
IconButton,
Link,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
@ -24,18 +23,18 @@ import { useUserContacts } from "../../hooks/use-user-contacts";
import { NoteRelays } from "./note-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserLink } from "../user-link";
import { ReplyIcon, ShareIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply, buildShare } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { convertTimestampToDate } from "../../helpers/date";
import { useCurrentAccount } from "../../hooks/use-current-account";
import NoteLikeButton from "./note-like-button";
import ReactionButton from "./buttons/reaction-button";
import NoteZapButton from "./note-zap-button";
import { ExpandProvider } from "./expanded";
import useSubject from "../../hooks/use-subject";
import settings from "../../services/settings";
import EventVerificationIcon from "../event-verification-icon";
import { ReplyButton } from "./buttons/reply-button";
import { RepostButton } from "./buttons/repost-button";
import { QuoteRepostButton } from "./buttons/quote-repost-button";
export type NoteProps = {
event: NostrEvent;
@ -45,16 +44,12 @@ export type NoteProps = {
export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteProps) => {
const isMobile = useIsMobile();
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const showReactions = useSubject(settings.showReactions);
const showSignatureVerification = useSubject(settings.showSignatureVerification);
const contacts = useUserContacts(account.pubkey);
const following = contacts?.contacts || [];
const reply = () => openModal(buildReply(event));
const share = () => openModal(buildShare(event));
return (
<ExpandProvider>
<Card variant={variant}>
@ -81,27 +76,12 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP
/>
</CardBody>
<CardFooter padding="2" display="flex" gap="2">
<IconButton
variant="link"
icon={<ReplyIcon />}
title="Reply"
aria-label="Reply"
onClick={reply}
size="sm"
isDisabled={account.readonly}
/>
<IconButton
variant="link"
icon={<ShareIcon />}
onClick={share}
aria-label="Share Note"
title="Share Note"
size="sm"
isDisabled={account.readonly}
/>
<ButtonGroup size="sm" variant="link">
<ReplyButton event={event} />
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton note={event} size="sm" />
{showReactions && <NoteLikeButton note={event} size="sm" />}
{showReactions && <ReactionButton note={event} size="sm" />}
</ButtonGroup>
<Box flexGrow={1} />
<NoteRelays event={event} size="sm" variant="link" />

View File

@ -15,11 +15,12 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import { ClipboardIcon, CodeIcon, LikeIcon, ShareIcon } from "../icons";
import { ClipboardIcon, CodeIcon, LikeIcon, RepostIcon } from "../icons";
import { getReferences } from "../../helpers/nostr-event";
import NoteReactionsModal from "./note-zaps-modal";
import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
import NoteDebugModal from "../debug-modals/note-debug-modal";
function getShareLink(eventId: string) {
const relays = getEventRelays(eventId).value;
@ -43,7 +44,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Zaps/Reactions
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + getShareLink(event.id))} icon={<ShareIcon />}>
<MenuItem onClick={() => copyToClipboard("nostr:" + getShareLink(event.id))} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
{noteId && (
@ -56,19 +57,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<Modal isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Raw Event</ModalHeader>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
Raw JSON:
<pre>{JSON.stringify(event, null, 2)}</pre>
Parsed Refs:
<pre>{JSON.stringify(getReferences(event), null, 2)}</pre>
</ModalBody>
</ModalContent>
</Modal>
<NoteDebugModal event={event} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />

View File

@ -1,10 +1,95 @@
import { Input, InputProps } from "@chakra-ui/react";
import {
Badge,
Box,
Button,
Flex,
Highlight,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputProps,
InputRightElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useState } from "react";
import { useAsync } from "react-use";
import { unique } from "../helpers/array";
import { RelayIcon, SearchIcon } from "./icons";
function RelayPickerModal({
onSelect,
onClose,
...props
}: { onSelect: (relay: string) => void } & Omit<ModalProps, "children">) {
const [search, setSearch] = useState("");
const { value: onlineRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
);
const { value: paidRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/paid").then((res) => res.json() as Promise<string[]>)
);
const relayList = unique(onlineRelays ?? []);
const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList;
return (
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Pick Relay</ModalHeader>
<ModalCloseButton />
<ModalBody pt="0" px="4" pb="4">
<InputGroup mb="2">
<InputLeftElement pointerEvents="none" children={<SearchIcon />} />
<Input
type="search"
placeholder="Search"
name="relay-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</InputGroup>
<Flex gap="2" direction="column">
{filteredRelays.map((url) => (
<Flex gap="2" alignItems="center">
<Button
key={url}
value={url}
onClick={() => {
onSelect(url);
onClose();
}}
variant="outline"
size="sm"
>
{url}
</Button>
{paidRelays?.includes(url) && <Badge colorScheme="green">Paid</Badge>}
</Flex>
))}
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}
export type RelayUrlInputProps = Omit<InputProps, "type">;
export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => {
export const RelayUrlInput = ({
onChange,
...props
}: Omit<RelayUrlInputProps, "onChange"> & { onChange: (url: string) => void }) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const { value: relaysJson } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
);
@ -12,14 +97,20 @@ export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => {
return (
<>
<Input list="relay-suggestions" type="url" {...props} />
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
<InputGroup>
<Input list="relay-suggestions" type="url" onChange={(e) => onChange(e.target.value)} {...props} />
<datalist id="relay-suggestions">
{relaySuggestions.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</datalist>
<InputRightElement>
<IconButton icon={<RelayIcon />} aria-label="Pick from list" size="sm" onClick={onOpen} />
</InputRightElement>
</InputGroup>
<RelayPickerModal onClose={onClose} isOpen={isOpen} onSelect={(url) => onChange(url)} size="2xl" />
</>
);
};

View File

@ -0,0 +1,47 @@
import { Box, Flex, Heading, SkeletonText } from "@chakra-ui/react";
import { useAsync } from "react-use";
import clientRelaysService from "../services/client-relays";
import singleEventService from "../services/single-event";
import { isETag, NostrEvent } from "../types/nostr-event";
import { ErrorFallback } from "./error-boundary";
import { Note } from "./note";
import { NoteMenu } from "./note/note-menu";
import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity";
import { UserLink } from "./user-link";
export default function RepostNote({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) {
const {
value: repostNote,
loading,
error,
} = useAsync(async () => {
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
if (eventId) {
return singleEventService.requestEvent(eventId, relay ? [relay] : clientRelaysService.getReadUrls());
}
return null;
}, [event]);
return (
<Flex gap="2" direction="column">
<Flex gap="2" alignItems="center" pl="1">
<UserAvatar pubkey={event.pubkey} size="xs" />
<Heading size="sm" display="inline">
<UserLink pubkey={event.pubkey} />
</Heading>
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<span>Shared note</span>
<Box flex={1} />
{import.meta.env.DEV && <NoteMenu event={event} size="sm" variant="link" aria-label="note options" />}
</Flex>
{loading ? (
<SkeletonText />
) : repostNote ? (
<Note event={repostNote} maxHeight={maxHeight} />
) : (
<ErrorFallback error={error} />
)}
</Flex>
);
}

View File

@ -74,7 +74,7 @@ export default function ZapModal({
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
amount: initialAmount ?? 10,
amount: initialAmount ?? zapAmounts[0],
comment: initialComment ?? "",
},
});

View File

@ -6,11 +6,11 @@ import accountService from "../services/account";
import { Kind } from "nostr-tools";
export function isReply(event: NostrEvent | DraftNostrEvent) {
return !!getReferences(event).replyId;
return event.kind === 1 && !!getReferences(event).replyId;
}
export function isNote(event: NostrEvent | DraftNostrEvent) {
return !isReply(event);
export function isRepost(event: NostrEvent | DraftNostrEvent) {
return event.kind === 6;
}
export function truncatedId(id: string, keep = 6) {
@ -112,7 +112,22 @@ export function buildReply(event: NostrEvent, account = accountService.current.v
};
}
export function buildShare(event: NostrEvent): DraftNostrEvent {
export function buildRepost(event: NostrEvent): DraftNostrEvent {
const relay = getEventRelays(event.id).value?.[0] ?? "";
const tags: NostrEvent["tags"] = [];
tags.push(["e", event.id, relay]);
tags.push(["p", event.pubkey]);
return {
kind: 6, //Kind.Repost
tags,
content: "",
created_at: moment().unix(),
};
}
export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
const relay = getEventRelays(event.id).value?.[0] ?? "";
const tags: NostrEvent["tags"] = [];

View File

@ -5,7 +5,7 @@ import { Providers } from "./providers";
// register nostr: protocol handler
try {
navigator.registerProtocolHandler("web+nostr", new URL("/nostr-link?q=%s", location.origin).toString());
navigator.registerProtocolHandler("web+nostr", new URL("/l/%s", location.origin).toString());
} catch (e) {
console.log("Failed to register handler");
console.log(e);

View File

@ -3,7 +3,7 @@ import { Button, Flex, Spinner } from "@chakra-ui/react";
import moment from "moment";
import { Note } from "../../components/note";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isNote } from "../../helpers/nostr-event";
import { isReply } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
@ -75,7 +75,7 @@ export default function DiscoverTab() {
{ pageSize: moment.duration(1, "hour").asSeconds(), enabled: throttledPubkeys.length > 0 }
);
const timeline = events.filter(isNote);
const timeline = events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="2">

View File

@ -2,7 +2,7 @@ import { Button, Flex, FormControl, FormLabel, Spinner, Switch } from "@chakra-u
import { useSearchParams } from "react-router-dom";
import moment from "moment";
import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event";
import { isReply } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { AddIcon } from "@chakra-ui/icons";
@ -10,6 +10,7 @@ import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import RepostNote from "../../components/repost-note";
export default function FollowingTab() {
const account = useCurrentAccount();
@ -26,11 +27,11 @@ export default function FollowingTab() {
const { events, loading, loadMore } = useTimelineLoader(
`${account.pubkey}-following-posts`,
relays,
{ authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() },
{ authors: following, kinds: [1, 6], since: moment().subtract(2, "hour").unix() },
{ pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 }
);
const timeline = showReplies ? events : events.filter(isNote);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="2">
@ -43,9 +44,13 @@ export default function FollowingTab() {
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={600} />
))}
{timeline.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={600} />
) : (
<Note key={event.id} event={event} maxHeight={600} />
)
)}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);

View File

@ -3,7 +3,7 @@ import moment from "moment";
import { useSearchParams } from "react-router-dom";
import { Note } from "../../components/note";
import { unique } from "../../helpers/array";
import { isNote } from "../../helpers/nostr-event";
import { isReply } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
@ -29,7 +29,7 @@ export default function GlobalTab() {
{ pageSize: moment.duration(5, "minutes").asSeconds() }
);
const timeline = showReplies ? events : events.filter(isNote);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
return (
<Flex direction="column" gap="2">

View File

@ -1,5 +1,5 @@
import { Alert, AlertIcon, AlertTitle, Spinner } from "@chakra-ui/react";
import { Navigate, useSearchParams } from "react-router-dom";
import { Navigate, useParams } from "react-router-dom";
import { Kind, nip19 } from "nostr-tools";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import useSingleEvent from "../../hooks/use-single-event";
@ -26,7 +26,7 @@ export function NoteLinkHandler({ eventId, relays }: { eventId: string; relays?:
</Alert>
);
if (event.kind !== Kind.Text)
if (event.kind !== Kind.Text && event.kind !== 6)
return (
<Alert status="error">
<AlertIcon />
@ -38,10 +38,9 @@ export function NoteLinkHandler({ eventId, relays }: { eventId: string; relays?:
}
export default function NostrLinkView() {
const [searchParams] = useSearchParams();
const rawLink = searchParams.get("q");
const { link } = useParams() as { link?: string };
if (!rawLink)
if (!link)
return (
<Alert status="warning">
<AlertIcon />
@ -49,7 +48,7 @@ export default function NostrLinkView() {
</Alert>
);
const cleanLink = rawLink.replace(/(web\+)?nostr:/, "");
const cleanLink = link.replace(/(web\+)?nostr:/, "");
const decoded = nip19.decode(cleanLink);
if (decoded.type === "npub") return <NpubLinkHandler pubkey={decoded.data as string} />;

View File

@ -121,7 +121,7 @@ export default function LoginNip05View() {
placeholder="wss://nostr.example.com"
isRequired
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
onChange={(url) => setRelayUrl(url)}
/>
<FormHelperText>The first relay to connect to.</FormHelperText>
</FormControl>

View File

@ -46,7 +46,7 @@ export default function LoginNpubView() {
placeholder="wss://nostr.example.com"
isRequired
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
onChange={(url) => setRelayUrl(url)}
/>
<FormHelperText>The first relay to connect to.</FormHelperText>
</FormControl>

View File

@ -137,7 +137,7 @@ export default function LoginNsecView() {
placeholder="wss://nostr.example.com"
isRequired
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
onChange={(url) => setRelayUrl(url)}
/>
<FormHelperText>The first relay to connect to.</FormHelperText>
</FormControl>

View File

@ -134,7 +134,7 @@ export default function RelaysView() {
<RelayUrlInput
id="relay-url-input"
value={relayInputValue}
onChange={(e) => setRelayInputValue(e.target.value)}
onChange={(url) => setRelayInputValue(url)}
isRequired
/>
<Button type="submit" isDisabled={saving}>

View File

@ -90,7 +90,11 @@ export default function SearchView() {
// set the search when the form is submitted
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
setSearchParams({ q: search }, { replace: true });
if (search.startsWith("nostr:")) {
navigate({ pathname: "/l/" + search }, { replace: true });
} else {
setSearchParams({ q: search }, { replace: true });
}
};
// fetch search data from nostr.band
@ -105,7 +109,7 @@ export default function SearchView() {
const handleQrCodeData = (text: string) => {
// if its a nostr: link pass it on the the link handler
if (text.startsWith("nostr:")) {
navigate({ pathname: "/nostr-link", search: `q=${text}` }, { replace: true });
navigate({ pathname: "/l", search: `q=${text}` }, { replace: true });
} else {
setSearchParams({ q: text }, { replace: true });
}

View File

@ -12,6 +12,8 @@ import {
Tab,
TabPanels,
TabPanel,
Input,
Flex,
} from "@chakra-ui/react";
import { useMemo } from "react";
import { RelayMode } from "../../../classes/relay";
@ -21,6 +23,7 @@ import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
import useFallbackUserRelays from "../../../hooks/use-fallback-user-relays";
import relayScoreboardService from "../../../services/relay-scoreboard";
import { nip19 } from "nostr-tools";
import { CopyIconButton } from "../../../components/copy-icon-button";
function useUserShareLink(pubkey: string) {
const userRelays = useFallbackUserRelays(pubkey);
@ -50,16 +53,24 @@ export const QrIconButton = ({ pubkey, ...props }: { pubkey: string } & Omit<Ico
<ModalBody p="2">
<Tabs>
<TabList>
<Tab>npub</Tab>
<Tab>nprofile</Tab>
<Tab>npub</Tab>
</TabList>
<TabPanels>
<TabPanel>
<QrCodeSvg content={"nostr:" + npub} border={2} />
</TabPanel>
<TabPanel>
<TabPanel p="0" pt="2">
<QrCodeSvg content={"nostr:" + nprofile} border={2} />
<Flex gap="2" mt="2">
<Input readOnly value={"nostr:" + nprofile} />
<CopyIconButton text={"nostr:" + nprofile} aria-label="copy nprofile" />
</Flex>
</TabPanel>
<TabPanel p="0" pt="2">
<QrCodeSvg content={"nostr:" + npub} border={2} />
<Flex gap="2" mt="2">
<Input readOnly value={"nostr:" + npub} />
<CopyIconButton text={"nostr:" + npub} aria-label="copy npub" />
</Flex>
</TabPanel>
</TabPanels>
</Tabs>

View File

@ -21,7 +21,8 @@ import { useOutletContext } from "react-router-dom";
import { RelayMode } from "../../classes/relay";
import { RelayIcon } from "../../components/icons";
import { Note } from "../../components/note";
import { isNote, truncatedId } from "../../helpers/nostr-event";
import RepostNote from "../../components/repost-note";
import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useFallbackUserRelays from "../../hooks/use-fallback-user-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
@ -39,22 +40,31 @@ const UserNotesTab = () => {
const relays = userRelays.length === 0 ? readRelays : relayScoreboardService.getRankedRelays(userRelays).slice(0, 4);
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const { events, loading, loadMore } = useTimelineLoader(
`${truncatedId(pubkey)}-notes`,
relays,
{ authors: [pubkey], kinds: [1] },
{ authors: [pubkey], kinds: [1, 6] },
{ pageSize: moment.duration(2, "day").asSeconds(), startLimit: 20 }
);
const timeline = showReplies ? events : events.filter(isNote);
const timeline = events.filter((event) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
});
return (
<Flex direction="column" gap="2" pr="2" pl="2">
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
</FormLabel>
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={toggleReplies} />
<Box flexGrow={1} />
<Popover>
<PopoverTrigger>
@ -75,9 +85,13 @@ const UserNotesTab = () => {
</PopoverContent>
</Popover>
</FormControl>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={1200} />
))}
{timeline.map((event) =>
event.kind === 6 ? (
<RepostNote key={event.id} event={event} maxHeight={1200} />
) : (
<Note key={event.id} event={event} maxHeight={1200} />
)
)}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);

View File

@ -4207,7 +4207,7 @@ normalize-package-data@^2.5.0:
semver "2 || 3 || 4 || 5"
validate-npm-package-license "^3.0.1"
nostr-tools@^1.7.4:
nostr-tools@^1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.8.3.tgz#10ddb8ed5d9ca3bf6c1e8fdd1961bb6584b8e1f2"
integrity sha512-0giVDk0ElhqlGY032ma/8Q8NsIyFL53fCCkndFCpuLabZ2E134Kth0sbnIIIFXLqm7VnYIlgLVtCna8+dUiZUg==