bug: mentions duplication

This commit is contained in:
Kieran 2023-04-08 22:29:38 +01:00
parent b650a1684f
commit c79adf7e9e
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
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
*/
// eslint-disable-next-line no-useless-escape
export const FileExtensionRegex = /\.([\w]+)$/i;
export const FileExtensionRegex = /\.([\w]{1,7})$/i;
/**
* 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 { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import {
FileExtensionRegex,
YoutubeUrlRegex,
@ -17,161 +13,100 @@ import {
NostrNestsRegex,
WavlakeRegex,
} from "Const";
import { RootState } from "State/Store";
import { magnetURIDecode } from "Util";
import SoundCloudEmbed from "Element/SoundCloudEmded";
import MixCloudEmbed from "Element/MixCloudEmbed";
import SpotifyEmbed from "Element/SpotifyEmbed";
import TidalEmbed from "Element/TidalEmbed";
import { ProxyImg } from "Element/ProxyImg";
import TwitchEmbed from "Element/TwitchEmbed";
import AppleMusicEmbed from "Element/AppleMusicEmbed";
import NostrNestsEmbed from "Element/NostrNestsEmbed";
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";
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) {
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 [reveal, setReveal] = useState(false);
const wrapReveal = useCallback(
(e: JSX.Element, a: string): JSX.Element => {
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
const isMine = creator === publicKey;
const hideMedia = pref.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(a).host;
if (hideMedia && !reveal) {
return (
<div
onClick={e => {
e.stopPropagation();
setReveal(true);
}}
className="note-invoice">
<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} />
</div>
);
} else {
return e;
}
},
[reveal, pref, follows, publicKey]
);
const render = useCallback(() => {
const a = link;
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isSpotifyLink = SpotifyRegex.test(a);
const isTwitchLink = TwitchRegex.test(a);
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) {
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>
);
}
} 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 [
export default function HyperText({ link, creator }: { link: string; creator: string }) {
const a = link;
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isSpotifyLink = SpotifyRegex.test(a);
const isTwitchLink = TwitchRegex.test(a);
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} />;
} 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}
</a>,
/*<NostrNestsEmbed link={a} />,*/
];
} else if (isWavlakeLink) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} />;
} else {
return [
</a>
{/*<NostrNestsEmbed link={a} />,*/}
</>
);
} else if (isWavlakeLink) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} />;
} 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}
</a>,
<LinkPreview url={a} />,
];
}
} catch (error) {
// Ignore the error.
</a>
<LinkPreview url={a} />
</>
);
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}, [link, reveal]);
const children = render();
return <>{Children.map(children, elm => (elm.type === "a" ? elm : wrapReveal(elm, link)))}</>;
} catch {
// Ignore the error.
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}

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";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login";
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 publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const [contentWarningAccepted, setContentWarningAccepted] = useState(false);
const { formatMessage } = useIntl();
const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]);
const groupReactions = useMemo(() => {
@ -153,7 +153,7 @@ export default function Note(props: NoteProps) {
}
}
const transformBody = useCallback(() => {
const transformBody = () => {
const body = ev?.content ?? "";
if (deletions?.length > 0) {
return (
@ -163,31 +163,31 @@ export default function Note(props: NoteProps) {
);
}
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning && !contentWarningAccepted) {
if (contentWarning) {
return (
<div
className="note-invoice"
onClick={e => {
e.stopPropagation();
setContentWarningAccepted(true);
}}>
<FormattedMessage defaultMessage="This note has been marked as sensitive, click here to reveal" />
{contentWarning[1] && (
<Reveal
message={
<>
<br />
<FormattedMessage
defaultMessage="Reason: {reason}"
values={{
reason: contentWarning[1],
}}
/>
<FormattedMessage defaultMessage="This note has been marked as sensitive, click here to reveal" />
{contentWarning[1] && (
<>
<br />
<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} />;
}, [ev, contentWarningAccepted]);
};
useLayoutEffect(() => {
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 { useMemo, useCallback } from "react";
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit";
import * as unist from "unist";
import { HexKey, NostrPrefix } from "@snort/nostr";
import { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const";
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
import { MentionRegex, InvoiceRegex, HashtagRegex } from "Const";
import { eventLink, hexToBech32, splitByUrl, unwrap } from "Util";
import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Mention from "Element/Mention";
import HyperText from "Element/HyperText";
import MagnetLink from "Element/MagnetLink";
export type Fragment = string | React.ReactNode;
@ -36,7 +35,7 @@ export default function Text({ content, tags, creator, disableMedia }: TextProps
.map(f => {
if (typeof f === "string") {
return splitByUrl(f).map(a => {
if (a.match(/^(?:https?|(?:web\+)?nostr):/i)) {
if (a.match(/^(?:https?|(?:web\+)?nostr|magnet):/i)) {
if (disableMedia ?? false) {
return (
<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>
);
}
return <HyperText key={a} 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 <HyperText link={a} creator={creator} />;
}
return a;
});
@ -168,48 +148,45 @@ export default function Text({ content, tags, creator, disableMedia }: TextProps
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
fragments = extractMagnetLinks(fragments);
return fragments;
}
const components = useMemo(() => {
return {
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }),
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }),
};
}, [content]);
const components = {
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }),
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }),
};
interface Node extends unist.Node<unist.Data> {
value: string;
}
const disableMarkdownLinks = useCallback(
() => (tree: Node) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === "number" &&
(node.type === "link" ||
node.type === "linkReference" ||
node.type === "image" ||
node.type === "imageReference" ||
node.type === "definition")
) {
node.type = "text";
const position = unwrap(node.position);
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
return SKIP;
}
});
},
[content]
);
return (
<div dir="auto">
const disableMarkdownLinks = () => (tree: Node) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === "number" &&
(node.type === "link" ||
node.type === "linkReference" ||
node.type === "image" ||
node.type === "imageReference" ||
node.type === "definition")
) {
node.type = "text";
const position = unwrap(node.position);
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
return SKIP;
}
});
};
const element = useMemo(() => {
return (
<ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}>
{content}
</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) {
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);
}

View File

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