Note -> EventComponent, split NoteInner

This commit is contained in:
Martti Malmi
2024-01-08 13:15:20 +02:00
parent 615f3ca504
commit 5adaed2737
29 changed files with 133 additions and 135 deletions

View File

@ -0,0 +1,214 @@
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import SnortApi from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import { setBookmarked, setPinned } from "@/Utils/Login";
import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
import { ReBroadcaster } from "../../ReBroadcaster";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const { formatMessage } = useIntl();
const login = useLogin();
const { mute, block } = useModeration();
const { publisher, system } = useEventPublisher();
const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const isMine = ev.pubkey === login.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 = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
const url = `${window.location.protocol}//${window.location.host}/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const api = new SnortApi();
const targetLang = lang.split("-")[0].toUpperCase();
const result = await api.translate({
text: [ev.content],
target_lang: targetLang,
});
if ("translations" in result) {
if (
typeof props.onTranslated === "function" &&
result.translations.length > 0 &&
targetLang != result.translations[0].detected_source_language
) {
props.onTranslated({
text: result.translations[0].text,
fromLanguage: langNames.of(result.translations[0].detected_source_language),
confidence: 1,
} as NoteTranslation);
}
}
}
useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) {
translate();
}
}, []);
async function copyId() {
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...login.pinned.item, id];
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.bookmarks(
es.map(a => new NostrLink(NostrPrefix.Note, a)),
"bookmark",
);
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
const handleReBroadcastButtonClick = () => {
setShowBroadcast(true);
};
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>
{!login.pinned.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!login.bookmarked.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
{!login.readonly && (
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
)}
{login.appData.item.preferences.enableReactions && !login.readonly && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem>
{ev.pubkey !== login.publicKey && !login.readonly && (
<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>
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
{isMine && !login.readonly && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<Menu
menuButton={
<div className="reaction-pill cursor-pointer">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{showBroadcast && <ReBroadcaster ev={ev} onClose={() => setShowBroadcast(false)} />}
</>
);
}

View File

@ -0,0 +1,331 @@
import { normalizeReaction } from "@snort/shared";
import { countLeadingZeros, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import React, { forwardRef, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { ZapsSummary } from "@/Components/Event/Zap";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
import { delay, findTag, getDisplayName } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
import messages from "../../messages";
let isZapperBusy = false;
const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
while (isZapperBusy) {
await delay(100);
}
isZapperBusy = true;
try {
return await then();
} finally {
isZapperBusy = false;
}
};
export interface NoteFooterProps {
replies?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props;
const link = NostrLink.fromEvent(ev);
const related = useReactions(link.id + "related", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
const { positive } = reactions;
const { formatMessage } = useIntl();
const {
publicKey,
preferences: prefs,
readonly,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const { publisher, system } = useEventPublisher();
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const canFastZap = wallet?.isReady() && !readonly;
const isMine = ev.pubkey === publicKey;
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
},
);
function hasReacted(emoji: string) {
return (
interactionCache.data.reacted ||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
}
function hasReposted() {
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
interactionCache.react();
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
}
function getZapTarget(): Array<ZapTarget> | undefined {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: link,
},
} as ZapTarget,
];
}
}
async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setTip(true);
}
} finally {
setZapping(false);
}
} else {
setTip(true);
}
}
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierZapper(async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
await interactionCache.zap();
}
});
}
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap, author, zapping]);
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
<AsyncFooterIcon
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={pow}
/>
);
}
}
function tipButton() {
const targets = getZapTarget();
if (targets) {
return (
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
return null;
}
function repostIcon() {
if (readonly) return;
return (
<Menu
menuButton={
<AsyncFooterIcon
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<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={() => repost()} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);
}
function reactionIcon() {
if (!prefs.enableReactions) {
return null;
}
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positive.length}
onClick={async () => {
if (readonly) return;
await react(prefs.reactionEmoji);
}}
/>
);
}
function replyIcon() {
if (readonly) return;
return (
<AsyncFooterIcon
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={props.replies ?? 0}
onClick={async () => handleReplyButtonClick()}
/>
);
}
const handleReplyButtonClick = () => {
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<>
<div className="footer">
<div className="footer-reactions">
{replyIcon()}
{repostIcon()}
{reactionIcon()}
{tipButton()}
{powIcon()}
</div>
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
<ZapsSummary zaps={zaps} />
</>
);
}
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
};
return (
<AsyncIcon ref={ref} {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -0,0 +1,21 @@
import "../EventComponent.css";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps {
className?: string;
children: React.ReactNode;
}
export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div>
);
}

View File

@ -0,0 +1,287 @@
import { EventExt, EventKind, HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import React, { ReactNode, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import { Link, useNavigate } from "react-router-dom";
import { UserCache } from "@/Cache";
import { NoteText } from "@/Components/Event/Note/NoteText";
import Icon from "@/Components/Icons/Icon";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileLink } from "@/Components/User/ProfileLink";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import { chainKey } from "@/Hooks/useThreadContext";
import { findTag, hexToBech32 } from "@/Utils";
import { setBookmarked, setPinned } from "@/Utils/Login";
import messages from "../../messages";
import Text from "../../Text/Text";
import ProfileImage from "../../User/ProfileImage";
import { NoteProps } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import { NoteContextMenu, NoteTranslation } from "./NoteContextMenu";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import ReactionsModal from "./ReactionsModal";
export function NoteInner(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className);
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const login = useLogin();
const { pinned, bookmarked } = useLogin();
const { publisher, system } = useEventPublisher();
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>();
const { formatMessage } = useIntl();
const options = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
...opt,
};
async function unpin(id: HexKey) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
}
function goToEvent(e: React.MouseEvent, eTarget: TaggedNostrEvent) {
if (opt?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return; // is there a better way to do this?
}
target = target.parentElement;
}
e.stopPropagation();
if (props.onClick) {
props.onClick(eTarget);
return;
}
const link = NostrLink.fromEvent(eTarget);
// detect cmd key and open in new tab
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
} else {
navigate(`/${link.encode(CONFIG.eventLinkPrefix)}`, {
state: eTarget,
});
}
}
function replyTag() {
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo
? NostrLink.fromTag(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
</ProfileLink>
),
});
}
mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1));
const othersLength = mentions.length - maxMentions;
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
return (
<React.Fragment key={m.pk}>
{idx > 0 && ", "}
{m.link}
</React.Fragment>
);
};
const pubMentions =
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions} {others}
</>
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
);
}
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
if (!canRenderAsTextNote.includes(ev.kind)) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
}
function translation() {
if (translated && translated.confidence > 0.5) {
return (
<>
<span
className="text-xs font-semibold text-gray-light select-none"
onClick={e => {
e.stopPropagation();
setShowTranslation(s => !s);
}}>
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</span>
</>
);
} else if (translated) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
}
function pollOptions() {
if (ev.kind !== EventKind.Polls) return;
return <Poll ev={ev} />;
}
function content() {
if (waitUntilInView && !inView) return undefined;
return (
<>
{options.showHeader && (
<div className="header flex">
<ProfileImage
pubkey={ev.pubkey}
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
{(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>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
)}
</div>
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev, true)}>
<NoteText {...props} translated={translated} showTranslation={showTranslation} login={login} />
{translation()}
{pollOptions()}
</div>
{options.showFooter && <NoteFooter ev={ev} replies={props.threadChains?.get(chainKey(ev))?.length} />}
<ReactionsModal show={showReactions} setShow={setShowReactions} event={ev} />
</>
);
}
const note = (
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{note}</HiddenNote> : note;
}

View File

@ -0,0 +1,26 @@
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import Note from "@/Components/Event/EventComponent";
import PageSpinner from "@/Components/PageSpinner";
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
if (!ev.data)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return (
<Note
data={ev.data}
className="note-quote"
depth={(depth ?? 0) + 1}
options={{
showFooter: false,
truncate: true,
}}
/>
);
}

View File

@ -0,0 +1,96 @@
import React, { useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text";
import { LoginSession } from "@/Utils/Login";
const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean; login: LoginSession },
) {
const { data: ev, options, translated, showTranslation, login } = props;
const [showMore, setShowMore] = useState(false);
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = options?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const innerContent = (
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options?.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
if (!login.appData.item.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
<Reveal
message={
<>
<FormattedMessage
defaultMessage="The author has marked this note as a <i>sensitive topic</i>"
id="StKzTE"
values={{
i: c => <i>{c}</i>,
}}
/>
{contentWarning[1] && (
<>
&nbsp;
<FormattedMessage
defaultMessage="Reason: <i>{reason}</i>"
id="6OSOXl"
values={{
i: c => <i>{c}</i>,
reason: contentWarning[1],
}}
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</i>
</Link>
</>
}>
{innerContent}
</Reveal>
);
}
}
return innerContent;
};

View File

@ -0,0 +1,70 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const calcTime = useCallback((fromTime: number) => {
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromTime) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
const fromDate = new Date(fromTime);
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} else {
return fromDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
}
}, []);
const [time, setTime] = useState<string | ReactNode>(calcTime(from));
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
useEffect(() => {
const t = setInterval(() => {
const newTime = calcTime(from);
setTime(s => (s !== newTime ? newTime : s));
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}
</time>
);
};
export default NoteTime;

View File

@ -0,0 +1,117 @@
.reactions-modal .modal-body {
padding: 24px 32px;
background-color: var(--gray-superdark);
border-radius: 16px;
position: relative;
min-height: 33vh;
}
.light .reactions-modal .modal-body {
background-color: var(--gray-superdark);
}
@media (max-width: 720px) {
.reactions-modal .modal-body {
padding: 12px 16px;
max-width: calc(100vw - 32px);
}
}
.reactions-modal .modal-body .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.reactions-modal .modal-body .close:hover {
color: var(--font-tertiary-color);
}
.reactions-modal .modal-body .tabs.p {
padding: 12px 0;
}
.reactions-modal .modal-body .reactions-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.reactions-modal .modal-body .tab {
background: none;
border: none;
}
.reactions-modal .modal-body .tab.active {
background: #fff;
color: #000;
}
.reactions-modal .modal-body .tab:hover {
background: rgba(255, 255, 255, 0.8);
color: #000;
border: none;
}
.reactions-modal .modal-body .reactions-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.reactions-modal .modal-body .reactions-body {
overflow: scroll;
height: 40vh;
-ms-overflow-style: none;
/* for Internet Explorer, Edge */
scrollbar-width: none;
/* Firefox */
margin-top: 12px;
}
.reactions-modal .modal-body .reactions-body::-webkit-scrollbar {
display: none;
}
.reactions-item {
display: grid;
grid-template-columns: 52px auto;
align-items: center;
margin-bottom: 24px;
}
.reactions-item .reaction-icon {
display: flex;
align-items: center;
justify-content: center;
}
.reactions-item .follow-button {
margin-left: auto;
}
.reactions-item .zap-reaction-icon {
width: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.reactions-item .zap-amount {
margin-top: 10px;
font-weight: 500;
font-size: 14px;
line-height: 17px;
}
@media (max-width: 520px) {
.reactions-modal .modal-body .tab.disabled {
display: none;
}
}

View File

@ -0,0 +1,116 @@
import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import Tabs from "@/Components/Tabs/Tabs";
import ProfileImage from "@/Components/User/ProfileImage";
import { formatShort } from "@/Utils/Number";
import messages from "../../messages";
interface ReactionsModalProps {
show: boolean;
setShow(b: boolean): void;
event: TaggedNostrEvent;
}
const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const link = NostrLink.fromEvent(event);
const related = useReactions(link.id + "related", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
const { positive, negative } = reactions;
const sortEvents = events =>
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]);
const sortedReposts = useMemo(() => sortEvents([...reposts]), [reposts]);
const total = positive.length + negative.length + zaps.length + reposts.length;
const createTab = (message, count, value, disabled = false) => ({
text: formatMessage(message, { n: count }),
value,
disabled,
});
const tabs = useMemo(() => {
const baseTabs = [
createTab(messages.Likes, likes.length, 0),
createTab(messages.Zaps, zaps.length, 1, zaps.length === 0),
createTab(messages.Reposts, reposts.length, 2, reposts.length === 0),
];
return dislikes.length !== 0 ? baseTabs.concat(createTab(messages.Dislikes, dislikes.length, 3)) : baseTabs;
}, [likes.length, zaps.length, reposts.length, dislikes.length, formatMessage]);
const [tab, setTab] = useState(tabs[0]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show, tabs]);
const renderReactionItem = (ev, icon, size) => (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name={icon} size={size} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
return show ? (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap" size={20} />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</Modal>
) : null;
};
export default ReactionsModal;