refactor embeds

This commit is contained in:
hzrd149 2023-04-13 08:38:43 -05:00
parent d15f1a8df9
commit b75b1b3455
15 changed files with 393 additions and 281 deletions

View File

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

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

@ -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,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>

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

@ -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

@ -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>;
}