bug: mentions duplication

This commit is contained in:
2023-04-08 22:29:38 +01:00
parent b650a1684f
commit c79adf7e9e
9 changed files with 243 additions and 230 deletions

View File

@ -95,7 +95,7 @@ export const MnemonicRegex = /^([^\s]+\s){11}[^\s]+$/;
* Extract file extensions regex * Extract file extensions regex
*/ */
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
export const FileExtensionRegex = /\.([\w]+)$/i; export const FileExtensionRegex = /\.([\w]{1,7})$/i;
/** /**
* Extract note reactions regex * Extract note reactions regex

View File

@ -1,9 +1,5 @@
import { useCallback, useState, Children } from "react";
import { useSelector } from "react-redux";
import { TwitterTweetEmbed } from "react-twitter-embed"; import { TwitterTweetEmbed } from "react-twitter-embed";
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import { import {
FileExtensionRegex, FileExtensionRegex,
YoutubeUrlRegex, YoutubeUrlRegex,
@ -17,161 +13,100 @@ import {
NostrNestsRegex, NostrNestsRegex,
WavlakeRegex, WavlakeRegex,
} from "Const"; } from "Const";
import { RootState } from "State/Store"; import { magnetURIDecode } from "Util";
import SoundCloudEmbed from "Element/SoundCloudEmded"; import SoundCloudEmbed from "Element/SoundCloudEmded";
import MixCloudEmbed from "Element/MixCloudEmbed"; import MixCloudEmbed from "Element/MixCloudEmbed";
import SpotifyEmbed from "Element/SpotifyEmbed"; import SpotifyEmbed from "Element/SpotifyEmbed";
import TidalEmbed from "Element/TidalEmbed"; import TidalEmbed from "Element/TidalEmbed";
import { ProxyImg } from "Element/ProxyImg";
import TwitchEmbed from "Element/TwitchEmbed"; import TwitchEmbed from "Element/TwitchEmbed";
import AppleMusicEmbed from "Element/AppleMusicEmbed"; import AppleMusicEmbed from "Element/AppleMusicEmbed";
import NostrNestsEmbed from "Element/NostrNestsEmbed";
import WavlakeEmbed from "Element/WavlakeEmbed"; import WavlakeEmbed from "Element/WavlakeEmbed";
import LinkPreview from "Element/LinkPreview"; import LinkPreview from "Element/LinkPreview";
import NostrLink from "Element/NostrLink"; import NostrLink from "Element/NostrLink";
import RevealMedia from "Element/RevealMedia";
import MagnetLink from "Element/MagnetLink";
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) { export default function HyperText({ link, creator }: { link: string; creator: string }) {
const pref = useSelector((s: RootState) => s.login.preferences); const a = link;
const follows = useSelector((s: RootState) => s.login.follows); try {
const publicKey = useSelector((s: RootState) => s.login.publicKey); const url = new URL(a);
const [reveal, setReveal] = useState(false); const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const wrapReveal = useCallback( const tidalId = TidalRegex.test(a) && RegExp.$1;
(e: JSX.Element, a: string): JSX.Element => { const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator); const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isMine = creator === publicKey; const isSpotifyLink = SpotifyRegex.test(a);
const hideMedia = pref.autoLoadMedia === "none" || (!isMine && hideNonFollows); const isTwitchLink = TwitchRegex.test(a);
const hostname = new URL(a).host; const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
if (hideMedia && !reveal) { const isWavlakeLink = WavlakeRegex.test(a);
return ( const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
<div if (extension && !isAppleMusicLink) {
onClick={e => { return <RevealMedia link={a} creator={creator} />;
e.stopPropagation(); } else if (tweetId) {
setReveal(true); return (
}} <div className="tweet" key={tweetId}>
className="note-invoice"> <TwitterTweetEmbed tweetId={tweetId} />
<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} /> </div>
</div> );
); } else if (youtubeId) {
} else { return (
return e; <iframe
} className="w-max"
}, src={`https://www.youtube.com/embed/${youtubeId}`}
[reveal, pref, follows, publicKey] title="YouTube video player"
); key={youtubeId}
frameBorder="0"
const render = useCallback(() => { allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
const a = link; allowFullScreen={true}
try { />
const url = new URL(a); );
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1; } else if (tidalId) {
const tweetId = TweetUrlRegex.test(a) && RegExp.$2; return <TidalEmbed link={a} />;
const tidalId = TidalRegex.test(a) && RegExp.$1; } else if (soundcloundId) {
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1; return <SoundCloudEmbed link={a} />;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1; } else if (mixcloudId) {
const isSpotifyLink = SpotifyRegex.test(a); return <MixCloudEmbed link={a} />;
const isTwitchLink = TwitchRegex.test(a); } else if (isSpotifyLink) {
const isAppleMusicLink = AppleMusicRegex.test(a); return <SpotifyEmbed link={a} />;
const isNostrNestsLink = NostrNestsRegex.test(a); } else if (isTwitchLink) {
const isWavlakeLink = WavlakeRegex.test(a); return <TwitchEmbed link={a} />;
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1; } else if (isAppleMusicLink) {
if (extension && !isAppleMusicLink) { return <AppleMusicEmbed link={a} />;
switch (extension) { } else if (isNostrNestsLink) {
case "gif": return (
case "jpg": <>
case "jpeg":
case "jfif":
case "png":
case "bmp":
case "webp": {
return <ProxyImg key={url.toString()} src={url.toString()} />;
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm": {
return <video key={url.toString()} src={url.toString()} controls />;
}
default:
return (
<a
key={url.toString()}
href={url.toString()}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{url.toString()}
</a>
);
}
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
);
} else if (youtubeId) {
return (
<iframe
className="w-max"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
} else if (tidalId) {
return <TidalEmbed link={a} />;
} else if (soundcloundId) {
return <SoundCloudEmbed link={a} />;
} else if (mixcloudId) {
return <MixCloudEmbed link={a} />;
} else if (isSpotifyLink) {
return <SpotifyEmbed link={a} />;
} else if (isTwitchLink) {
return <TwitchEmbed link={a} />;
} else if (isAppleMusicLink) {
return <AppleMusicEmbed link={a} />;
} else if (isNostrNestsLink) {
return [
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> <a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a} {a}
</a>, </a>
/*<NostrNestsEmbed link={a} />,*/ {/*<NostrNestsEmbed link={a} />,*/}
]; </>
} else if (isWavlakeLink) { );
return <WavlakeEmbed link={a} />; } else if (isWavlakeLink) {
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") { return <WavlakeEmbed link={a} />;
return <NostrLink link={a} />; } else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
} else { return <NostrLink link={a} />;
return [ } else if (url.protocol === "magnet:") {
const parsed = magnetURIDecode(a);
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} else {
return (
<>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> <a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a} {a}
</a>, </a>
<LinkPreview url={a} />, <LinkPreview url={a} />
]; </>
} );
} catch (error) {
// Ignore the error.
} }
return ( } catch {
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> // Ignore the error.
{a} }
</a> return (
); <a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
}, [link, reveal]); {a}
</a>
const children = render(); );
return <>{Children.map(children, elm => (elm.type === "a" ? elm : wrapReveal(elm, link)))}</>;
} }

View File

@ -0,0 +1,43 @@
import { FileExtensionRegex } from "Const";
import { ProxyImg } from "Element/ProxyImg";
export default function MediaLink({ link }: { link: string }) {
const url = new URL(link);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "jfif":
case "png":
case "bmp":
case "webp": {
return <ProxyImg key={url.toString()} src={url.toString()} />;
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm": {
return <video key={url.toString()} src={url.toString()} controls />;
}
default:
return (
<a
key={url.toString()}
href={url.toString()}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{url.toString()}
</a>
);
}
}

View File

@ -23,6 +23,7 @@ import {
} from "Util"; } from "Util";
import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login"; import { setPinned, setBookmarked } from "State/Login";
import type { RootState } from "State/Store"; import type { RootState } from "State/Store";
@ -83,7 +84,6 @@ export default function Note(props: NoteProps) {
const { pinned, bookmarked } = useSelector((s: RootState) => s.login); const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>(); const [translated, setTranslated] = useState<Translation>();
const [contentWarningAccepted, setContentWarningAccepted] = useState(false);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]); const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]);
const groupReactions = useMemo(() => { const groupReactions = useMemo(() => {
@ -153,7 +153,7 @@ export default function Note(props: NoteProps) {
} }
} }
const transformBody = useCallback(() => { const transformBody = () => {
const body = ev?.content ?? ""; const body = ev?.content ?? "";
if (deletions?.length > 0) { if (deletions?.length > 0) {
return ( return (
@ -163,31 +163,31 @@ export default function Note(props: NoteProps) {
); );
} }
const contentWarning = ev.tags.find(a => a[0] === "content-warning"); const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning && !contentWarningAccepted) { if (contentWarning) {
return ( return (
<div <Reveal
className="note-invoice" message={
onClick={e => {
e.stopPropagation();
setContentWarningAccepted(true);
}}>
<FormattedMessage defaultMessage="This note has been marked as sensitive, click here to reveal" />
{contentWarning[1] && (
<> <>
<br /> <FormattedMessage defaultMessage="This note has been marked as sensitive, click here to reveal" />
<FormattedMessage {contentWarning[1] && (
defaultMessage="Reason: {reason}" <>
values={{ <br />
reason: contentWarning[1], <FormattedMessage
}} defaultMessage="Reason: {reason}"
/> values={{
reason: contentWarning[1],
}}
/>
</>
)}
</> </>
)} }>
</div> <Text content={body} tags={ev.tags} creator={ev.pubkey} />
</Reveal>
); );
} }
return <Text content={body} tags={ev.tags} creator={ev.pubkey} />; return <Text content={body} tags={ev.tags} creator={ev.pubkey} />;
}, [ev, contentWarningAccepted]); };
useLayoutEffect(() => { useLayoutEffect(() => {
if (entry && inView && extendable === false) { if (entry && inView && extendable === false) {

View File

@ -0,0 +1,25 @@
import { useState } from "react";
interface RevealProps {
message: React.ReactNode;
children: React.ReactNode;
}
export default function Reveal(props: RevealProps): JSX.Element {
const [reveal, setReveal] = useState(false);
if (!reveal) {
return (
<div
onClick={e => {
e.stopPropagation();
setReveal(true);
}}
className="note-invoice">
{props.message}
</div>
);
} else {
return <>{props.children}</>;
}
}

View File

@ -0,0 +1,33 @@
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import MediaLink from "Element/MediaLink";
import Reveal from "Element/Reveal";
import { RootState } from "State/Store";
interface RevealMediaProps {
creator: string;
link: string;
}
export default function RevealMedia(props: RevealMediaProps) {
const pref = useSelector((s: RootState) => s.login.preferences);
const follows = useSelector((s: RootState) => s.login.follows);
const publicKey = useSelector((s: RootState) => s.login.publicKey);
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = pref.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;
if (hideMedia) {
return (
<Reveal
message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} />}>
<MediaLink link={props.link} />
</Reveal>
);
} else {
return <MediaLink link={props.link} />;
}
}

View File

@ -1,18 +1,17 @@
import "./Text.css"; import "./Text.css";
import { useMemo, useCallback } from "react"; import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit"; import { visit, SKIP } from "unist-util-visit";
import * as unist from "unist"; import * as unist from "unist";
import { HexKey, NostrPrefix } from "@snort/nostr"; import { HexKey, NostrPrefix } from "@snort/nostr";
import { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const"; import { MentionRegex, InvoiceRegex, HashtagRegex } from "Const";
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util"; import { eventLink, hexToBech32, splitByUrl, unwrap } from "Util";
import Invoice from "Element/Invoice"; import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag"; import Hashtag from "Element/Hashtag";
import Mention from "Element/Mention"; import Mention from "Element/Mention";
import HyperText from "Element/HyperText"; import HyperText from "Element/HyperText";
import MagnetLink from "Element/MagnetLink";
export type Fragment = string | React.ReactNode; export type Fragment = string | React.ReactNode;
@ -36,7 +35,7 @@ export default function Text({ content, tags, creator, disableMedia }: TextProps
.map(f => { .map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return splitByUrl(f).map(a => { return splitByUrl(f).map(a => {
if (a.match(/^(?:https?|(?:web\+)?nostr):/i)) { if (a.match(/^(?:https?|(?:web\+)?nostr|magnet):/i)) {
if (disableMedia ?? false) { if (disableMedia ?? false) {
return ( return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> <a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
@ -44,26 +43,7 @@ export default function Text({ content, tags, creator, disableMedia }: TextProps
</a> </a>
); );
} }
return <HyperText key={a} link={a} creator={creator} />; return <HyperText link={a} creator={creator} />;
}
return a;
});
}
return f;
})
.flat();
}
function extractMagnetLinks(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(MagnetRegex).map(a => {
if (a.startsWith("magnet:")) {
const parsed = magnetURIDecode(a);
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} }
return a; return a;
}); });
@ -168,48 +148,45 @@ export default function Text({ content, tags, creator, disableMedia }: TextProps
fragments = extractLinks(fragments); fragments = extractLinks(fragments);
fragments = extractInvoices(fragments); fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments); fragments = extractHashtags(fragments);
fragments = extractMagnetLinks(fragments);
return fragments; return fragments;
} }
const components = useMemo(() => { const components = {
return { p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }),
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }), a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />, li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }),
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }), };
};
}, [content]);
interface Node extends unist.Node<unist.Data> { interface Node extends unist.Node<unist.Data> {
value: string; value: string;
} }
const disableMarkdownLinks = useCallback( const disableMarkdownLinks = () => (tree: Node) => {
() => (tree: Node) => { visit(tree, (node, index, parent) => {
visit(tree, (node, index, parent) => { if (
if ( parent &&
parent && typeof index === "number" &&
typeof index === "number" && (node.type === "link" ||
(node.type === "link" || node.type === "linkReference" ||
node.type === "linkReference" || node.type === "image" ||
node.type === "image" || node.type === "imageReference" ||
node.type === "imageReference" || node.type === "definition")
node.type === "definition") ) {
) { node.type = "text";
node.type = "text"; const position = unwrap(node.position);
const position = unwrap(node.position); node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )"); return SKIP;
return SKIP; }
} });
}); };
},
[content] const element = useMemo(() => {
); return (
return (
<div dir="auto">
<ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}> <ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}>
{content} {content}
</ReactMarkdown> </ReactMarkdown>
</div> );
); }, [content]);
return <div dir="auto">{element}</div>;
} }

View File

@ -285,7 +285,7 @@ export function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: Metadata
export function splitByUrl(str: string) { export function splitByUrl(str: string) {
const urlRegex = const urlRegex =
/((?:http|ftp|https|nostr|web\+nostr):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~_|]))/i; /((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~_|]))/i;
return str.split(urlRegex); return str.split(urlRegex);
} }

View File

@ -4,9 +4,9 @@
--font-secondary-color: #a7a7a7; --font-secondary-color: #a7a7a7;
--font-tertiary-color: #a3a3a3; --font-tertiary-color: #a3a3a3;
--border-color: rgba(163, 163, 163, 0.3); --border-color: rgba(163, 163, 163, 0.3);
--font-size: 16px; --font-size: 15px;
--font-size-small: 14px; --font-size-small: 13px;
--font-size-tiny: 12px; --font-size-tiny: 11px;
--modal-bg-color: rgba(0, 0, 0, 0.8); --modal-bg-color: rgba(0, 0, 0, 0.8);
--note-bg: #0c0c0c; --note-bg: #0c0c0c;
--highlight: #8b5cf6; --highlight: #8b5cf6;