This commit is contained in:
2023-07-12 19:27:42 +01:00
parent 3b11f63573
commit 1ead1e4a7c
19 changed files with 663 additions and 537 deletions

View File

@ -1,7 +1,6 @@
import { TwitterTweetEmbed } from "react-twitter-embed";
import {
FileExtensionRegex,
YoutubeUrlRegex,
TweetUrlRegex,
TidalRegex,
@ -23,17 +22,14 @@ import AppleMusicEmbed from "Element/AppleMusicEmbed";
import WavlakeEmbed from "Element/WavlakeEmbed";
import LinkPreview from "Element/LinkPreview";
import NostrLink from "Element/NostrLink";
import RevealMedia from "Element/RevealMedia";
import MagnetLink from "Element/MagnetLink";
interface HypeTextProps {
link: string;
creator: string;
depth?: number;
disableMediaSpotlight?: boolean;
}
export default function HyperText({ link, creator, depth, disableMediaSpotlight }: HypeTextProps) {
export default function HyperText({ link, depth }: HypeTextProps) {
const a = link;
try {
const url = new URL(a);
@ -47,10 +43,7 @@ export default function HyperText({ link, creator, depth, disableMediaSpotlight
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension && !isAppleMusicLink) {
return <RevealMedia link={a} creator={creator} disableSpotlight={disableMediaSpotlight} />;
} else if (tweetId) {
if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />

View File

@ -1,6 +1,6 @@
.link-preview-container {
border: 1px solid var(--gray);
border-radius: 10px;
border-radius: 0px 0px 12px 12px;
background: #151515;
overflow: hidden;
}

View File

@ -52,25 +52,29 @@ export function MediaElement(props: MediaElementProps) {
return;
}
const req = new Request(props.url, {
method: "OPTIONS",
headers: {
accept: "L402",
},
});
const rsp = await fetch(req);
if (rsp.status === 402) {
const auth = rsp.headers.get("www-authenticate");
if (auth?.startsWith("L402")) {
const vals = kvToObject<L402Object>(auth.substring(5));
console.debug(vals);
setL402(vals);
try {
const req = new Request(props.url, {
method: "OPTIONS",
headers: {
accept: "L402",
},
});
const rsp = await fetch(req);
if (rsp.status === 402) {
const auth = rsp.headers.get("www-authenticate");
if (auth?.startsWith("L402")) {
const vals = kvToObject<L402Object>(auth.substring(5));
console.debug(vals);
setL402(vals);
if (vals.invoice) {
const decoded = decodeInvoice(vals.invoice);
setInvoice(decoded);
if (vals.invoice) {
const decoded = decodeInvoice(vals.invoice);
setInvoice(decoded);
}
}
}
} catch (e) {
console.error(e);
}
}

View File

@ -19,16 +19,17 @@
text-decoration-color: var(--highlight);
}
.note > .header > .info {
.note .header .info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
display: flex;
align-items: center;
gap: 8px;
}
.note > .header > .info .saved {
.note .header .info .saved {
margin-right: 12px;
font-weight: 600;
font-size: 10px;
@ -39,11 +40,11 @@
align-items: center;
}
.note > .header > .info .saved svg {
.note .header .info .saved svg {
margin-right: 8px;
}
.note > .header > .pinned {
.note .header .pinned {
font-size: var(--font-size-small);
color: var(--font-secondary-color);
font-weight: 500;
@ -53,7 +54,7 @@
align-items: center;
}
.note > .header > .pinned svg {
.note .header .pinned svg {
margin-right: 8px;
}
@ -67,10 +68,11 @@
padding-left: 0;
}
.note > .body {
margin-top: 4px;
margin-bottom: 24px;
padding-left: 56px;
.note > .body .text-frag {
padding-left: 61px;
}
.note > .body .text-frag {
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: normal;
@ -78,8 +80,14 @@
overflow-y: visible;
}
.note > .body img,
.note > .body video,
.note > .body audio {
margin-top: 16px;
}
.note > .footer {
padding-left: 46px;
padding: 16px 0 0px 61px;
}
.note .footer .footer-reactions {
@ -88,8 +96,7 @@
align-items: center;
justify-content: center;
margin-left: auto;
gap: 1em;
padding-left: 0.8em;
gap: 48px;
}
@media (min-width: 720px) {
@ -98,7 +105,7 @@
}
}
.note > .footer .ctx-menu {
.note .ctx-menu {
color: var(--font-secondary-color);
background: transparent;
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.4);
@ -108,7 +115,7 @@
border-radius: 16px;
}
.note > .footer .ctx-menu li {
.note .ctx-menu li {
background: #1e1e1e;
padding-top: 8px;
padding-bottom: 8px;
@ -116,28 +123,28 @@
grid-template-columns: 2rem auto;
}
.light .note > .footer .ctx-menu li {
.light .note .ctx-menu li {
background: var(--note-bg);
}
.note > .footer .ctx-menu li:first-of-type {
.note .ctx-menu li:first-of-type {
padding-top: 12px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.note > .footer .ctx-menu li:last-of-type {
.note .ctx-menu li:last-of-type {
padding-bottom: 12px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.note > .footer .ctx-menu li:hover {
.note .ctx-menu li:hover {
color: white;
background: #2a2a2a;
}
.light .note > .footer .ctx-menu li:hover {
.light .note .ctx-menu li:hover {
color: white;
background: var(--font-secondary-color);
}
@ -196,11 +203,7 @@
user-select: none;
color: var(--font-secondary-color);
font-feature-settings: "tnum";
}
.reaction-pill .reaction-pill-number {
margin-left: 8px;
font-feature-settings: "tnum";
gap: 5px;
}
.reaction-pill.reacted {

View File

@ -1,5 +1,5 @@
import "./Note.css";
import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react";
import React, { useMemo, useState, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
@ -20,7 +20,7 @@ import {
Reaction,
profileLink,
} from "SnortUtils";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteFooter from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration";
@ -32,6 +32,8 @@ import { NostrFileElement } from "Element/NostrFileHeader";
import ZapstrEmbed from "Element/ZapstrEmbed";
import PubkeyList from "Element/PubkeyList";
import { LiveEvent } from "Element/LiveEvent";
import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu";
import Reactions from "Element/Reactions";
import messages from "./messages";
@ -97,12 +99,10 @@ export default function Note(props: NoteProps) {
const { isMuted } = useModeration();
const isOpMuted = isMuted(ev?.pubkey);
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const login = useLogin();
const { pinned, bookmarked } = login;
const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const [translated, setTranslated] = useState<NoteTranslation>();
const { formatMessage } = useIntl();
const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]);
const groupReactions = useMemo(() => {
@ -208,15 +208,6 @@ export default function Note(props: NoteProps) {
return <Text content={body} tags={ev.tags} creator={ev.pubkey} depth={props.depth} />;
};
useLayoutEffect(() => {
if (entry && inView && extendable === false) {
const h = (entry?.target as HTMLDivElement)?.offsetHeight ?? 0;
if (h > 650) {
setExtendable(true);
}
}
}, [inView, entry, extendable]);
function goToEvent(
e: React.MouseEvent,
eTarget: TaggedNostrEvent,
@ -342,21 +333,31 @@ export default function Note(props: NoteProps) {
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
/>
{(options.showTime || options.showBookmarked) && (
<div className="info">
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</div>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
<div className="info">
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div
className={`saved ${options.canUnbookmark ? "pointer" : ""}`}
onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
</div>
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev, true)}>
@ -369,32 +370,21 @@ export default function Note(props: NoteProps) {
</div>
)}
</div>
{extendable && !showMore && (
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
<FormattedMessage {...messages.ShowMore} />
</span>
)}
{options.showFooter && (
<NoteFooter
ev={ev}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
onTranslated={t => setTranslated(t)}
showReactions={showReactions}
setShowReactions={setShowReactions}
/>
)}
{options.showFooter && <NoteFooter ev={ev} positive={positive} reposts={reposts} zaps={zaps} />}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
</>
);
}
const note = (
<div
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
onClick={e => goToEvent(e, ev)}
ref={ref}>
<div className={`${baseClassName}${highlight ? " active " : " "}`} onClick={e => goToEvent(e, ev)} ref={ref}>
{content()}
</div>
);

View File

@ -0,0 +1,220 @@
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, Lists, NostrPrefix, TaggedRawEvent, encodeTLV } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useDispatch, useSelector } from "react-redux";
import { TranslateHost } from "Const";
import { System } from "index";
import Icon from "Icons/Icon";
import { setPinned, setBookmarked } from "Login";
import {
setNote as setReBroadcastNote,
setShow as setReBroadcastShow,
reset as resetReBroadcast,
} from "State/ReBroadcast";
import messages from "Element/messages";
import useLogin from "Hooks/useLogin";
import useModeration from "Hooks/useModeration";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { ReBroadcaster } from "./ReBroadcaster";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedRawEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login;
const { mute, block } = useModeration();
const publisher = useEventPublisher();
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const isMine = ev.pubkey === publicKey;
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
System.BroadcastEvent(evDelete);
}
}
async function share() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.content,
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const result = await res.json();
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
} as NoteTranslation);
}
}
}
async function copyId() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
const handleReBroadcastButtonClick = () => {
if (reBroadcastNote?.id !== ev.id) {
dispatch(resetReBroadcast());
}
dispatch(setReBroadcastNote(ev));
dispatch(setReBroadcastShow(!showReBroadcastModal));
};
function menuItems() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => props.setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
{ev.pubkey === publicKey && (
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage {...messages.ReBroadcast} />
</MenuItem>
)}
{ev.pubkey !== publicKey && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<Menu
menuButton={
<div className="reaction-pill">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{willRenderReBroadcast && <ReBroadcaster />}
</>
);
}

View File

@ -1,9 +1,8 @@
import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists, ParsedZap } from "@snort/system";
import { TaggedNostrEvent, HexKey, u256, ParsedZap } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
@ -14,22 +13,13 @@ import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { delay, normalizeReaction, unwrap } from "SnortUtils";
import { NoteCreator } from "Element/NoteCreator";
import { ReBroadcaster } from "Element/ReBroadcaster";
import Reactions from "Element/Reactions";
import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Zap";
import { RootState } from "State/Store";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import {
setNote as setReBroadcastNote,
setShow as setReBroadcastShow,
reset as resetReBroadcast,
} from "State/ReBroadcast";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import { System } from "index";
@ -49,49 +39,31 @@ const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
}
};
export interface Translation {
text: string;
fromLanguage: string;
confidence: number;
}
export interface NoteFooterProps {
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedNostrEvent[];
negative: TaggedNostrEvent[];
showReactions: boolean;
setShowReactions(b: boolean): void;
ev: TaggedNostrEvent;
onTranslated?: (content: Translation) => void;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
const { ev, positive, reposts, zaps } = props;
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login;
const { mute, block } = useModeration();
const { publicKey, preferences: prefs, relays } = login;
const author = useUserProfile(System, ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const isMine = ev.pubkey === publicKey;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
@ -123,13 +95,6 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
System.BroadcastEvent(evDelete);
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
@ -248,145 +213,6 @@ export default function NoteFooter(props: NoteFooterProps) {
);
}
async function share() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.content,
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const result = await res.json();
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
} as Translation);
}
}
}
async function copyId() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
function menuItems() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
{ev.pubkey === publicKey && (
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage {...messages.ReBroadcast} />
</MenuItem>
)}
{ev.pubkey !== publicKey && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
const handleReplyButtonClick = () => {
if (replyTo?.id !== ev.id) {
dispatch(reset());
@ -396,15 +222,6 @@ export default function NoteFooter(props: NoteFooterProps) {
dispatch(setShow(!showNoteCreatorModal));
};
const handleReBroadcastButtonClick = () => {
if (reBroadcastNote?.id !== ev.id) {
dispatch(resetReBroadcast());
}
dispatch(setReBroadcastNote(ev));
dispatch(setReBroadcastShow(!showReBroadcastModal));
};
return (
<>
<div className="footer">
@ -415,26 +232,8 @@ export default function NoteFooter(props: NoteFooterProps) {
<div className={`reaction-pill ${showNoteCreatorModal ? "reacted" : ""}`} onClick={handleReplyButtonClick}>
<Icon name="reply" size={17} />
</div>
<Menu
menuButton={
<div className="reaction-pill">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
</div>
{willRenderNoteCreator && <NoteCreator />}
{willRenderReBroadcast && <ReBroadcaster />}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
<SendSats
lnurl={getLNURL()}
onClose={() => setTip(false)}

View File

@ -5,11 +5,7 @@
text-decoration: none;
user-select: none;
min-width: 0;
}
.pfp .avatar-wrapper {
margin-right: 8px;
z-index: 2;
gap: 12px;
}
.pfp .avatar {

View File

@ -1,16 +1,18 @@
.text {
font-size: var(--font-size);
line-height: 24px;
white-space: pre-wrap;
word-break: break-word;
}
.text > a {
.text .text-frag > a {
color: var(--highlight);
text-decoration: none;
}
.text a:hover {
.text .text-frag > a:hover {
text-decoration: underline;
}
.text .text-frag .hashtag:hover {
text-decoration: underline;
}
@ -65,11 +67,8 @@
.text video,
.text iframe,
.text audio {
max-width: 100%;
max-height: 500px;
margin: 10px auto;
width: 100%;
display: block;
border-radius: 12px;
}
.text iframe,

View File

@ -1,23 +1,13 @@
import "./Text.css";
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { HexKey, NostrPrefix, validateNostrLink } from "@snort/system";
import { HexKey, ParsedFragment, transformText } from "@snort/system";
import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const";
import { eventLink, hexToBech32, splitByUrl } from "SnortUtils";
import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Mention from "Element/Mention";
import HyperText from "Element/HyperText";
import CashuNuts from "Element/CashuNuts";
import { ProxyImg } from "Element/ProxyImg";
export type Fragment = string | React.ReactNode;
export interface TextFragment {
body: React.ReactNode[];
tags: Array<Array<string>>;
}
import RevealMedia from "./RevealMedia";
import { ProxyImg } from "./ProxyImg";
export interface TextProps {
content: string;
@ -29,168 +19,64 @@ export interface TextProps {
}
export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) {
const location = useLocation();
function extractLinks(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return splitByUrl(f).map(a => {
const validateLink = () => {
const normalizedStr = a.toLowerCase();
if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) {
return validateNostrLink(normalizedStr);
}
return (
normalizedStr.startsWith("http:") ||
normalizedStr.startsWith("https:") ||
normalizedStr.startsWith("magnet:")
);
};
if (validateLink()) {
if ((disableMedia ?? false) && !a.startsWith("nostr:")) {
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}
return (
<HyperText link={a} creator={creator} depth={depth} disableMediaSpotlight={disableMediaSpotlight} />
);
function renderChunk(f: Array<ParsedFragment>) {
if (f.every(a => a.type === "media") && f.length === 1) {
if (disableMedia ?? false) {
return (
<a href={f[0].content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{f[0].content}
</a>
);
}
return <RevealMedia link={f[0].content} creator={creator} disableSpotlight={disableMediaSpotlight} />;
} else {
return (
<div className="text-frag">
{f.map(a => {
switch (a.type) {
case "invoice":
return <Invoice invoice={a.content} />;
case "hashtag":
return <Hashtag tag={a.content} />;
case "cashu":
return <CashuNuts token={a.content} />;
case "media":
case "link":
return <HyperText link={a.content} depth={depth} />;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <>{a.content}</>;
}
return a;
});
})}
</div>
);
}
}
const elements = useMemo(() => {
const frags = transformText(content, tags);
const chunked = frags.reduce((acc, v) => {
if (v.type === "media" && !(v.mimeType?.startsWith("unknown") ?? true)) {
if (acc.length === 0) {
acc.push([], [v]);
} else {
acc.push([v]);
}
return f;
})
.flat();
}
function extractCashuTokens(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string" && f.includes("cashuA")) {
return f.split(CashuRegex).map(a => {
return <CashuNuts token={a} />;
});
} else {
if (acc.length === 0) {
acc.push([v]);
} else {
acc[0].push(v);
}
return f;
})
.flat();
}
function extractMentions(frag: TextFragment) {
return frag.body
.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map(match => {
const matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
const idx = parseInt(matchTag[1]);
const ref = frag.tags?.[idx];
if (ref) {
switch (ref[0]) {
case "p": {
return <Mention pubkey={ref[1] ?? ""} relays={ref[2]} />;
}
case "e": {
const eText = hexToBech32(NostrPrefix.Event, ref[1]).substring(0, 12);
return (
ref[1] && (
<Link
to={eventLink(ref[1], ref[2])}
onClick={e => e.stopPropagation()}
state={{ from: location.pathname }}>
#{eText}
</Link>
)
);
}
case "t": {
return <Hashtag tag={ref[1] ?? ""} />;
}
}
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
}
});
}
return f;
})
.flat();
}
function extractInvoices(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(InvoiceRegex).map(i => {
if (i.toLowerCase().startsWith("lnbc")) {
return <Invoice invoice={i} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractHashtags(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(HashtagRegex).map(i => {
if (i.toLowerCase().startsWith("#")) {
return <Hashtag tag={i.substring(1)} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractCustomEmoji(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/:(\w+):/g).map(i => {
const t = tags.find(a => a[0] === "emoji" && a[1] === i);
if (t) {
return <ProxyImg src={t[2]} size={15} className="custom-emoji" />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function transformText(frag: TextFragment) {
let fragments = extractMentions(frag);
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
fragments = extractCashuTokens(fragments);
fragments = extractCustomEmoji(fragments);
return fragments;
}
const element = useMemo(() => {
return <div className="text">{transformText({ body: [content], tags })}</div>;
}
return acc;
}, [] as Array<Array<ParsedFragment>>);
return chunked.reverse();
}, [content]);
return <div dir="auto">{element}</div>;
return (
<div dir="auto" className="text">
{elements.map(a => renderChunk(a))}
</div>
);
}