mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-03 16:09:52 +02:00
refactor embeds
This commit is contained in:
5
.changeset/honest-actors-allow.md
Normal file
5
.changeset/honest-actors-allow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Show image and video embeds in DMs (big refactor to support hashtags)
|
@@ -15,6 +15,7 @@ import ProfileView from "./views/profile";
|
|||||||
import FollowingTab from "./views/home/following-tab";
|
import FollowingTab from "./views/home/following-tab";
|
||||||
import DiscoverTab from "./views/home/discover-tab";
|
import DiscoverTab from "./views/home/discover-tab";
|
||||||
import GlobalTab from "./views/home/global-tab";
|
import GlobalTab from "./views/home/global-tab";
|
||||||
|
import HashTagView from "./views/hashtag";
|
||||||
import UserView from "./views/user";
|
import UserView from "./views/user";
|
||||||
import UserNotesTab from "./views/user/notes";
|
import UserNotesTab from "./views/user/notes";
|
||||||
import UserFollowersTab from "./views/user/followers";
|
import UserFollowersTab from "./views/user/followers";
|
||||||
@@ -121,6 +122,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: "dm/:key", element: <DirectMessageChatView /> },
|
{ path: "dm/:key", element: <DirectMessageChatView /> },
|
||||||
{ path: "profile", element: <ProfileView /> },
|
{ path: "profile", element: <ProfileView /> },
|
||||||
{ path: "l/:link", element: <NostrLinkView /> },
|
{ path: "l/:link", element: <NostrLinkView /> },
|
||||||
|
{ path: "t/:hashtag", element: <HashTagView /> },
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
element: <HomeView />,
|
element: <HomeView />,
|
||||||
|
20
src/components/embed-types/app-music.tsx
Normal file
20
src/components/embed-types/app-music.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
49
src/components/embed-types/common.tsx
Normal file
49
src/components/embed-types/common.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
9
src/components/embed-types/index.ts
Normal file
9
src/components/embed-types/index.ts
Normal 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";
|
10
src/components/embed-types/lightning.tsx
Normal file
10
src/components/embed-types/lightning.tsx
Normal 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]} />,
|
||||||
|
});
|
||||||
|
}
|
83
src/components/embed-types/nostr.tsx
Normal file
83
src/components/embed-types/nostr.tsx
Normal 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];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
26
src/components/embed-types/spotify.tsx
Normal file
26
src/components/embed-types/spotify.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
16
src/components/embed-types/tidal.tsx
Normal file
16
src/components/embed-types/tidal.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
11
src/components/embed-types/twitter.tsx
Normal file
11
src/components/embed-types/twitter.tsx
Normal 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} />,
|
||||||
|
});
|
||||||
|
}
|
65
src/components/embed-types/youtube.tsx
Normal file
65
src/components/embed-types/youtube.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
@@ -1,289 +1,49 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AspectRatio, Box, Button, ButtonGroup, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
|
import { Box } from "@chakra-ui/react";
|
||||||
import { InlineInvoiceCard } from "../inline-invoice-card";
|
|
||||||
import { TweetEmbed } from "../tweet-embed";
|
|
||||||
import { UserLink } from "../user-link";
|
|
||||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||||
import appSettings from "../../services/app-settings";
|
|
||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import QuoteNote from "./quote-note";
|
|
||||||
import { useExpand } from "./expanded";
|
import { useExpand } from "./expanded";
|
||||||
import { nip19 } from "nostr-tools";
|
import { EmbedableContent } from "../../helpers/embeds";
|
||||||
import { EventPointer, ProfilePointer } from "nostr-tools/lib/nip19";
|
import {
|
||||||
|
embedTweet,
|
||||||
|
embedLightningInvoice,
|
||||||
|
embedImages,
|
||||||
|
embedVideos,
|
||||||
|
embedLinks,
|
||||||
|
embedSpotifyMusic,
|
||||||
|
embedTidalMusic,
|
||||||
|
embedYoutubeVideo,
|
||||||
|
embedYoutubePlaylist,
|
||||||
|
embedYoutubeMusic,
|
||||||
|
embedNostrLinks,
|
||||||
|
embedNostrMentions,
|
||||||
|
embedAppleMusic,
|
||||||
|
embedNostrHashtags,
|
||||||
|
} from "../embed-types";
|
||||||
|
|
||||||
const BlurredImage = (props: ImageProps) => {
|
function buildContents(event: NostrEvent | DraftNostrEvent, trusted: boolean = false) {
|
||||||
const { isOpen, onToggle } = useDisclosure();
|
let content: EmbedableContent = [event.content];
|
||||||
return (
|
|
||||||
<Box overflow="hidden">
|
|
||||||
<Image onClick={onToggle} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type EmbedType = {
|
content = embedLightningInvoice(content);
|
||||||
regexp: RegExp;
|
content = embedTweet(content);
|
||||||
render: (match: RegExpMatchArray, event?: NostrEvent | DraftNostrEvent, trusted?: boolean) => JSX.Element | string;
|
content = embedYoutubeVideo(content);
|
||||||
name?: string;
|
content = embedYoutubePlaylist(content);
|
||||||
isMedia: boolean;
|
content = embedYoutubeMusic(content);
|
||||||
};
|
content = embedTidalMusic(content);
|
||||||
|
content = embedAppleMusic(content);
|
||||||
|
content = embedSpotifyMusic(content);
|
||||||
|
|
||||||
const embeds: EmbedType[] = [
|
// common
|
||||||
// Lightning Invoice
|
content = embedImages(content, trusted);
|
||||||
{
|
content = embedVideos(content);
|
||||||
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/im,
|
content = embedLinks(content);
|
||||||
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);
|
|
||||||
|
|
||||||
switch (decoded.type) {
|
// nostr
|
||||||
case "npub":
|
content = embedNostrLinks(content, event);
|
||||||
return <UserLink color="blue.500" pubkey={decoded.data as string} showAt />;
|
content = embedNostrMentions(content, event);
|
||||||
case "nprofile": {
|
// content = embedNostrHashtags(content, event);
|
||||||
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];
|
|
||||||
|
|
||||||
if (tag) {
|
return content;
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GradientOverlay = styled.div`
|
const GradientOverlay = styled.div`
|
||||||
@@ -303,7 +63,7 @@ export type NoteContentsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NoteContents = React.memo(({ event, trusted, maxHeight }: 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 expand = useExpand();
|
||||||
const [innerHeight, setInnerHeight] = useState(0);
|
const [innerHeight, setInnerHeight] = useState(0);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -330,7 +90,7 @@ export const NoteContents = React.memo(({ event, trusted, maxHeight }: NoteConte
|
|||||||
onLoad={() => testHeight()}
|
onLoad={() => testHeight()}
|
||||||
>
|
>
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{parts.map((part, i) => (
|
{content.map((part, i) => (
|
||||||
<span key={"part-" + i}>{part}</span>
|
<span key={"part-" + i}>{part}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
32
src/helpers/embeds.ts
Normal file
32
src/helpers/embeds.ts
Normal 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();
|
||||||
|
}
|
@@ -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 moment from "moment";
|
||||||
import { Kind } from "nostr-tools";
|
import { Kind } from "nostr-tools";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -17,6 +17,20 @@ import clientRelaysService from "../../services/client-relays";
|
|||||||
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
|
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
|
||||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
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">) {
|
function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
@@ -33,7 +47,7 @@ function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">)
|
|||||||
data={event.content}
|
data={event.content}
|
||||||
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
|
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
|
||||||
>
|
>
|
||||||
{(text) => <Text whiteSpace="pre-wrap">{text}</Text>}
|
{(text) => <MessageContent event={event} text={text} />}
|
||||||
</DecryptPlaceholder>
|
</DecryptPlaceholder>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
10
src/views/hashtag/index.tsx
Normal file
10
src/views/hashtag/index.tsx
Normal 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>;
|
||||||
|
}
|
Reference in New Issue
Block a user