mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-12 05:39:18 +02:00
Merge branch 'next'
This commit is contained in:
commit
ca6349d364
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)
|
5
.changeset/silent-gifts-brush.md
Normal file
5
.changeset/silent-gifts-brush.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Desktop: Remove following list on right side
|
5
.changeset/silly-rats-count.md
Normal file
5
.changeset/silly-rats-count.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple event deletion modal
|
5
.changeset/tame-forks-raise.md
Normal file
5
.changeset/tame-forks-raise.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Mobile: Move user icon to bottom bar
|
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
@ -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 />,
|
||||
|
@ -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"} />
|
||||
|
@ -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} />}
|
||||
|
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,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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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
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();
|
||||
}
|
@ -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":
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
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>;
|
||||
}
|
@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user