192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
import { FormattedMessage } from "react-intl";
|
|
import { Link, useNavigationType } from "react-router-dom";
|
|
|
|
import { Relay } from "@/Cache";
|
|
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
|
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
|
import { TaskList } from "@/Components/Tasks/TaskList";
|
|
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
|
import useFollowsControls from "@/Hooks/useFollowControls";
|
|
import useHistoryState from "@/Hooks/useHistoryState";
|
|
import useLogin from "@/Hooks/useLogin";
|
|
import messages from "@/Pages/messages";
|
|
import { System } from "@/system";
|
|
|
|
const FollowsHint = () => {
|
|
const publicKey = useLogin(s => s.publicKey);
|
|
const { followList } = useFollowsControls();
|
|
if (followList.length === 0 && publicKey) {
|
|
return (
|
|
<FormattedMessage
|
|
{...messages.NoFollows}
|
|
values={{
|
|
newUsersPage: (
|
|
<Link to={"/discover"}>
|
|
<FormattedMessage {...messages.NewUsers} />
|
|
</Link>
|
|
),
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
let forYouFeed = {
|
|
events: [] as NostrEvent[],
|
|
created_at: 0,
|
|
};
|
|
|
|
let getForYouFeedPromise: Promise<NostrEvent[]> | null = null;
|
|
let reactionsRequested = false;
|
|
|
|
const getReactedByFollows = (follows: string[]) => {
|
|
const rb1 = new RequestBuilder("follows:reactions");
|
|
rb1.withFilter().kinds([EventKind.Reaction, EventKind.ZapReceipt]).authors(follows).limit(100);
|
|
const q = System.Query(rb1);
|
|
setTimeout(() => {
|
|
q.cancel();
|
|
const reactedIds = new Set<string>();
|
|
q.snapshot.forEach((ev: TaggedNostrEvent) => {
|
|
const reactedTo = ev.tags.find((t: string[]) => t[0] === "e")?.[1];
|
|
if (reactedTo) {
|
|
reactedIds.add(reactedTo);
|
|
}
|
|
});
|
|
const rb2 = new RequestBuilder("follows:reactedEvents");
|
|
rb2.withFilter().ids(Array.from(reactedIds));
|
|
System.Query(rb2);
|
|
}, 500);
|
|
};
|
|
|
|
export const ForYouTab = memo(function ForYouTab() {
|
|
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
|
|
const login = useLogin(s => ({
|
|
feedDisplayAs: s.feedDisplayAs,
|
|
publicKey: s.publicKey,
|
|
tags: s.state.getList(EventKind.InterestSet),
|
|
}));
|
|
const displayAsInitial = login.feedDisplayAs ?? "list";
|
|
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
|
const navigationType = useNavigationType();
|
|
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
|
const { followList } = useFollowsControls();
|
|
|
|
if (!reactionsRequested && login.publicKey) {
|
|
reactionsRequested = true;
|
|
// on first load, ask relays for reactions to events by follows
|
|
getReactedByFollows(followList);
|
|
}
|
|
|
|
const subject = useMemo(
|
|
() =>
|
|
({
|
|
type: "pubkey",
|
|
items: followList,
|
|
discriminator: login.publicKey?.slice(0, 12),
|
|
extra: rb => {
|
|
if (login.tags.length > 0) {
|
|
rb.withFilter().kinds([EventKind.TextNote]).tags(login.tags);
|
|
}
|
|
},
|
|
}) as TimelineSubject,
|
|
[login.publicKey, followList, login.tags],
|
|
);
|
|
// also get "follows" feed so data is loaded from relays and there's a fallback if "for you" feed is empty
|
|
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
|
const filteredLatestFeed = useMemo(() => {
|
|
return (
|
|
latestFeed.main?.filter((ev: NostrEvent) => {
|
|
// no replies
|
|
return !ev.tags.some((tag: string[]) => tag[0] === "e");
|
|
}) ?? []
|
|
);
|
|
}, [latestFeed.main, subject]);
|
|
|
|
const getFeed = () => {
|
|
if (!login.publicKey) {
|
|
return [];
|
|
}
|
|
if (!getForYouFeedPromise) {
|
|
getForYouFeedPromise = Relay.forYouFeed(login.publicKey);
|
|
}
|
|
getForYouFeedPromise!.then(notes => {
|
|
getForYouFeedPromise = null;
|
|
if (notes.length < 10) {
|
|
setTimeout(() => {
|
|
getForYouFeedPromise = Relay.forYouFeed(login.publicKey!);
|
|
}, 1000);
|
|
}
|
|
forYouFeed = {
|
|
events: notes,
|
|
created_at: Date.now(),
|
|
};
|
|
setNotes(notes);
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (
|
|
forYouFeed.events.length < 10 ||
|
|
(navigationType !== "POP" && Date.now() - forYouFeed.created_at > 1000 * 60 * 2)
|
|
) {
|
|
getFeed();
|
|
}
|
|
}, []);
|
|
|
|
const combinedFeed = useMemo(() => {
|
|
const seen = new Set<string>();
|
|
const combined = [];
|
|
let i = 0; // Index for `notes`
|
|
let j = 0; // Index for `latestFeed.main`
|
|
let count = 0; // Combined feed count to decide when to insert from `latestFeed`
|
|
|
|
while (i < notes.length || j < (filteredLatestFeed.length ?? 0)) {
|
|
// Insert approximately 1 event from `latestFeed` for every 4 events from `notes`
|
|
if (count % 5 === 0 && j < (filteredLatestFeed.length ?? 0)) {
|
|
const ev = filteredLatestFeed[j];
|
|
if (!seen.has(ev.id) && !ev.tags.some((a: string[]) => a[0] === "e")) {
|
|
seen.add(ev.id);
|
|
combined.push(ev);
|
|
}
|
|
j++;
|
|
} else if (i < notes.length) {
|
|
// Add from `notes` otherwise
|
|
const ev = notes[i];
|
|
if (!seen.has(ev.id)) {
|
|
seen.add(ev.id);
|
|
combined.push(ev);
|
|
}
|
|
i++;
|
|
}
|
|
count++;
|
|
}
|
|
return combined;
|
|
}, [notes, filteredLatestFeed]);
|
|
|
|
const frags = useMemo(() => {
|
|
return [
|
|
{
|
|
events: combinedFeed as Array<TaggedNostrEvent>,
|
|
refTime: Date.now(),
|
|
},
|
|
];
|
|
}, [notes]);
|
|
|
|
return (
|
|
<>
|
|
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
|
|
<FollowsHint />
|
|
<TaskList />
|
|
<TimelineRenderer
|
|
frags={frags}
|
|
latest={[]}
|
|
displayAs={displayAs}
|
|
loadMore={() => latestFeed.loadMore()}
|
|
showLatest={() => {}}
|
|
/>
|
|
</>
|
|
);
|
|
});
|