diff --git a/.changeset/brave-mayflies-laugh.md b/.changeset/brave-mayflies-laugh.md new file mode 100644 index 000000000..98db919db --- /dev/null +++ b/.changeset/brave-mayflies-laugh.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Dont proxy main user profile image diff --git a/.changeset/brown-lies-hide.md b/.changeset/brown-lies-hide.md new file mode 100644 index 000000000..eaed9c36d --- /dev/null +++ b/.changeset/brown-lies-hide.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Dont blur images on shared notes diff --git a/.changeset/fuzzy-pumpkins-allow.md b/.changeset/fuzzy-pumpkins-allow.md new file mode 100644 index 000000000..b8dd7ff6f --- /dev/null +++ b/.changeset/fuzzy-pumpkins-allow.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Make all note links nevent diff --git a/.changeset/good-mails-play.md b/.changeset/good-mails-play.md new file mode 100644 index 000000000..6a9b76eb3 --- /dev/null +++ b/.changeset/good-mails-play.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Trim note content diff --git a/.changeset/wise-gorillas-jog.md b/.changeset/wise-gorillas-jog.md new file mode 100644 index 000000000..3eace8701 --- /dev/null +++ b/.changeset/wise-gorillas-jog.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Update nostr-tools dependency diff --git a/.changeset/witty-seals-attack.md b/.changeset/witty-seals-attack.md new file mode 100644 index 000000000..2ee19f995 --- /dev/null +++ b/.changeset/witty-seals-attack.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Fix link regexp diff --git a/package.json b/package.json index 37559a5e7..78f70219d 100644 --- a/package.json +++ b/package.json @@ -5,41 +5,41 @@ "license": "MIT", "scripts": { "start": "vite serve", - "build": "tsc && vite build", + "build": "tsc --project tsconfig.json && vite build", "format": "prettier --ignore-path .prettierignore -w ." }, "dependencies": { - "@chakra-ui/icons": "^2.0.14", - "@chakra-ui/react": "^2.4.4", - "@changesets/cli": "^2.26.1", - "@emotion/react": "^11.10.5", - "@emotion/styled": "^11.10.5", + "@chakra-ui/icons": "^2.0.19", + "@chakra-ui/react": "^2.6.1", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", "bech32": "^2.0.0", "framer-motion": "^7.10.3", "idb": "^7.1.1", "identicon.js": "^2.3.3", - "light-bolt11-decoder": "^2.1.0", + "light-bolt11-decoder": "^3.0.0", "moment": "^2.29.4", "noble-secp256k1": "^1.2.14", - "nostr-tools": "^1.8.3", + "nostr-tools": "^1.11.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-error-boundary": "^3.1.4", - "react-hook-form": "^7.43.1", + "react-error-boundary": "^4.0.4", + "react-hook-form": "^7.43.9", "react-qr-barcode-scanner": "^1.0.6", - "react-router-dom": "^6.5.0", + "react-router-dom": "^6.11.2", "react-singleton-hook": "^4.0.1", "react-use": "^17.4.0", "webln": "^0.3.2" }, "devDependencies": { + "@changesets/cli": "^2.26.1", "@types/identicon.js": "^2.3.1", - "@types/react": "^18.0.26", - "@types/react-dom": "^18.0.9", - "@vitejs/plugin-react": "^3.0.0", - "prettier": "^2.8.1", - "typescript": "^4.9.4", - "vite": "^4.0.2", - "vite-plugin-pwa": "^0.14.1" + "@types/react": "^18.2.7", + "@types/react-dom": "^18.2.4", + "@vitejs/plugin-react": "^4.0.0", + "prettier": "^2.8.8", + "typescript": "^5.0.4", + "vite": "^4.3.8", + "vite-plugin-pwa": "^0.15.1" } } diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx index 4b082fa89..bf1b8607e 100644 --- a/src/components/embed-types/common.tsx +++ b/src/components/embed-types/common.tsx @@ -2,6 +2,7 @@ import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react"; import { EmbedableContent, embedJSX } from "../../helpers/embeds"; import appSettings from "../../services/app-settings"; import { ImageGalleryLink } from "../image-gallery"; +import { useIsMobile } from "../../hooks/use-is-mobile"; const BlurredImage = (props: ImageProps) => { const { isOpen, onOpen } = useDisclosure(); @@ -12,24 +13,26 @@ const BlurredImage = (props: ImageProps) => { ); }; +const EmbeddedImage = ({ src, blue }: { src: string; blue: boolean }) => { + const isMobile = useIsMobile(); + const ImageComponent = blue || !appSettings.value.blurImages ? Image : BlurredImage; + const thumbnail = appSettings.value.imageProxy + ? new URL(`/256,fit/${src}`, appSettings.value.imageProxy).toString() + : src; + + return ( + + + + ); +}; + // note1n06jceulg3gukw836ghd94p0ppwaz6u3mksnnz960d8vlcp2fnqsgx3fu9 export function embedImages(content: EmbedableContent, trusted = false) { return embedJSX(content, { regexp: /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i, - render: (match) => { - const ImageComponent = trusted || !appSettings.value.blurImages ? Image : BlurredImage; - const thumbnail = appSettings.value.imageProxy - ? new URL(`/256,fit/${match[0]}`, appSettings.value.imageProxy).toString() - : match[0]; - const src = match[0]; - - return ( - - - - ); - }, + render: (match) => , name: "Image", }); } @@ -44,12 +47,12 @@ export function embedVideos(content: EmbedableContent) { } // based on http://urlregex.com/ -// note1c34vht0lu2qzrgr4az3u8jn5xl3fycr2gfpahkepthg7hzlqg26sr59amt +// nostr:nevent1qqsvg6kt4hl79qpp5p673g7ref6r0c5jvp4yys7mmvs4m50t30sy9dgpp4mhxue69uhkummn9ekx7mqpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet59dl66z +// nostr:nevent1qqsymds0vlpp4f5s0dckjf4qz283pdsen0rmx8lu7ct6hpnxag2hpacpremhxue69uhkummnw3ez6un9d3shjtnwda4k7arpwfhjucm0d5q3qamnwvaz7tmwdaehgu3wwa5kueghxyq76 export function embedLinks(content: EmbedableContent) { return embedJSX(content, { name: "Link", - regexp: - /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i, + regexp: /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})(\/[\+~%\/\.\w\-_]*)?([\?#][^\s]+)?/i, render: (match) => ( {match[0]} diff --git a/src/components/embeded-note.tsx b/src/components/embeded-note.tsx deleted file mode 100644 index c2dd9b2bc..000000000 --- a/src/components/embeded-note.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Link as RouterLink } from "react-router-dom"; -import moment from "moment"; -import { Card, CardBody, CardHeader, Flex, Heading, Link } from "@chakra-ui/react"; -import { useIsMobile } from "../hooks/use-is-mobile"; -import { NoteContents } from "./note/note-contents"; -import { useUserContacts } from "../hooks/use-user-contacts"; -import { useCurrentAccount } from "../hooks/use-current-account"; -import { NostrEvent } from "../types/nostr-event"; -import { UserAvatarLink } from "./user-avatar-link"; -import { UserLink } from "./user-link"; -import { UserDnsIdentityIcon } from "./user-dns-identity"; -import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19"; -import { convertTimestampToDate } from "../helpers/date"; -import useSubject from "../hooks/use-subject"; -import appSettings from "../services/app-settings"; -import EventVerificationIcon from "./event-verification-icon"; -import { useReadRelayUrls } from "../hooks/use-client-relays"; - -const EmbeddedNote = ({ note }: { note: NostrEvent }) => { - const account = useCurrentAccount(); - const { showSignatureVerification } = useSubject(appSettings); - - const readRelays = useReadRelayUrls(); - const contacts = useUserContacts(account.pubkey, readRelays); - const following = contacts?.contacts || []; - - return ( - - - - - - - - - - - {showSignatureVerification && } - - {moment(convertTimestampToDate(note.created_at)).fromNow()} - - - - - - - - ); -}; - -export default EmbeddedNote; diff --git a/src/components/note-link.tsx b/src/components/note-link.tsx index 45cdf6024..828930571 100644 --- a/src/components/note-link.tsx +++ b/src/components/note-link.tsx @@ -1,18 +1,31 @@ import { Link, LinkProps } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; -import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19"; import { truncatedId } from "../helpers/nostr-event"; +import { nip19 } from "nostr-tools"; +import { getEventRelays } from "../services/event-relays"; +import relayScoreboardService from "../services/relay-scoreboard"; +import { useMemo } from "react"; + +export function getSharableEncodedNoteId(eventId: string) { + const relays = getEventRelays(eventId).value; + const ranked = relayScoreboardService.getRankedRelays(relays); + const onlyTwo = ranked.slice(0, 2); + + if (onlyTwo.length > 0) { + return nip19.neventEncode({ id: eventId, relays: onlyTwo }); + } else return nip19.noteEncode(eventId); +} export type NoteLinkProps = LinkProps & { noteId: string; }; -export const NoteLink = ({ noteId, ...props }: NoteLinkProps) => { - const note1 = normalizeToBech32(noteId, Bech32Prefix.Note) ?? noteId; +export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => { + const encoded = useMemo(() => getSharableEncodedNoteId(noteId), [noteId]); return ( - - {truncatedId(note1)} + + {children || truncatedId(nip19.noteEncode(noteId))} ); }; diff --git a/src/components/note/embeded-note.tsx b/src/components/note/embeded-note.tsx new file mode 100644 index 000000000..15c8e6a7f --- /dev/null +++ b/src/components/note/embeded-note.tsx @@ -0,0 +1,44 @@ +import { Link as RouterLink } from "react-router-dom"; +import moment from "moment"; +import { Card, CardBody, CardHeader, Flex, Heading, Link } from "@chakra-ui/react"; +import { NoteContents } from "./note-contents"; +import { NostrEvent } from "../../types/nostr-event"; +import { UserAvatarLink } from "../user-avatar-link"; +import { UserLink } from "../user-link"; +import { UserDnsIdentityIcon } from "../user-dns-identity"; +import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19"; +import { convertTimestampToDate } from "../../helpers/date"; +import useSubject from "../../hooks/use-subject"; +import appSettings from "../../services/app-settings"; +import EventVerificationIcon from "../event-verification-icon"; +import { TrustProvider } from "./trust"; +import { NoteLink } from "../note-link"; + +export default function EmbeddedNote({ note }: { note: NostrEvent }) { + const { showSignatureVerification } = useSubject(appSettings); + + return ( + + + + + + + + + + + + {showSignatureVerification && } + + {moment(convertTimestampToDate(note.created_at)).fromNow()} + + + + + + + + + ); +} diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 85b0cb53a..2c91a6969 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -1,13 +1,7 @@ import React, { useMemo } from "react"; -import { Link as RouterLink } from "react-router-dom"; import moment from "moment"; import { - Alert, - AlertDescription, - AlertIcon, - AlertTitle, Box, - Button, ButtonGroup, Card, CardBody, @@ -18,57 +12,29 @@ import { Heading, IconButton, Link, - Spacer, } from "@chakra-ui/react"; import { NostrEvent } from "../../types/nostr-event"; import { UserAvatarLink } from "../user-avatar-link"; -import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19"; -import { NoteContents } from "./note-contents"; import { NoteMenu } from "./note-menu"; -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 { UserDnsIdentityIcon } from "../user-dns-identity"; import { convertTimestampToDate } from "../../helpers/date"; -import { useCurrentAccount } from "../../hooks/use-current-account"; import ReactionButton from "./buttons/reaction-button"; import NoteZapButton from "./note-zap-button"; -import { ExpandProvider, useExpand } from "./expanded"; +import { ExpandProvider } from "./expanded"; import useSubject from "../../hooks/use-subject"; import appSettings from "../../services/app-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"; -import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { ExternalLinkIcon } from "../icons"; -import SensitiveContentWarning from "../sensitive-content-warning"; -import useAppSettings from "../../hooks/use-app-settings"; - -function NoteContentWithWarning({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) { - const account = useCurrentAccount(); - const expand = useExpand(); - const settings = useAppSettings(); - - const readRelays = useReadRelayUrls(); - const contacts = useUserContacts(account.pubkey, readRelays); - const following = contacts?.contacts || []; - - const contentWarning = event.tags.find((t) => t[0] === "content-warning")?.[1]; - const showContentWarning = settings.showContentWarning && contentWarning && !expand?.expanded; - - return showContentWarning ? ( - - ) : ( - - ); -} +import NoteContentWithWarning from "./note-content-with-warning"; +import { TrustProvider } from "./trust"; +import { NoteLink } from "../note-link"; export type NoteProps = { event: NostrEvent; @@ -83,50 +49,52 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr"), [event]); return ( - - - - - + + + + + + - - - - - - {showSignatureVerification && } - - {moment(convertTimestampToDate(event.created_at)).fromNow()} - - - - - - - - - - - - - {showReactions && } - - - {externalLink && ( - } - aria-label="Open External" - href={externalLink[1]} - size="sm" - variant="link" - target="_blank" - /> - )} - - - - - + + + + + + {showSignatureVerification && } + + {moment(convertTimestampToDate(event.created_at)).fromNow()} + + + + + + + + + + + + + {showReactions && } + + + {externalLink && ( + } + aria-label="Open External" + href={externalLink[1]} + size="sm" + variant="link" + target="_blank" + /> + )} + + + + + + ); }); diff --git a/src/components/note/note-content-with-warning.tsx b/src/components/note/note-content-with-warning.tsx new file mode 100644 index 000000000..2bca9d7b5 --- /dev/null +++ b/src/components/note/note-content-with-warning.tsx @@ -0,0 +1,20 @@ +import { NostrEvent } from "../../types/nostr-event"; + +import { NoteContents } from "./note-contents"; +import { useExpand } from "./expanded"; +import SensitiveContentWarning from "../sensitive-content-warning"; +import useAppSettings from "../../hooks/use-app-settings"; + +export default function NoteContentWithWarning({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) { + const expand = useExpand(); + const settings = useAppSettings(); + + const contentWarning = event.tags.find((t) => t[0] === "content-warning")?.[1]; + const showContentWarning = settings.showContentWarning && contentWarning && !expand?.expanded; + + return showContentWarning ? ( + + ) : ( + + ); +} diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx index 2cb493397..fd8f8e117 100644 --- a/src/components/note/note-contents.tsx +++ b/src/components/note/note-contents.tsx @@ -21,9 +21,10 @@ import { embedNostrHashtags, } from "../embed-types"; import { ImageGalleryProvider } from "../image-gallery"; +import { useTrusted } from "./trust"; -function buildContents(event: NostrEvent | DraftNostrEvent, trusted: boolean = false) { - let content: EmbedableContent = [event.content]; +function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) { + let content: EmbedableContent = [event.content.trim()]; content = embedLightningInvoice(content); content = embedTweet(content); @@ -59,12 +60,12 @@ const GradientOverlay = styled.div` export type NoteContentsProps = { event: NostrEvent | DraftNostrEvent; - trusted?: boolean; maxHeight?: number; }; -export const NoteContents = React.memo(({ event, trusted, maxHeight }: NoteContentsProps) => { - const content = buildContents(event, trusted ?? false); +export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => { + const trusted = useTrusted(); + const content = buildContents(event, trusted); const expand = useExpand(); const [innerHeight, setInnerHeight] = useState(0); const ref = useRef(null); diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index b4ae901da..1820da1ae 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -13,7 +13,6 @@ import { useToast, } from "@chakra-ui/react"; import { useCopyToClipboard } from "react-use"; -import { nip19 } from "nostr-tools"; import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19"; import { NostrEvent } from "../../types/nostr-event"; @@ -21,8 +20,6 @@ import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; import { ClipboardIcon, CodeIcon, LikeIcon, RepostIcon, TrashIcon } from "../icons"; 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"; import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCallback, useState } from "react"; @@ -31,16 +28,7 @@ import { buildDeleteEvent } from "../../helpers/nostr-event"; import signingService from "../../services/signing"; import { nostrPostAction } from "../../classes/nostr-post-action"; import clientRelaysService from "../../services/client-relays"; - -function getShareLink(eventId: string) { - const relays = getEventRelays(eventId).value; - const ranked = relayScoreboardService.getRankedRelays(relays); - const onlyTwo = ranked.slice(0, 2); - - if (onlyTwo.length > 0) { - return nip19.neventEncode({ id: eventId, relays: onlyTwo }); - } else return nip19.noteEncode(eventId); -} +import { getSharableEncodedNoteId } from "../note-link"; export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit) => { const account = useCurrentAccount(); @@ -80,7 +68,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit}> Zaps/Reactions - copyToClipboard("nostr:" + getShareLink(event.id))} icon={}> + copyToClipboard("nostr:" + getSharableEncodedNoteId(event.id))} icon={}> Copy Share Link {noteId && ( diff --git a/src/components/note/quote-note.tsx b/src/components/note/quote-note.tsx index c0dcf448c..c4232ab52 100644 --- a/src/components/note/quote-note.tsx +++ b/src/components/note/quote-note.tsx @@ -1,6 +1,6 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays"; import useSingleEvent from "../../hooks/use-single-event"; -import EmbeddedNote from "../embeded-note"; +import EmbeddedNote from "./embeded-note"; import { NoteLink } from "../note-link"; const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => { diff --git a/src/components/note/repost-note.tsx b/src/components/note/repost-note.tsx new file mode 100644 index 000000000..5f7b93cde --- /dev/null +++ b/src/components/note/repost-note.tsx @@ -0,0 +1,53 @@ +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 "."; +import { NoteMenu } from "./note-menu"; +import { UserAvatar } from "../user-avatar"; +import { UserDnsIdentityIcon } from "../user-dns-identity"; +import { UserLink } from "../user-link"; +import { unique } from "../../helpers/array"; +import { TrustProvider } from "./trust"; + +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) { + const readRelays = clientRelaysService.getReadUrls(); + if (relay) readRelays.push(relay); + return singleEventService.requestEvent(eventId, unique(readRelays)); + } + return null; + }, [event]); + + return ( + + + + + + + + + Shared note + + + + {loading ? ( + + ) : repostNote ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/note/trust.tsx b/src/components/note/trust.tsx new file mode 100644 index 000000000..e5110ae34 --- /dev/null +++ b/src/components/note/trust.tsx @@ -0,0 +1,28 @@ +import React, { PropsWithChildren, useContext } from "react"; +import { NostrEvent } from "../../types/nostr-event"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import { useUserContacts } from "../../hooks/use-user-contacts"; +import { useCurrentAccount } from "../../hooks/use-current-account"; + +const TrustContext = React.createContext(false); + +export function useTrusted() { + return useContext(TrustContext); +} + +export function TrustProvider({ + children, + event, + trust = false, +}: PropsWithChildren & { event?: NostrEvent; trust?: boolean }) { + const parentTrust = useContext(TrustContext); + + const account = useCurrentAccount(); + const readRelays = useReadRelayUrls(); + const contacts = useUserContacts(account.pubkey, readRelays); + const following = contacts?.contacts || []; + + const isEventTrusted = trust || (!!event && (event.pubkey === account.pubkey || following.includes(event.pubkey))); + + return {children}; +} diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index c24a6b2bd..45dd13a68 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -27,6 +27,7 @@ import { ImageIcon } from "../icons"; import { NoteLink } from "../note-link"; import { NoteContents } from "../note/note-contents"; import { PostResults } from "./post-results"; +import { TrustProvider } from "../note/trust"; function emptyDraft(): DraftNostrEvent { return { @@ -139,7 +140,9 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => )} {showPreview ? ( - + + + ) : (