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

View File

@ -178,5 +178,23 @@
<path fill="currentColor" d="m 1698.113,694.724 c -53.893,-1.633 -85.466,-2.449 -106.425,-2.722 l -43.005,141.537 h -79.479 l 43.006,-141.537 c -21.231,0.272 -53.077,1.089 -108.059,2.722 l 17.42,-56.342 c 50.082,-2.723 99.076,-3.539 148.069,-3.539 49.266,0 97.442,0.816 145.892,3.539 z" />
</g>
</symbol>
<!-- V2 -->
<symbol id="mail" viewBox="0 0 24 24" fill="none">
<g id="mail-01">
<g id="Solid">
<path d="M2.13352 8.18144C1.83359 7.9672 1.68363 7.86008 1.55288 7.84617C1.35735 7.82537 1.16139 7.92622 1.06467 8.09741C0.999992 8.21189 0.999995 8.39416 1 8.75869V15.2413C0.999988 16.0463 0.999978 16.7106 1.04419 17.2518C1.09012 17.8139 1.18868 18.3306 1.43598 18.816C1.81947 19.5686 2.43139 20.1805 3.18404 20.564C3.66937 20.8113 4.18608 20.9099 4.74818 20.9558C5.28937 21 5.95372 21 6.75868 21H17.2413C18.0463 21 18.7106 21 19.2518 20.9558C19.8139 20.9099 20.3306 20.8113 20.816 20.564C21.5686 20.1805 22.1805 19.5686 22.564 18.816C22.8113 18.3306 22.9099 17.8139 22.9558 17.2518C23 16.7106 23 16.0463 23 15.2413V8.75868C23 8.58001 23 8.49068 22.9836 8.42829C22.9096 8.14627 22.603 7.98561 22.329 8.08531C22.2684 8.10737 22.1941 8.15886 22.0453 8.26184L14.3032 13.6219C13.7542 14.0032 13.2722 14.3379 12.7247 14.4706C12.2458 14.5867 11.7456 14.583 11.2685 14.4599C10.7229 14.3191 10.2459 13.9774 9.70265 13.5881L2.13352 8.18144Z" fill="currentColor"/>
<path d="M22.1328 5.76872C22.3174 5.64092 22.4097 5.57702 22.4664 5.47424C22.5104 5.39443 22.5356 5.26804 22.5255 5.17745C22.5125 5.06079 22.463 4.98377 22.3638 4.82973C21.9839 4.23964 21.4373 3.75256 20.816 3.43598C20.3306 3.18868 19.8139 3.09012 19.2518 3.04419C18.7106 2.99998 18.0463 2.99999 17.2413 3H6.7587C5.95374 2.99999 5.28937 2.99998 4.74818 3.04419C4.18608 3.09012 3.66937 3.18868 3.18404 3.43598C2.62501 3.72082 2.1418 4.1326 1.77436 4.63335C1.65877 4.79089 1.60097 4.86966 1.58192 4.98921C1.56703 5.08269 1.58783 5.21257 1.63116 5.29674C1.68657 5.40436 1.78269 5.47302 1.97493 5.61033L10.75 11.8783C11.4773 12.3977 11.6316 12.4881 11.7681 12.5233C11.9272 12.5644 12.0939 12.5656 12.2535 12.5269C12.3906 12.4937 12.5463 12.4056 13.281 11.8969L22.1328 5.76872Z" fill="currentColor"/>
</g>
</g>
</symbol>
<symbol id="bell-v2" viewBox="0 0 24 24" fill="none" >
<g id="bell-02">
<g id="Solid">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99974 21C8.99974 20.4477 9.44745 20 9.99974 20H13.9997C14.552 20 14.9997 20.4477 14.9997 21C14.9997 21.5523 14.552 22 13.9997 22H9.99974C9.44745 22 8.99974 21.5523 8.99974 21Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.04999 3.05025C8.36275 1.7375 10.1432 1 11.9997 1C13.8563 1 15.6367 1.7375 16.9495 3.05025C18.2622 4.36301 18.9997 6.14349 18.9997 8C18.9997 10.9127 19.7317 12.8439 20.4991 14.0771L20.511 14.0962C20.8683 14.6704 21.1507 15.1243 21.3411 15.4547C21.4366 15.6202 21.5235 15.7797 21.5879 15.9215C21.62 15.9922 21.6559 16.079 21.684 16.1733C21.7073 16.2515 21.7517 16.4187 21.7351 16.6223C21.7239 16.7591 21.696 16.9928 21.5618 17.2343C21.4277 17.4758 21.244 17.623 21.1337 17.7047C20.8834 17.8904 20.596 17.9329 20.5001 17.947L20.4957 17.9477C20.3482 17.9695 20.181 17.9804 20.0122 17.9869C19.677 18 19.2128 18 18.6356 18H5.36388C4.78666 18 4.32252 18 3.98726 17.9869C3.81851 17.9804 3.65123 17.9695 3.50382 17.9477L3.49939 17.947C3.40347 17.9329 3.11604 17.8904 2.86574 17.7047C2.7555 17.623 2.57178 17.4758 2.43762 17.2343C2.30347 16.9928 2.27558 16.7591 2.26439 16.6223C2.24774 16.4187 2.29214 16.2515 2.31545 16.1733C2.34354 16.079 2.37948 15.9922 2.41161 15.9215C2.47598 15.7797 2.56291 15.6202 2.65833 15.4547C2.84876 15.1243 3.13124 14.6703 3.48856 14.0961L3.50035 14.0771C4.26773 12.8439 4.99974 10.9127 4.99974 8C4.99974 6.14348 5.73724 4.36301 7.04999 3.05025Z" fill="currentColor"/>
</g>
</g>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

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

View File

@ -12,14 +12,15 @@
header {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 16px;
justify-content: space-between;
align-items: center;
align-self: stretch;
}
.header-actions .avatar {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
cursor: pointer;
}

View File

@ -12,7 +12,6 @@ import { RootState } from "State/Store";
import { setShow, reset } from "State/NoteCreator";
import { System } from "index";
import useLoginFeed from "Feed/LoginFeed";
import useModeration from "Hooks/useModeration";
import { NoteCreator } from "Element/NoteCreator";
import { mapPlanName } from "./subscribe";
import useLogin from "Hooks/useLogin";
@ -103,7 +102,7 @@ export default function Layout() {
return (
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content mt5">
<header className="main-content">
<div className="logo" onClick={() => navigate("/")}>
<Icon name="snort-by" size={150} height={20} />
{currentSubscription && (
@ -177,11 +176,11 @@ const AccountHeader = () => {
<Icon name="search" />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/messages")}>
<Icon name="envelope" />
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</div>
<div className="btn btn-rnd" onClick={goToNotifications}>
<Icon name="bell" />
<Icon name="bell-v2" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</div>
<Avatar

View File

@ -14,6 +14,9 @@
--success: #2ad544;
--warning: #ff8800;
/* V2 */
--border-primary: #1a1a1a;
--gray-superlight: #eee;
--gray-light: #999;
--gray-medium: #7b7b7b;
@ -115,24 +118,15 @@ body #root > div:not(.page) header {
@media (min-width: 720px) {
.page {
width: 586px;
width: 640px;
margin-left: auto;
margin-right: auto;
}
}
.card {
margin-bottom: 12px;
border-radius: 16px;
background-color: var(--note-bg);
padding: 6px 12px;
}
@media (min-width: 720px) {
.card {
margin-bottom: 16px;
padding: 12px 24px;
}
padding: 16px 12px;
border-bottom: 1px solid var(--border-primary);
}
html.light .card {
@ -560,14 +554,7 @@ small.xs {
}
.main-content {
padding: 0 12px;
position: relative;
}
@media (min-width: 720px) {
.main-content {
padding: 0;
}
border: 1px solid var(--border-primary);
}
.bold {

View File

@ -13,3 +13,24 @@ export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/g;
* How long profile cache should be considered valid for
*/
export const ProfileCacheExpire = 1_000 * 60 * 60 * 6;
/**
* Extract file extensions regex
*/
// eslint-disable-next-line no-useless-escape
export const FileExtensionRegex = /\.([\w]{1,7})$/i;
/**
* Simple lightning invoice regex
*/
export const InvoiceRegex = /(lnbc\w+)/i;
/*
* Regex to match any base64 string
*/
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;
/**
* Regex to match any npub/nevent/naddr/nprofile/note
*/
export const MentionNostrEntityRegex = /@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g;

View File

@ -1,5 +1,5 @@
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner } from ".";
import { HashtagRegex } from "./const";
import { HashtagRegex, MentionNostrEntityRegex } from "./const";
import { getPublicKey, unixNow } from "@snort/shared";
import { EventExt } from "./event-ext";
import { tryParseNostrLink } from "./nostr-link";
@ -43,7 +43,7 @@ export class EventBuilder {
*/
processContent() {
if (this.#content) {
this.#content = this.#content.replace(/@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g, m =>
this.#content = this.#content.replace(MentionNostrEntityRegex, m =>
this.#replaceMention(m)
);

View File

@ -20,6 +20,7 @@ export * from "./nostr-link";
export * from "./profile-cache";
export * from "./zaps";
export * from "./signer";
export * from "./text";
export * from "./impl/nip4";
export * from "./impl/nip44";

204
packages/system/src/text.ts Normal file
View File

@ -0,0 +1,204 @@
import { unwrap } from "@snort/shared";
import { CashuRegex, FileExtensionRegex, HashtagRegex, InvoiceRegex, MentionNostrEntityRegex } from "./const";
import { validateNostrLink } from "./nostr-link";
import { splitByUrl } from "./utils";
export interface ParsedFragment {
type: "text" | "link" | "mention" | "invoice" | "media" | "cashu" | "hashtag" | "custom_emoji"
content: string
mimeType?: string
}
export type Fragment = string | ParsedFragment;
export interface TextFragment {
body: React.ReactNode[];
tags: Array<Array<string>>;
}
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()) {
const url = new URL(a);
const extension = url.pathname.match(FileExtensionRegex);
if (extension && extension.length > 1) {
const mediaType = (() => {
switch (extension[1]) {
case "gif":
case "jpg":
case "jpeg":
case "jfif":
case "png":
case "bmp":
case "webp":
return "image";
case "wav":
case "mp3":
case "ogg":
return "audio";
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm":
case "m3u8":
return "video";
default:
return "unknown";
}
})();
return {
type: "media",
content: a,
mimeType: `${mediaType}/${extension[1]}`
} as ParsedFragment;
} else {
return {
type: "link",
content: a
} as ParsedFragment;
}
}
return a;
});
}
return f;
})
.flat();
}
function extractMentions(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(MentionNostrEntityRegex).map(i => {
if (MentionNostrEntityRegex.test(i)) {
return {
type: "mention",
content: i
} as ParsedFragment;
} else {
return i;
}
});
}
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 {
type: "cashu",
content: a
} as ParsedFragment
});
}
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 {
type: "invoice",
content: i
} as ParsedFragment
} 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 {
type: "hashtag",
content: i.substring(1)
} as ParsedFragment;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractCustomEmoji(fragments: Fragment[], tags: Array<Array<string>>) {
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 {
type: "custom_emoji",
content: t[2]
} as ParsedFragment
} else {
return i;
}
});
}
return f;
})
.flat();
}
export function transformText(body: string, tags: Array<Array<string>>) {
let fragments = extractLinks([body]);
fragments = extractMentions(fragments);
fragments = extractHashtags(fragments);
fragments = extractInvoices(fragments);
fragments = extractCashuTokens(fragments);
fragments = extractCustomEmoji(fragments, tags);
fragments = fragments.map(a => {
if (typeof a === "string") {
if (a.trim().length > 0) {
return { type: "text", content: a } as ParsedFragment;
}
} else {
return a;
}
}).filter(a => a).map(a => unwrap(a));
return fragments as Array<ParsedFragment>;
}

View File

@ -27,19 +27,24 @@ export function reqFilterEq(a: FlatReqFilter | ReqFilter, b: FlatReqFilter | Req
}
export function flatFilterEq(a: FlatReqFilter, b: FlatReqFilter): boolean {
return (
a.keys === b.keys &&
a.since === b.since &&
a.until === b.until &&
a.limit === b.limit &&
a.search === b.search &&
a.ids === b.ids &&
a.kinds === b.kinds &&
a.authors === b.authors &&
a["#e"] === b["#e"] &&
a["#p"] === b["#p"] &&
a["#t"] === b["#t"] &&
a["#d"] === b["#d"] &&
a["#r"] === b["#r"]
);
return a.keys === b.keys
&& a.since === b.since
&& a.until === b.until
&& a.limit === b.limit
&& a.search === b.search
&& a.ids === b.ids
&& a.kinds === b.kinds
&& a.authors === b.authors
&& a["#e"] === b["#e"]
&& a["#p"] === b["#p"]
&& a["#t"] === b["#t"]
&& a["#d"] === b["#d"]
&& a["#r"] === b["#r"];
}
export function splitByUrl(str: string) {
const urlRegex =
/((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i;
return str.split(urlRegex);
}