v2 start
This commit is contained in:
@ -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} />
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
|
220
packages/app/src/Element/NoteContextMenu.tsx
Normal file
220
packages/app/src/Element/NoteContextMenu.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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)}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user