Merge branch 'next'

This commit is contained in:
hzrd149 2023-04-13 08:39:34 -05:00
commit ca6349d364
30 changed files with 589 additions and 454 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show image and video embeds in DMs (big refactor to support hashtags)

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Desktop: Remove following list on right side

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple event deletion modal

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Mobile: Move user icon to bottom bar

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2023 Talha Buğra Bulut
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -15,6 +15,7 @@ import ProfileView from "./views/profile";
import FollowingTab from "./views/home/following-tab";
import DiscoverTab from "./views/home/discover-tab";
import GlobalTab from "./views/home/global-tab";
import HashTagView from "./views/hashtag";
import UserView from "./views/user";
import UserNotesTab from "./views/user/notes";
import UserFollowersTab from "./views/user/followers";
@ -121,6 +122,7 @@ const router = createBrowserRouter([
{ path: "dm/:key", element: <DirectMessageChatView /> },
{ path: "profile", element: <ProfileView /> },
{ path: "l/:link", element: <NostrLinkView /> },
{ path: "t/:hashtag", element: <HashTagView /> },
{
path: "",
element: <HomeView />,

View File

@ -11,9 +11,8 @@ export default function NoteDebugModal({ event, ...props }: { event: NostrEvent
<Modal {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Event Debug</ModalHeader>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
<ModalBody overflow="auto" p="4">
<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"} />

View File

@ -15,7 +15,7 @@ export default function UserDebugModal({ pubkey, ...props }: { pubkey: string }
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
<ModalBody overflow="auto" p="4">
<Flex gap="2" direction="column">
<RawValue heading="Hex pubkey" value={pubkey} />
{npub && <RawValue heading="Encoded pubkey (NIP-19)" value={npub} />}

View File

@ -0,0 +1,20 @@
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
// note1tvqk2mu829yr6asf7w5dgpp8t0mlp2ax5t26ctfdx8m0ptkssamqsleeux
// note1ygx9tec3af92704d92jwrj3zs7cws2jl29yvrlxzqlcdlykhwssqpupa7t
export function embedAppleMusic(content: EmbedableContent) {
return embedJSX(content, {
regexp: /https?:\/\/music\.apple\.com(?:\/[\+~%\/\.\w\-_]*)?(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/,
render: (match) => (
<iframe
allow="encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={match[0].includes("?i=") ? 175 : 450}
style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent" }}
// sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={match[0].replace("music.apple.com", "embed.music.apple.com")}
></iframe>
),
name: "Apple Music",
});
}

View File

@ -0,0 +1,49 @@
import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import appSettings from "../../services/app-settings";
const BlurredImage = (props: ImageProps) => {
const { isOpen, onToggle } = useDisclosure();
return (
<Box overflow="hidden">
<Image onClick={onToggle} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />
</Box>
);
};
// 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;
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" />;
},
name: "Image",
});
}
export function embedVideos(content: EmbedableContent) {
return embedJSX(content, {
name: "Video",
regexp:
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:mp4|mkv|webm|mov))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
render: (match) => <video src={match[0]} controls style={{ maxWidth: "30rem", maxHeight: "20rem" }} />,
});
}
// based on http://urlregex.com/
// note1c34vht0lu2qzrgr4az3u8jn5xl3fycr2gfpahkepthg7hzlqg26sr59amt
export function embedLinks(content: EmbedableContent) {
return embedJSX(content, {
name: "Link",
regexp:
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
render: (match) => (
<Link color="blue.500" href={match[0]} target="_blank" isExternal>
{match[0]}
</Link>
),
});
}

View File

@ -0,0 +1,9 @@
export * from "./twitter";
export * from "./lightning";
export * from "./app-music";
export * from "./common";
export * from "./spotify";
export * from "./tidal";
export * from "./youtube";
export * from "./spotify";
export * from "./nostr";

View File

@ -0,0 +1,10 @@
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { InlineInvoiceCard } from "../inline-invoice-card";
export function embedLightningInvoice(content: EmbedableContent) {
return embedJSX(content, {
name: "Lightning Invoice",
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/im,
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} />,
});
}

View File

@ -0,0 +1,83 @@
import { nip19 } from "nostr-tools";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import QuoteNote from "../note/quote-note";
import { UserLink } from "../user-link";
import { EventPointer, ProfilePointer } from "nostr-tools/lib/nip19";
import { Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
export function embedNostrLinks(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
return embedJSX(content, {
name: "nostr-link",
regexp: /(nostr:)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i,
render: (match) => {
try {
const decoded = nip19.decode(match[2]);
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data as string} showAt />;
case "nprofile": {
const pointer = decoded.data as ProfilePointer;
return <UserLink color="blue.500" pubkey={pointer.pubkey} showAt />;
}
case "note":
return <QuoteNote noteId={decoded.data as string} />;
case "nevent": {
const pointer = decoded.data as EventPointer;
return <QuoteNote noteId={pointer.id} relay={pointer.relays?.[0]} />;
}
default:
return match[0];
}
} catch (e) {
return match[0];
}
},
});
}
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
return embedJSX(content, {
name: "nostr-mention",
regexp: /#\[(\d+)\]/,
render: (match) => {
const index = parseInt(match[1]);
const tag = event?.tags[index];
if (tag) {
if (tag[0] === "p" && tag[1]) {
return <UserLink color="blue.500" pubkey={tag[1]} showAt />;
}
if (tag[0] === "e" && tag[1]) {
return <QuoteNote noteId={tag[1]} relay={tag[2]} />;
}
}
return match[0];
},
});
}
export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
const hashtags = event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]?.toLowerCase()) as string[];
return embedJSX(content, {
name: "nostr-hashtag",
regexp: /#([^\[\]\s]+)/i,
render: (match) => {
const hashtag = match[1].toLowerCase();
if (hashtags.includes(hashtag)) {
return (
<Link as={RouterLink} to={`/t/${hashtag}`} color="blue.500">
#{match[1]}
</Link>
);
}
return match[0];
},
});
}

View File

@ -0,0 +1,26 @@
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
// nostr:nevent1qqs9r94qeqhqayvuz6q6u88spvuz0d25nhpyv0c39wympmfu646x4pgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3samnwvaz7tmjv4kxz7fwwdhx7un59eek7cmfv9kqmhxhvq
export function embedSpotifyMusic(content: EmbedableContent) {
return embedJSX(content, {
regexp:
/https?:\/\/open\.spotify\.com\/(track|episode|album|playlist)\/(\w+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/im,
render: (match) => {
const isList = match[1] === "album" || match[1] === "playlist";
return (
<iframe
style={{ borderRadius: "12px" }}
width="100%"
height={isList ? 400 : 152}
title="Spotify Embed: Beethoven - Fur Elise - Komuz Remix"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
src={`https://open.spotify.com/embed/${match[1]}/${match[2]}`}
></iframe>
);
},
name: "Spotify",
});
}

View File

@ -0,0 +1,16 @@
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
// note132m5xc3zhj7fap67vzwx5x3s8xqgz49k669htcn8kppr4m654tuq960tuu
export function embedTidalMusic(content: EmbedableContent) {
return embedJSX(content, {
regexp: /https?:\/\/tidal\.com(\/browse)?\/(track|album)\/(\d+)/im,
render: (match) => (
<iframe
src={`https://embed.tidal.com/${match[2]}s/${match[3]}?disableAnalytics=true`}
width="100%"
height={match[2] === "album" ? 400 : 96}
></iframe>
),
name: "Tidal",
});
}

View File

@ -0,0 +1,11 @@
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { TweetEmbed } from "../tweet-embed";
export function embedTweet(content: EmbedableContent) {
return embedJSX(content, {
name: "Tweet",
regexp:
/https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/status(es)?\/(\d+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/im,
render: (match) => <TweetEmbed href={match[0]} conversation={false} />,
});
}

View File

@ -0,0 +1,65 @@
import { AspectRatio } from "@chakra-ui/react";
import { EmbedType, EmbedableContent, embedJSX } from "../../helpers/embeds";
// nostr:note1ya94hd44g3m2x4gagcydkg28qcp924dd238vq5k4chly84mqt2wqnwgu6d
// nostr:note1apu56y4h2ms5uwpzz209vychr309kllhq6wz46te84u9rus5x7kqj5f5n9
export function embedYoutubeVideo(content: EmbedableContent) {
return embedJSX(content, {
name: "Youtube Video",
regexp:
/https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com|youtu\.be)(\/(?:[\w\-]+\?v=|embed\/|v\/|live\/|shorts\/)?)([\w\-]+)(\S+)?/,
render: (match) => (
<AspectRatio ratio={16 / 10} maxWidth="30rem">
<iframe
src={`https://www.youtube.com/embed/${match[2]}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
width="100%"
></iframe>
</AspectRatio>
),
});
}
// nostr:note12vqqte3gtd729gp65tgdk0a8yym5ynwqjuhk5s6l333yethlvlcsqptvmk
export function embedYoutubePlaylist(content: EmbedableContent) {
return embedJSX(content, {
name: "Youtube Playlist",
regexp: /https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com|youtu\.be)\/(?:playlist\?list=)([\w\-]+)(\S+)?/im,
render: (match) => (
<AspectRatio ratio={560 / 315} maxWidth="30rem">
<iframe
// width="560"
// height="315"
width="100%"
height="100%"
src={`https://www.youtube.com/embed/videoseries?list=${match[1]}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</AspectRatio>
),
});
}
export function embedYoutubeMusic(content: EmbedableContent) {
return embedJSX(content, {
regexp: /https?:\/\/music\.youtube\.com\/watch\?v=(\w+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/,
render: (match) => (
<AspectRatio ratio={16 / 10} maxWidth="30rem">
<iframe
width="100%"
src={`https://youtube.com/embed/${match[1]}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</AspectRatio>
),
name: "Youtube Music",
});
}

View File

@ -1,45 +0,0 @@
import { Box, Button, Flex, SkeletonText } from "@chakra-ui/react";
import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19";
import { getUserDisplayName } from "../helpers/user-metadata";
import useSubject from "../hooks/use-subject";
import { useUserMetadata } from "../hooks/use-user-metadata";
import clientFollowingService from "../services/client-following";
import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity";
const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
const metadata = useUserMetadata(pubkey);
if (!metadata) return <SkeletonText />;
return (
<Button
as={Link}
leftIcon={<UserAvatar pubkey={pubkey} size="xs" />}
overflow="hidden"
variant="outline"
to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}
justifyContent="flex-start"
rightIcon={<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />}
>
{getUserDisplayName(metadata, pubkey)}
</Button>
);
};
export const FollowingList = () => {
const following = useSubject(clientFollowingService.following);
if (!following) return <SkeletonText />;
return (
<Box overflow="auto" pr="2" pb="4" pt="2">
<Flex direction="column" gap="2">
{following.map((pTag) => (
<FollowingListItem key={pTag[1]} pubkey={pTag[1]} />
))}
</Flex>
</Box>
);
};

View File

@ -1,289 +1,49 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { AspectRatio, Box, Button, ButtonGroup, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import { InlineInvoiceCard } from "../inline-invoice-card";
import { TweetEmbed } from "../tweet-embed";
import { UserLink } from "../user-link";
import { Box } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import appSettings from "../../services/app-settings";
import styled from "@emotion/styled";
import QuoteNote from "./quote-note";
import { useExpand } from "./expanded";
import { nip19 } from "nostr-tools";
import { EventPointer, ProfilePointer } from "nostr-tools/lib/nip19";
import { EmbedableContent } from "../../helpers/embeds";
import {
embedTweet,
embedLightningInvoice,
embedImages,
embedVideos,
embedLinks,
embedSpotifyMusic,
embedTidalMusic,
embedYoutubeVideo,
embedYoutubePlaylist,
embedYoutubeMusic,
embedNostrLinks,
embedNostrMentions,
embedAppleMusic,
embedNostrHashtags,
} from "../embed-types";
const BlurredImage = (props: ImageProps) => {
const { isOpen, onToggle } = useDisclosure();
return (
<Box overflow="hidden">
<Image onClick={onToggle} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />
</Box>
);
};
function buildContents(event: NostrEvent | DraftNostrEvent, trusted: boolean = false) {
let content: EmbedableContent = [event.content];
type EmbedType = {
regexp: RegExp;
render: (match: RegExpMatchArray, event?: NostrEvent | DraftNostrEvent, trusted?: boolean) => JSX.Element | string;
name?: string;
isMedia: boolean;
};
content = embedLightningInvoice(content);
content = embedTweet(content);
content = embedYoutubeVideo(content);
content = embedYoutubePlaylist(content);
content = embedYoutubeMusic(content);
content = embedTidalMusic(content);
content = embedAppleMusic(content);
content = embedSpotifyMusic(content);
const embeds: EmbedType[] = [
// Lightning Invoice
{
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/im,
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} />,
name: "Lightning Invoice",
isMedia: false,
},
// Twitter tweet
{
regexp:
/https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/status(es)?\/(\d+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/im,
render: (match) => <TweetEmbed href={match[0]} conversation={false} />,
name: "Tweet",
isMedia: true,
},
// Youtube Playlist
// note12vqqte3gtd729gp65tgdk0a8yym5ynwqjuhk5s6l333yethlvlcsqptvmk
{
regexp: /https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com|youtu\.be)\/(?:playlist\?list=)([\w\-]+)(\S+)?/im,
render: (match) => (
<AspectRatio ratio={560 / 315} maxWidth="30rem">
<iframe
// width="560"
// height="315"
width="100%"
height="100%"
src={`https://www.youtube.com/embed/videoseries?list=${match[1]}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</AspectRatio>
),
name: "Youtube Playlist",
isMedia: true,
},
// Youtube Video
// note1ya94hd44g3m2x4gagcydkg28qcp924dd238vq5k4chly84mqt2wqnwgu6d
// note1apu56y4h2ms5uwpzz209vychr309kllhq6wz46te84u9rus5x7kqj5f5n9
{
regexp:
/https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com|youtu\.be)(\/(?:[\w\-]+\?v=|embed\/|v\/|live\/|shorts\/)?)([\w\-]+)(\S+)?/,
render: (match) => (
<AspectRatio ratio={16 / 10} maxWidth="30rem">
<iframe
src={`https://www.youtube.com/embed/${match[2]}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
width="100%"
></iframe>
</AspectRatio>
),
name: "Youtube Video",
isMedia: true,
},
// Youtube Music
{
regexp: /https?:\/\/music\.youtube\.com\/watch\?v=(\w+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/,
render: (match) => (
<AspectRatio ratio={16 / 10} maxWidth="30rem">
<iframe
width="100%"
src={`https://youtube.com/embed/${match[1]}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</AspectRatio>
),
name: "Youtube Music",
isMedia: true,
},
// Tidal
// note132m5xc3zhj7fap67vzwx5x3s8xqgz49k669htcn8kppr4m654tuq960tuu
{
regexp: /https?:\/\/tidal\.com(\/browse)?\/(track|album)\/(\d+)/im,
render: (match) => (
<iframe
src={`https://embed.tidal.com/${match[2]}s/${match[3]}?disableAnalytics=true`}
width="100%"
height={match[2] === "album" ? 400 : 96}
></iframe>
),
name: "Tidal",
isMedia: true,
},
// Spotify
// note12xt2pjpwp6gec95p4cw0qzecy764f8wzgcl3z2ufkrkne4t5d2zse3ze78
{
regexp:
/https?:\/\/open\.spotify\.com\/(track|episode|album|playlist)\/(\w+)(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/im,
render: (match) => {
const isList = match[1] === "album" || match[1] === "playlist";
return (
<iframe
style={{ borderRadius: "12px" }}
width="100%"
height={isList ? 400 : 152}
title="Spotify Embed: Beethoven - Fur Elise - Komuz Remix"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
src={`https://open.spotify.com/embed/${match[1]}/${match[2]}`}
></iframe>
);
},
name: "Spotify",
isMedia: true,
},
// apple music
// note1tvqk2mu829yr6asf7w5dgpp8t0mlp2ax5t26ctfdx8m0ptkssamqsleeux
// note1ygx9tec3af92704d92jwrj3zs7cws2jl29yvrlxzqlcdlykhwssqpupa7t
{
regexp: /https?:\/\/music\.apple\.com(?:\/[\+~%\/\.\w\-_]*)?(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/,
render: (match) => (
<iframe
allow="encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={match[0].includes("?i=") ? 175 : 450}
style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent" }}
// sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={match[0].replace("music.apple.com", "embed.music.apple.com")}
></iframe>
),
name: "Apple Music",
isMedia: true,
},
// Image
// note1n06jceulg3gukw836ghd94p0ppwaz6u3mksnnz960d8vlcp2fnqsgx3fu9
{
regexp:
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
render: (match, event, trusted) => {
const ImageComponent = trusted || !appSettings.value.blurImages ? Image : BlurredImage;
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" />;
},
name: "Image",
isMedia: true,
},
// Video
{
regexp:
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:mp4|mkv|webm|mov))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
render: (match) => <video src={match[0]} controls style={{ maxWidth: "30rem", maxHeight: "20rem" }} />,
name: "Video",
isMedia: true,
},
// Link
// based on http://urlregex.com/
// note1c34vht0lu2qzrgr4az3u8jn5xl3fycr2gfpahkepthg7hzlqg26sr59amt
{
regexp:
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
render: (match) => (
<Link color="blue.500" href={match[0]} target="_blank" isExternal>
{match[0]}
</Link>
),
isMedia: false,
},
// nostr: links
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
{
regexp: /(nostr:)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i,
render: (match, event) => {
try {
const decoded = nip19.decode(match[2]);
console.log(decoded);
// common
content = embedImages(content, trusted);
content = embedVideos(content);
content = embedLinks(content);
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data as string} showAt />;
case "nprofile": {
const pointer = decoded.data as ProfilePointer;
return <UserLink color="blue.500" pubkey={pointer.pubkey} showAt />;
}
case "note":
return <QuoteNote noteId={decoded.data as string} />;
case "nevent": {
const pointer = decoded.data as EventPointer;
return <QuoteNote noteId={pointer.id} relay={pointer.relays?.[0]} />;
}
default:
return match[0];
}
} catch (e) {
return match[0];
}
},
isMedia: false,
},
// Nostr Mention Links
{
regexp: /#\[(\d+)\]/,
render: (match, event) => {
const index = parseInt(match[1]);
const tag = event?.tags[index];
// nostr
content = embedNostrLinks(content, event);
content = embedNostrMentions(content, event);
// content = embedNostrHashtags(content, event);
if (tag) {
if (tag[0] === "p" && tag[1]) {
return <UserLink color="blue.500" pubkey={tag[1]} showAt />;
}
if (tag[0] === "e" && tag[1]) {
return <QuoteNote noteId={tag[1]} relay={tag[2]} />;
}
}
return match[0];
},
isMedia: false,
},
// bold text
{
regexp: /\*\*([^\n]+)\*\*/im,
render: (match) => <span style={{ fontWeight: "bold" }}>{match[1]}</span>,
isMedia: false,
},
];
const MediaEmbed = ({ children, type }: { children: JSX.Element | string; type: EmbedType }) => {
const [show, setShow] = useState(appSettings.value.autoShowMedia);
return show ? (
<>{children}</>
) : (
<ButtonGroup size="sm" isAttached variant="outline">
<Button onClick={() => setShow(true)}>Show {type.name ?? "Embed"}</Button>
{/* TODO: add external link for embed */}
{/* <IconButton as="a" aria-label="Add to friends" icon={<ExternalLinkIcon />} href={}/> */}
</ButtonGroup>
);
};
function embedContent(
content: string,
event?: NostrEvent | DraftNostrEvent,
trusted: boolean = false
): (string | JSX.Element)[] {
for (const embedType of embeds) {
const match = content.match(embedType.regexp);
if (match && match.index !== undefined) {
const before = content.slice(0, match.index);
const after = content.slice(match.index + match[0].length, content.length);
const embedRender = embedType.render(match, event, trusted);
const embed = embedType.isMedia ? <MediaEmbed type={embedType}>{embedRender}</MediaEmbed> : embedRender;
return [...embedContent(before, event, trusted), embed, ...embedContent(after, event, trusted)];
}
}
return [content];
return content;
}
const GradientOverlay = styled.div`
@ -303,7 +63,7 @@ export type NoteContentsProps = {
};
export const NoteContents = React.memo(({ event, trusted, maxHeight }: NoteContentsProps) => {
const parts = embedContent(event.content, event, trusted ?? false);
const content = buildContents(event, trusted ?? false);
const expand = useExpand();
const [innerHeight, setInnerHeight] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);
@ -330,7 +90,7 @@ export const NoteContents = React.memo(({ event, trusted, maxHeight }: NoteConte
onLoad={() => testHeight()}
>
<div ref={ref}>
{parts.map((part, i) => (
{content.map((part, i) => (
<span key={"part-" + i}>{part}</span>
))}
</div>

View File

@ -1,12 +1,17 @@
import {
Button,
Input,
MenuItem,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Toast,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { nip19 } from "nostr-tools";
@ -15,12 +20,18 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import { ClipboardIcon, CodeIcon, LikeIcon, RepostIcon } from "../icons";
import { getReferences } from "../../helpers/nostr-event";
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";
import QuoteNote from "./quote-note";
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;
@ -33,11 +44,37 @@ function getShareLink(eventId: string) {
}
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
const account = useCurrentAccount();
const toast = useToast();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
const deleteModal = useDisclosure();
const [reason, setReason] = useState("");
const [deleting, setDeleting] = useState(false);
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const noteId = normalizeToBech32(event.id, Bech32Prefix.Note);
const deleteNote = useCallback(async () => {
try {
setDeleting(true);
const deleteEvent = buildDeleteEvent([event.id], reason);
const signed = await signingService.requestSignature(deleteEvent, account);
const results = nostrPostAction(clientRelaysService.getWriteUrls(), signed);
await results.onComplete;
deleteModal.onClose();
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
} finally {
setDeleting(false);
}
}, [event]);
return (
<>
<MenuIconButton {...props}>
@ -52,16 +89,54 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
Copy Note ID
</MenuItem>
)}
{account.pubkey === event.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={deleteModal.onOpen}>
Delete Note
</MenuItem>
)}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={event} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
)}
{deleteModal.isOpen && (
<Modal isOpen={deleteModal.isOpen} onClose={deleteModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Delete Note?
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<QuoteNote noteId={event.id} />
<Input
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)"
mt="2"
/>
</ModalBody>
<ModalFooter px="4" py="4">
<Button variant="ghost" size="sm" mr={2} onClick={deleteModal.onClose}>
Cancel
</Button>
<Button colorScheme="red" variant="solid" onClick={deleteNote} size="sm" isLoading={deleting}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};

View File

@ -1,24 +1,13 @@
import React from "react";
import { Container, Flex, Heading, VStack } from "@chakra-ui/react";
import { Container, Flex } from "@chakra-ui/react";
import { ErrorBoundary } from "../error-boundary";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { FollowingList } from "../following-list";
import { ReloadPrompt } from "../reload-prompt";
import { PostModalProvider } from "../../providers/post-modal-provider";
import MobileHeader from "./mobile-header";
import DesktopSideNav from "./desktop-side-nav";
import MobileBottomNav from "./mobile-bottom-nav";
const FollowingSideNav = () => {
return (
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Heading size="md">Following</Heading>
<FollowingList />
</VStack>
);
};
export const Page = ({ children }: { children: React.ReactNode }) => {
const isMobile = useIsMobile();
@ -34,13 +23,11 @@ export const Page = ({ children }: { children: React.ReactNode }) => {
padding="0"
>
<ReloadPrompt />
{isMobile && <MobileHeader />}
<Flex gap="4" grow={1} overflow="hidden">
{!isMobile && <DesktopSideNav />}
<Flex flexGrow={1} direction="column" overflow="hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</Flex>
{!isMobile && <FollowingSideNav />}
</Flex>
{isMobile && <MobileBottomNav />}
</Container>

View File

@ -1,43 +1,53 @@
import { Flex, IconButton } from "@chakra-ui/react";
import { useContext } from "react";
import { useNavigate } from "react-router-dom";
import { Flex, IconButton, useDisclosure } from "@chakra-ui/react";
import { useContext, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { PostModalContext } from "../../providers/post-modal-provider";
import { ChatIcon, HomeIcon, NotificationIcon, PlusCircleIcon, SearchIcon } from "../icons";
import { UserAvatar } from "../user-avatar";
import MobileSideDrawer from "./mobile-side-drawer";
export default function MobileBottomNav() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { openModal } = useContext(PostModalContext);
const navigate = useNavigate();
const account = useCurrentAccount();
const location = useLocation();
useEffect(() => onClose(), [location.key, account]);
return (
<Flex flexShrink={0} gap="2" padding="2">
<IconButton icon={<HomeIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="md" />
<IconButton
icon={<SearchIcon />}
aria-label="Search"
onClick={() => navigate(`/search`)}
flexGrow="1"
size="md"
/>
<IconButton
icon={<PlusCircleIcon fontSize="1.8em" />}
aria-label="New Note"
onClick={() => {
openModal();
}}
variant="solid"
colorScheme="brand"
isDisabled={account.readonly}
/>
<IconButton icon={<ChatIcon />} aria-label="Messages" onClick={() => navigate(`/dm`)} flexGrow="1" size="md" />
<IconButton
icon={<NotificationIcon />}
aria-label="Notifications"
onClick={() => navigate("/notifications")}
flexGrow="1"
size="md"
/>
</Flex>
<>
<Flex flexShrink={0} gap="2" padding="2" alignItems="center">
<UserAvatar pubkey={account.pubkey} size="sm" onClick={onOpen} />
<IconButton icon={<HomeIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="md" />
<IconButton
icon={<SearchIcon />}
aria-label="Search"
onClick={() => navigate(`/search`)}
flexGrow="1"
size="md"
/>
<IconButton
icon={<PlusCircleIcon fontSize="1.8em" />}
aria-label="New Note"
onClick={() => {
openModal();
}}
variant="solid"
colorScheme="brand"
isDisabled={account.readonly}
/>
<IconButton icon={<ChatIcon />} aria-label="Messages" onClick={() => navigate(`/dm`)} flexGrow="1" size="md" />
<IconButton
icon={<NotificationIcon />}
aria-label="Notifications"
onClick={() => navigate("/notifications")}
flexGrow="1"
size="md"
/>
</Flex>
<MobileSideDrawer isOpen={isOpen} onClose={onClose} />
</>
);
}

View File

@ -1,30 +0,0 @@
import { Flex, IconButton, Text, useDisclosure } from "@chakra-ui/react";
import { useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { ConnectedRelays } from "../connected-relays";
import { NotificationIcon } from "../icons";
import { UserAvatar } from "../user-avatar";
import MobileSideDrawer from "./mobile-side-drawer";
export default function MobileHeader() {
const { isOpen, onOpen, onClose } = useDisclosure();
const account = useCurrentAccount();
const location = useLocation();
useEffect(() => onClose(), [location.key, account]);
return (
<>
<Flex justifyContent="space-between" padding="2" alignItems="center">
<UserAvatar pubkey={account.pubkey} size="sm" onClick={onOpen} />
{account.readonly && (
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}
</Flex>
<MobileSideDrawer isOpen={isOpen} onClose={onClose} />
</>
);
}

View File

@ -9,7 +9,7 @@ import { NoteMenu } from "./note/note-menu";
import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity";
import { UserLink } from "./user-link";
import { getUserDisplayName } from "../helpers/user-metadata";
import { unique } from "../helpers/array";
export default function RepostNote({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) {
const {
@ -19,7 +19,9 @@ export default function RepostNote({ event, maxHeight }: { event: NostrEvent; ma
} = useAsync(async () => {
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
if (eventId) {
return singleEventService.requestEvent(eventId, relay ? [relay] : clientRelaysService.getReadUrls());
const readRelays = clientRelaysService.getReadUrls();
if (relay) readRelays.push(relay);
return singleEventService.requestEvent(eventId, unique(readRelays));
}
return null;
}, [event]);

32
src/helpers/embeds.ts Normal file
View File

@ -0,0 +1,32 @@
import { cloneElement } from "react";
export type EmbedableContent = (string | JSX.Element)[];
export type EmbedType = {
regexp: RegExp;
render: (match: RegExpMatchArray) => JSX.Element | string;
name: string;
};
export function embedJSX(content: EmbedableContent, embed: EmbedType): EmbedableContent {
return content
.map((subContent, i) => {
if (typeof subContent === "string") {
const match = subContent.match(embed.regexp);
if (match && match.index !== undefined) {
const before = subContent.slice(0, match.index);
const after = subContent.slice(match.index + match[0].length, subContent.length);
let embedRender = embed.render(match);
if (typeof embedRender !== "string" && !embedRender.props.key) {
embedRender = cloneElement(embedRender, { key: embed.name + i });
}
return [...embedJSX([before], embed), embedRender, ...embedJSX([after], embed)];
}
}
return subContent;
})
.flat();
}

View File

@ -143,6 +143,15 @@ export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
};
}
export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent {
return {
kind: Kind.EventDeletion,
tags: eventIds.map((id) => ["e", id]),
content: reason,
created_at: moment().unix(),
};
}
export function parseRTag(tag: RTag): RelayConfig {
switch (tag[2]) {
case "write":

View File

@ -1,4 +1,4 @@
import { Button, Card, CardBody, CardProps, Flex, IconButton, Spacer, Text, Textarea } from "@chakra-ui/react";
import { Box, Button, Card, CardBody, CardProps, Flex, IconButton, Spacer, Text, Textarea } from "@chakra-ui/react";
import moment from "moment";
import { Kind } from "nostr-tools";
import { useEffect, useMemo, useState } from "react";
@ -17,6 +17,20 @@ import clientRelaysService from "../../services/client-relays";
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent } from "../../helpers/embeds";
import { embedImages, embedLinks, embedNostrLinks, embedVideos } from "../../components/embed-types";
function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
content = embedImages(content, true);
content = embedVideos(content);
content = embedLinks(content);
content = embedNostrLinks(content, event);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount();
@ -33,7 +47,7 @@ function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">)
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <Text whiteSpace="pre-wrap">{text}</Text>}
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>

View File

@ -8,13 +8,14 @@ import {
Card,
CardBody,
Flex,
Link,
LinkBox,
LinkOverlay,
Text,
} from "@chakra-ui/react";
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { UserAvatar } from "../../components/user-avatar";
import { convertTimestampToDate } from "../../helpers/date";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
@ -22,6 +23,8 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
import useSubject from "../../hooks/use-subject";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import directMessagesService from "../../services/direct-messages";
import { ExternalLinkIcon } from "../../components/icons";
import { useIsMobile } from "../../hooks/use-is-mobile";
function ContactCard({ pubkey }: { pubkey: string }) {
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
@ -40,12 +43,13 @@ function ContactCard({ pubkey }: { pubkey: string }) {
)}
</Flex>
</CardBody>
<LinkOverlay as={Link} to={`/dm/${npub ?? pubkey}`} />
<LinkOverlay as={RouterLink} to={`/dm/${npub ?? pubkey}`} />
</LinkBox>
);
}
function DirectMessagesView() {
const isMobile = useIsMobile();
const [from, setFrom] = useState(moment().subtract(2, "days"));
const conversations = useSubject(directMessagesService.conversations);
@ -93,6 +97,20 @@ function DirectMessagesView() {
return (
<Flex direction="column" gap="2" overflowX="hidden" overflowY="auto" height="100%" pt="2" pb="8">
<Alert status="info" flexShrink={0}>
<AlertIcon />
<Flex direction={isMobile ? "column" : "row"}>
<AlertTitle>Give NostrChat a try</AlertTitle>
<AlertDescription>
<Text>
Its a much better chat app than what I can build inside of noStrudel.{" "}
<Link href="https://www.nostrchat.io/" isExternal>
nostrchat.io <ExternalLinkIcon />
</Link>
</Text>
</AlertDescription>
</Flex>
</Alert>
{sortedConversations.map((pubkey) => (
<ContactCard key={pubkey} pubkey={pubkey} />
))}

View File

@ -0,0 +1,10 @@
import { Flex } from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import { useAppTitle } from "../../hooks/use-app-title";
export default function HashTagView() {
const { hashtag } = useParams() as { hashtag: string };
useAppTitle("#" + hashtag);
return <Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1"></Flex>;
}

View File

@ -21,6 +21,7 @@ import { getUserDisplayName } from "../../../helpers/user-metadata";
import { useUserRelays } from "../../../hooks/use-user-relays";
import { RelayMode } from "../../../classes/relay";
import { CopyIconButton } from "../../../components/copy-icon-button";
import UserDebugModal from "../../../components/debug-modals/user-debug-modal";
export const UserProfileMenu = ({ pubkey, ...props }: { pubkey: string } & Omit<MenuIconButtonProps, "children">) => {
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
@ -67,46 +68,7 @@ export const UserProfileMenu = ({ pubkey, ...props }: { pubkey: string } & Omit<
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<Modal isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody overflow="auto" fontSize="sm" padding="2">
<Flex gap="2" direction="column">
<Heading size="sm" mt="2">
Hex pubkey
</Heading>
<Flex gap="2">
<Code fontSize="md" wordBreak="break-all">
{pubkey}
</Code>
<CopyIconButton text={pubkey} size="xs" aria-label="copy hex" />
</Flex>
{npub && (
<>
<Heading size="sm" mt="2">
Encoded pubkey (NIP-19)
</Heading>
<Flex gap="2">
<Code fontSize="md" wordBreak="break-all">
{npub}
</Code>
<CopyIconButton text={npub} size="xs" aria-label="copy npub" />
</Flex>
</>
)}
<Heading size="sm" mt="2">
Metadata (kind 0)
</Heading>
<Code whiteSpace="pre" overflowX="auto">
{JSON.stringify(metadata, null, 2)}
</Code>
</Flex>
</ModalBody>
</ModalContent>
</Modal>
<UserDebugModal pubkey={pubkey} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
</>
);