mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 13:20:37 +02:00
refactor embeds
This commit is contained in:
parent
d15f1a8df9
commit
b75b1b3455
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 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 />,
|
||||
|
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 { 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
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 { 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>
|
||||
|
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>;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user