Cache all the things

This commit is contained in:
2023-09-05 14:57:50 +01:00
parent 5521f685fc
commit b1459d0f49
49 changed files with 805 additions and 243 deletions

View File

@ -56,7 +56,7 @@ export default function DM(props: DMProps) {
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
<div>
{sender()}
<Text content={content} tags={[]} creator={otherPubkey} />
<Text id={msg.id} content={content} tags={[]} creator={otherPubkey} />
</div>
<div>
<NoteTime from={msg.created_at * 1000} fallback={formatMessage(messages.JustNow)} />

View File

@ -9,6 +9,7 @@ import AsyncButton from "Element/AsyncButton";
import { System } from "index";
import messages from "./messages";
import { FollowsFeed } from "Cache";
export interface FollowButtonProps {
pubkey: HexKey;
@ -24,6 +25,7 @@ export default function FollowButton(props: FollowButtonProps) {
async function follow(pubkey: HexKey) {
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
await FollowsFeed.backFill(System, [pubkey]);
System.BroadcastEvent(ev);
}
}

View File

@ -8,6 +8,7 @@ import useLogin from "Hooks/useLogin";
import { System } from "index";
import messages from "./messages";
import { FollowsFeed } from "Cache";
export interface FollowListBaseProps {
pubkeys: HexKey[];
@ -34,6 +35,7 @@ export default function FollowListBase({
async function followAll() {
if (publisher) {
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
await FollowsFeed.backFill(System, pubkeys);
System.BroadcastEvent(ev);
}
}

View File

@ -2,7 +2,7 @@ import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
import useEventFeed from "Feed/EventFeed";
import { useEventFeed } from "Feed/EventFeed";
import PageSpinner from "Element/PageSpinner";
import Reveal from "Element/Reveal";
import { MediaElement } from "Element/MediaElement";

View File

@ -36,6 +36,8 @@ import { LiveEvent } from "Element/LiveEvent";
import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu";
import Reactions from "Element/Reactions";
import { ZapGoal } from "Element/ZapGoal";
import NoteReaction from "Element/NoteReaction";
import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages";
@ -81,8 +83,10 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
};
export default function Note(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
const { data: ev, className } = props;
if (ev.kind === EventKind.Repost) {
return <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
}
if (ev.kind === EventKind.FileHeader) {
return <NostrFileElement ev={ev} />;
}
@ -95,10 +99,19 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.LiveEvent) {
return <LiveEvent ev={ev} />;
}
if (ev.kind === EventKind.SetMetadata) {
return <ProfilePreview actions={<></>} pubkey={ev.pubkey} className="card" />;
}
if (ev.kind === (9041 as EventKind)) {
return <ZapGoal ev={ev} />;
}
return <NoteInner {...props} />
}
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
const baseClassName = `note card${className ? ` ${className}` : ""}`;
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
@ -209,12 +222,13 @@ export default function Note(props: NoteProps) {
)}
</>
}>
<Text content={body} tags={ev.tags} creator={ev.pubkey} />
<Text id={ev.id} content={body} tags={ev.tags} creator={ev.pubkey} />
</Reveal>
);
}
return (
<Text
id={ev.id}
content={body}
tags={ev.tags}
creator={ev.pubkey}
@ -307,7 +321,7 @@ export default function Note(props: NoteProps) {
if (alt) {
return (
<div className="note-quote">
<Text content={alt} tags={[]} creator={ev.pubkey} />
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
@ -379,7 +393,7 @@ export default function Note(props: NoteProps) {
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
react={async () => { }}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>

View File

@ -1,4 +1,4 @@
import useEventFeed from "Feed/EventFeed";
import { useEventFeed } from "Feed/EventFeed";
import { NostrLink } from "@snort/system";
import Note from "Element/Note";
import PageSpinner from "Element/PageSpinner";

View File

@ -14,6 +14,7 @@ import { useUserProfile } from "@snort/system-react";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
depth?: number;
}
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
@ -47,7 +48,7 @@ export default function NoteReaction(props: NoteReactionProps) {
try {
const r: NostrEvent = JSON.parse(ev.content);
EventExt.fixupEvent(r);
if(!EventExt.verify(r)) {
if (!EventExt.verify(r)) {
console.debug("Event in repost is invalid");
return undefined;
}
@ -78,7 +79,7 @@ export default function NoteReaction(props: NoteReactionProps) {
}}
/>
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{root ? <Note data={root} options={opt} related={[]} depth={props.depth} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>

View File

@ -116,7 +116,7 @@ export default function Poll(props: PollProps) {
{opt === voting ? (
<Spinner />
) : (
<Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} disableMediaSpotlight={true} />
<Text id={props.ev.id} content={desc} tags={props.ev.tags} creator={props.ev.pubkey} disableMediaSpotlight={true} />
)}
</div>
{showResults && (

View File

@ -3,10 +3,11 @@ import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { RelaySettings } from "@snort/system";
import { unixNowMs } from "@snort/shared";
import useRelayState from "Feed/RelayState";
import { System } from "index";
import { getRelayName, unixNowMs, unwrap } from "SnortUtils";
import { getRelayName, unwrap } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
import Icon from "Icons/Icon";

View File

@ -6,10 +6,8 @@
.text .text-frag {
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: normal;
overflow-x: hidden;
overflow-y: visible;
display: inline;
overflow-wrap: break-word;
}
.text .text-frag > a {

View File

@ -11,70 +11,109 @@ import { ProxyImg } from "./ProxyImg";
import { SpotlightMedia } from "./SpotlightMedia";
export interface TextProps {
id: string;
content: string;
creator: HexKey;
tags: Array<Array<string>>;
disableMedia?: boolean;
disableMediaSpotlight?: boolean;
disableLinkPreview?: boolean;
depth?: number;
truncate?: number;
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) {
const TextCache = new Map<string, Array<ParsedFragment>>();
export default function Text({
id,
content,
tags,
creator,
disableMedia,
depth,
disableMediaSpotlight,
disableLinkPreview,
truncate,
className,
onClick
}: TextProps) {
const [showSpotlight, setShowSpotlight] = useState(false);
const [imageIdx, setImageIdx] = useState(0);
const elements = useMemo(() => {
return transformText(content, tags);
}, [content]);
const cached = TextCache.get(id);
if (cached) return cached;
const newCache = transformText(content, tags);
TextCache.set(id, newCache);
return newCache;
}, [content, id]);
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
function renderChunk(a: ParsedFragment) {
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
return (
<a href={a.content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a.content}
</a>
);
const renderContent = () => {
let lenCtr = 0;
function renderChunk(a: ParsedFragment) {
if (truncate) {
if (lenCtr > truncate) {
return null;
} else if (lenCtr + a.content.length > truncate) {
lenCtr += a.content.length;
return <div className="text-frag">{a.content.slice(0, truncate - lenCtr)}...</div>
} else {
lenCtr += a.content.length;
}
}
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
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} showLinkPreview={!(disableMedia ?? false)}/>;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <div className="text-frag">{a.content}</div>;
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
return (
<a href={a.content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a.content}
</a>
);
}
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
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} showLinkPreview={!(disableLinkPreview ?? false)} />;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <div className="text-frag">{a.content}</div>;
}
}
}
return elements.map(a => renderChunk(a));
}
return (
<div dir="auto" className="text">
{elements.map(a => renderChunk(a))}
<div dir="auto" className={`text${className ? ` ${className}` : ""}`} onClick={onClick}>
{renderContent()}
{showSpotlight && <SpotlightMedia images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);

View File

@ -2,18 +2,14 @@ import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { TaggedNostrEvent, EventKind, u256, parseZap } from "@snort/system";
import { TaggedNostrEvent, EventKind, u256 } from "@snort/system";
import Icon from "Icons/Icon";
import { dedupeByPubkey, findTag, tagFilterOfTextRepost } from "SnortUtils";
import { dedupeByPubkey, findTag } from "SnortUtils";
import ProfileImage from "Element/ProfileImage";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
import Zap from "Element/Zap";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "Element/ProfilePreview";
import { UserCache } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
export interface TimelineProps {
@ -28,7 +24,7 @@ export interface TimelineProps {
}
/**
* A list of notes by pubkeys
* A list of notes by "subject"
*/
const Timeline = (props: TimelineProps) => {
const feedOptions = useMemo(() => {
@ -70,44 +66,10 @@ const Timeline = (props: TimelineProps) => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
const findRelated = useCallback(
(id?: u256) => {
if (!id) return undefined;
return (feed.related ?? []).find(a => a.id === id);
},
[feed.related]
);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function eventElement(e: TaggedNostrEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;
}
case EventKind.Polls:
case EventKind.TextNote: {
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
if (eRef) {
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
return (
<Note key={e.id} data={e} related={relatedFeed(e.id)} ignoreModeration={props.ignoreModeration} depth={0} />
);
}
case EventKind.ZapReceipt: {
const zap = parseZap(e, UserCache);
return zap.event ? null : <Zap zap={zap} key={e.id} />;
}
case EventKind.Reaction:
case EventKind.Repost: {
const eRef = findTag(e, "e");
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
}
}
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
@ -144,7 +106,7 @@ const Timeline = (props: TimelineProps) => {
)}
</>
)}
{mainFeed.map(eventElement)}
{mainFeed.map(e => <Note key={e.id} data={e} related={relatedFeed(e.id)} ignoreModeration={props.ignoreModeration} depth={0} />)}
{(props.loadMore === undefined || props.loadMore === true) && (
<div className="flex f-center">
<button type="button" onClick={() => feed.loadMore()}>

View File

@ -0,0 +1,118 @@
import "./Timeline.css";
import { useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { FormattedMessage } from "react-intl";
import { TaggedNostrEvent, EventKind, u256, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { SnortContext } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { dedupeByPubkey, findTag, orderDescending } from "SnortUtils";
import Note from "Element/Note";
import useModeration from "Hooks/useModeration";
import { FollowsFeed } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
import { useReactions } from "Feed/FeedReactions";
import AsyncButton from "./AsyncButton";
import useLogin from "Hooks/useLogin";
import ProfileImage from "Element/ProfileImage";
import Icon from "Icons/Icon";
export interface TimelineFollowsProps {
postsOnly: boolean;
}
/**
* A list of notes by "subject"
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const [latest, setLatest] = useState(unixNow());
const feed = useSyncExternalStore(cb => FollowsFeed.hook(cb, "*"), () => FollowsFeed.snapshot())
const reactions = useReactions("follows-feed-reactions", feed.map(a => a.id));
const system = useContext(SnortContext);
const login = useLogin();
const { muted, isMuted } = useModeration();
const { ref, inView } = useInView();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
const filterPosts = useCallback(
function <T extends NostrEvent>(nts: Array<T>) {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey));
},
[props.postsOnly, muted, login.follows.timestamp]
);
const mainFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
}, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
const latestFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
}, [sortedFeed, latest]);
const relatedFeed = useCallback(
(id: u256) => {
return (reactions?.data ?? []).filter(a => findTag(a, "e") === id);
},
[reactions]
);
const liveStreams = useMemo(() => {
return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [sortedFeed]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
setLatest(unixNow());
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
<LiveStreams evs={liveStreams} />
{latestFeed.length > 0 && (
<>
<div className="card latest-notes" onClick={() => onShowLatest()} ref={ref}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div className="card latest-notes latest-notes-fixed pointer fade-in" onClick={() => onShowLatest(true)}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{mainFeed.map(a => <Note data={a as TaggedNostrEvent} related={relatedFeed(a.id)} key={a.id} depth={0} />)}
<div className="flex f-center p">
<AsyncButton onClick={async () => {
await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at);
}}>
<FormattedMessage defaultMessage="Load more" />
</AsyncButton>
</div>
</>
);
};
export default TimelineFollows;

View File

@ -28,7 +28,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
</div>
{(content?.length ?? 0) > 0 && sender && (
<div className="body">
<Text creator={sender} content={unwrap(content)} tags={[]} />
<Text id={zap.id} creator={sender} content={unwrap(content)} tags={[]} />
</div>
)}
</div>