diff --git a/.changeset/brown-snails-suffer.md b/.changeset/brown-snails-suffer.md new file mode 100644 index 000000000..7f3b12405 --- /dev/null +++ b/.changeset/brown-snails-suffer.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for kind 6 reposts diff --git a/.changeset/chilled-crabs-relate.md b/.changeset/chilled-crabs-relate.md new file mode 100644 index 000000000..c5caabb9c --- /dev/null +++ b/.changeset/chilled-crabs-relate.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add copy button to user QrCode modal diff --git a/package.json b/package.json index 766e4578e..f57b3a1c9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.tsx b/src/app.tsx index 5b54ad137..26d6b15c9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -119,7 +119,7 @@ const router = createBrowserRouter([ { path: "dm", element: }, { path: "dm/:key", element: }, { path: "profile", element: }, - { path: "nostr-link", element: }, + { path: "l/:link", element: }, { path: "", element: , diff --git a/src/components/debug-modals/note-debug-modal.tsx b/src/components/debug-modals/note-debug-modal.tsx new file mode 100644 index 000000000..afa33c86e --- /dev/null +++ b/src/components/debug-modals/note-debug-modal.tsx @@ -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) { + return ( + + + + Event Debug + + + + + + + + + + + + ); +} diff --git a/src/components/debug-modals/raw-block.tsx b/src/components/debug-modals/raw-block.tsx new file mode 100644 index 000000000..3e818b1a0 --- /dev/null +++ b/src/components/debug-modals/raw-block.tsx @@ -0,0 +1,16 @@ +import { Box, Code, Flex, Heading } from "@chakra-ui/react"; + +export default function RawJson({ json, heading }: { heading: string; json: any }) { + return ( + + + {heading} + + + + {JSON.stringify(json, null, 2)} + + + + ); +} diff --git a/src/components/debug-modals/raw-value.tsx b/src/components/debug-modals/raw-value.tsx new file mode 100644 index 000000000..ba0449f77 --- /dev/null +++ b/src/components/debug-modals/raw-value.tsx @@ -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 ( + + + {heading} + + + + {value} + + + + + ); +} diff --git a/src/components/debug-modals/user-debug-modal.tsx b/src/components/debug-modals/user-debug-modal.tsx new file mode 100644 index 000000000..799193edf --- /dev/null +++ b/src/components/debug-modals/user-debug-modal.tsx @@ -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) { + const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]); + const metadata = useUserMetadata(pubkey); + + return ( + + + + + + + + {npub && } + + + + + + ); +} diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index daf4fb612..c720038d1 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -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) { return ( Something went wrong - {error.message} + {error?.message} ); } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index aa82044e1..93e7820f4 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, }); diff --git a/src/components/note/buttons/quote-repost-button.tsx b/src/components/note/buttons/quote-repost-button.tsx new file mode 100644 index 000000000..3d3892f82 --- /dev/null +++ b/src/components/note/buttons/quote-repost-button.tsx @@ -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 ( + } + onClick={handleClick} + aria-label="Quote repost" + title="Quote repost" + isDisabled={account.readonly} + /> + ); +} diff --git a/src/components/note/note-like-button.tsx b/src/components/note/buttons/reaction-button.tsx similarity index 73% rename from src/components/note/note-like-button.tsx rename to src/components/note/buttons/reaction-button.tsx index 811b56a97..dc204b56b 100644 --- a/src/components/note/note-like-button.tsx +++ b/src/components/note/buttons/reaction-button.tsx @@ -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) { +export default function ReactionButton({ note, ...props }: { note: NostrEvent } & Omit) { const { requestSignature } = useSigningContext(); const account = useCurrentAccount(); diff --git a/src/components/note/buttons/reply-button.tsx b/src/components/note/buttons/reply-button.tsx new file mode 100644 index 000000000..3606bbc65 --- /dev/null +++ b/src/components/note/buttons/reply-button.tsx @@ -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 ( + } title="Reply" aria-label="Reply" onClick={reply} isDisabled={account.readonly} /> + ); +} diff --git a/src/components/note/buttons/repost-button.tsx b/src/components/note/buttons/repost-button.tsx new file mode 100644 index 000000000..0718bb941 --- /dev/null +++ b/src/components/note/buttons/repost-button.tsx @@ -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 ( + } + onClick={handleClick} + aria-label="Repost Note" + title="Repost Note" + isDisabled={account.readonly} + isLoading={loading} + /> + ); +} diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 30bf3a188..e82294e88 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -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 ( @@ -81,27 +76,12 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP /> - } - title="Reply" - aria-label="Reply" - onClick={reply} - size="sm" - isDisabled={account.readonly} - /> - } - onClick={share} - aria-label="Share Note" - title="Share Note" - size="sm" - isDisabled={account.readonly} - /> + + + - {showReactions && } + {showReactions && } diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 1417811f9..3cec9d7f7 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -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}> Zaps/Reactions - copyToClipboard("nostr:" + getShareLink(event.id))} icon={}> + copyToClipboard("nostr:" + getShareLink(event.id))} icon={}> Copy Share Link {noteId && ( @@ -56,19 +57,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit {infoModal.isOpen && ( - - - - Raw Event - - - Raw JSON: - {JSON.stringify(event, null, 2)} - Parsed Refs: - {JSON.stringify(getReferences(event), null, 2)} - - - + )} {reactionsModal.isOpen && ( diff --git a/src/components/relay-url-input.tsx b/src/components/relay-url-input.tsx index 5f896fdf4..350f06cc1 100644 --- a/src/components/relay-url-input.tsx +++ b/src/components/relay-url-input.tsx @@ -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) { + const [search, setSearch] = useState(""); + const { value: onlineRelays } = useAsync(async () => + fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise) + ); + const { value: paidRelays } = useAsync(async () => + fetch("https://api.nostr.watch/v1/paid").then((res) => res.json() as Promise) + ); + const relayList = unique(onlineRelays ?? []); + + const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList; + + return ( + + + + Pick Relay + + + + } /> + setSearch(e.target.value)} + /> + + + {filteredRelays.map((url) => ( + + { + onSelect(url); + onClose(); + }} + variant="outline" + size="sm" + > + {url} + + {paidRelays?.includes(url) && Paid} + + ))} + + + + + ); +} export type RelayUrlInputProps = Omit; -export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => { +export const RelayUrlInput = ({ + onChange, + ...props +}: Omit & { 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) ); @@ -12,14 +97,20 @@ export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => { return ( <> - - - {relaySuggestions.map((url) => ( - - {url} - - ))} - + + onChange(e.target.value)} {...props} /> + + {relaySuggestions.map((url) => ( + + {url} + + ))} + + + } aria-label="Pick from list" size="sm" onClick={onOpen} /> + + + onChange(url)} size="2xl" /> > ); }; diff --git a/src/components/repost-note.tsx b/src/components/repost-note.tsx new file mode 100644 index 000000000..d30843e39 --- /dev/null +++ b/src/components/repost-note.tsx @@ -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 ( + + + + + + + + Shared note + + {import.meta.env.DEV && } + + {loading ? ( + + ) : repostNote ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx index 45fc7407a..a69e080a9 100644 --- a/src/components/zap-modal.tsx +++ b/src/components/zap-modal.tsx @@ -74,7 +74,7 @@ export default function ZapModal({ } = useForm({ mode: "onBlur", defaultValues: { - amount: initialAmount ?? 10, + amount: initialAmount ?? zapAmounts[0], comment: initialComment ?? "", }, }); diff --git a/src/helpers/nostr-event.ts b/src/helpers/nostr-event.ts index ce994c038..24dd2fa89 100644 --- a/src/helpers/nostr-event.ts +++ b/src/helpers/nostr-event.ts @@ -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"] = []; diff --git a/src/index.tsx b/src/index.tsx index 74b346c6f..02401761d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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); diff --git a/src/views/home/discover-tab.tsx b/src/views/home/discover-tab.tsx index 55fc21336..5c054031d 100644 --- a/src/views/home/discover-tab.tsx +++ b/src/views/home/discover-tab.tsx @@ -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 ( diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index 7dc697c9f..0b953c6ff 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -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 ( @@ -43,9 +44,13 @@ export default function FollowingTab() { - {timeline.map((event) => ( - - ))} + {timeline.map((event) => + event.kind === 6 ? ( + + ) : ( + + ) + )} {loading ? : loadMore()}>Load More} ); diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index 7083e9ab2..ee5c2bdcb 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -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 ( diff --git a/src/views/link/index.tsx b/src/views/link/index.tsx index 82d7f989c..20d0340e9 100644 --- a/src/views/link/index.tsx +++ b/src/views/link/index.tsx @@ -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?: ); - if (event.kind !== Kind.Text) + if (event.kind !== Kind.Text && event.kind !== 6) return ( @@ -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 ( @@ -49,7 +48,7 @@ export default function NostrLinkView() { ); - const cleanLink = rawLink.replace(/(web\+)?nostr:/, ""); + const cleanLink = link.replace(/(web\+)?nostr:/, ""); const decoded = nip19.decode(cleanLink); if (decoded.type === "npub") return ; diff --git a/src/views/login/nip05.tsx b/src/views/login/nip05.tsx index 8e51a9036..92f2d4004 100644 --- a/src/views/login/nip05.tsx +++ b/src/views/login/nip05.tsx @@ -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)} /> The first relay to connect to. diff --git a/src/views/login/npub.tsx b/src/views/login/npub.tsx index ad511e256..8677d9b99 100644 --- a/src/views/login/npub.tsx +++ b/src/views/login/npub.tsx @@ -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)} /> The first relay to connect to. diff --git a/src/views/login/nsec.tsx b/src/views/login/nsec.tsx index fd4df736b..c9ea0415e 100644 --- a/src/views/login/nsec.tsx +++ b/src/views/login/nsec.tsx @@ -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)} /> The first relay to connect to. diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 68653b6f1..0292934fc 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -134,7 +134,7 @@ export default function RelaysView() { setRelayInputValue(e.target.value)} + onChange={(url) => setRelayInputValue(url)} isRequired /> diff --git a/src/views/search/index.tsx b/src/views/search/index.tsx index 3f9be07d4..61ca12b44 100644 --- a/src/views/search/index.tsx +++ b/src/views/search/index.tsx @@ -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 }); } diff --git a/src/views/user/components/share-qr-button.tsx b/src/views/user/components/share-qr-button.tsx index 600ea461c..d6f5edfd4 100644 --- a/src/views/user/components/share-qr-button.tsx +++ b/src/views/user/components/share-qr-button.tsx @@ -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 - npub nprofile + npub - - - - + + + + + + + + + + + + diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index fd559cc54..9cf65c3a4 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -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 ( - - Show Replies + + + Replies + + + + Reposts - @@ -75,9 +85,13 @@ const UserNotesTab = () => { - {timeline.map((event) => ( - - ))} + {timeline.map((event) => + event.kind === 6 ? ( + + ) : ( + + ) + )} {loading ? : loadMore()}>Load More} ); diff --git a/yarn.lock b/yarn.lock index 8fc234ff5..d85ac6bc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==
+ {JSON.stringify(json, null, 2)} +
+ {value} +
{JSON.stringify(event, null, 2)}
{JSON.stringify(getReferences(event), null, 2)}