feat: show latest

This commit is contained in:
Kieran 2023-01-21 16:09:35 +00:00
parent abeb2f5a6c
commit d66f9ab18d
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
13 changed files with 145 additions and 47 deletions

View File

@ -12,7 +12,7 @@ export default function FollowersList({ pubkey }: FollowersListProps) {
const feed = useFollowersFeed(pubkey); const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
let contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)); let contactLists = feed?.store.notes.filter(a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey));
return [...new Set(contactLists?.map(a => a.pubkey))]; return [...new Set(contactLists?.map(a => a.pubkey))];
}, [feed]); }, [feed]);

View File

@ -12,7 +12,7 @@ export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey); const feed = useFollowsFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
return getFollowers(feed, pubkey); return getFollowers(feed.store, pubkey);
}, [feed]); }, [feed]);
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} /> return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />

View File

@ -15,7 +15,7 @@ export default function FollowsYou({ pubkey }: FollowsYouProps ) {
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
return getFollowers(feed, pubkey); return getFollowers(feed.store, pubkey);
}, [feed]); }, [feed]);
const followsMe = pubkeys.includes(loginPubKey!) ?? false ; const followsMe = pubkeys.includes(loginPubKey!) ?? false ;

5
src/Element/Timeline.css Normal file
View File

@ -0,0 +1,5 @@
.latest-notes {
cursor: pointer;
font-weight: bold;
user-select: none;
}

View File

@ -1,3 +1,4 @@
import "./Timeline.css";
import { useMemo } from "react"; import { useMemo } from "react";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "Nostr"; import { TaggedRawEvent } from "Nostr";
@ -5,6 +6,8 @@ import EventKind from "Nostr/EventKind";
import LoadMore from "Element/LoadMore"; import LoadMore from "Element/LoadMore";
import Note from "Element/Note"; import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction"; import NoteReaction from "Element/NoteReaction";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFastForward, faForward } from "@fortawesome/free-solid-svg-icons";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean, postsOnly: boolean,
@ -16,18 +19,26 @@ export interface TimelineProps {
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
const { main, others, loadMore } = useTimelineFeed(subject, { const { main, related, latest, loadMore, showLatest } = useTimelineFeed(subject, {
method method
}); });
const filterPosts = (notes: TaggedRawEvent[]) => {
return [...notes].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
}
const mainFeed = useMemo(() => { const mainFeed = useMemo(() => {
return main?.sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true); return filterPosts(main.notes);
}, [main]); }, [main]);
const latestFeed = useMemo(() => {
return filterPosts(latest.notes);
}, [latest]);
function eventElement(e: TaggedRawEvent) { function eventElement(e: TaggedRawEvent) {
switch (e.kind) { switch (e.kind) {
case EventKind.TextNote: { case EventKind.TextNote: {
return <Note key={e.id} data={e} related={others} /> return <Note key={e.id} data={e} related={related.notes} />
} }
case EventKind.Reaction: case EventKind.Reaction:
case EventKind.Repost: { case EventKind.Repost: {
@ -38,6 +49,11 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
return ( return (
<> <>
{latestFeed.length > 0 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl"/>
&nbsp;
Show latest {latestFeed.length} notes
</div>)}
{mainFeed.map(eventElement)} {mainFeed.map(eventElement)}
{mainFeed.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null} {mainFeed.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null}
</> </>

View File

@ -47,13 +47,13 @@ export default function useLoginFeed() {
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => { useEffect(() => {
let contactList = main.notes.filter(a => a.kind === EventKind.ContactList); let contactList = main.store.notes.filter(a => a.kind === EventKind.ContactList);
let notifications = main.notes.filter(a => a.kind === EventKind.TextNote); let notifications = main.store.notes.filter(a => a.kind === EventKind.TextNote);
let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata); let metadata = main.store.notes.filter(a => a.kind === EventKind.SetMetadata);
let profiles = metadata.map(a => mapEventToProfile(a)) let profiles = metadata.map(a => mapEventToProfile(a))
.filter(a => a !== undefined) .filter(a => a !== undefined)
.map(a => a!); .map(a => a!);
let dms = main.notes.filter(a => a.kind === EventKind.DirectMessage); let dms = main.store.notes.filter(a => a.kind === EventKind.DirectMessage);
for (let cl of contactList) { for (let cl of contactList) {
if (cl.content !== "") { if (cl.content !== "") {
@ -87,7 +87,7 @@ export default function useLoginFeed() {
} }
} }
})().catch(console.warn); })().catch(console.warn);
}, [main]); }, [main.store]);
} }
async function makeNotification(ev: TaggedRawEvent) { async function makeNotification(ev: TaggedRawEvent) {

View File

@ -13,27 +13,38 @@ export type UseSubscriptionOptions = {
} }
interface ReducerArg { interface ReducerArg {
type: "END" | "EVENT" type: "END" | "EVENT" | "CLEAR",
ev?: TaggedRawEvent, ev?: TaggedRawEvent | Array<TaggedRawEvent>,
end?: boolean end?: boolean
} }
function notesReducer(state: NoteStore, arg: ReducerArg) { function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") { if (arg.type === "END") {
state.end = arg.end!; return {
return state; notes: state.notes,
end: arg.end!
} as NoteStore;
} }
let ev = arg.ev!; if (arg.type === "CLEAR") {
if (state.notes.some(a => a.id === ev.id)) { return {
//state.notes.find(a => a.id == ev.id)?.relays?.push(ev.relays[0]); notes: [],
return state; end: state.end,
} as NoteStore;
} }
let evs = arg.ev!;
if (!Array.isArray(evs)) {
evs = [evs];
}
evs = evs.filter(a => !state.notes.some(b => b.id === a.id));
if (evs.length === 0) {
return state;
}
return { return {
notes: [ notes: [
...state.notes, ...state.notes,
ev ...evs
] ]
} as NoteStore; } as NoteStore;
} }
@ -43,13 +54,19 @@ const initStore: NoteStore = {
end: false end: false
}; };
export interface UseSubscriptionState {
store: NoteStore,
clear: () => void,
append: (notes: TaggedRawEvent[]) => void
}
/** /**
* *
* @param {Subscriptions} sub * @param {Subscriptions} sub
* @param {any} opt * @param {any} opt
* @returns * @returns
*/ */
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions) { export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore); const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounce, setDebounce] = useState<number>(0); const [debounce, setDebounce] = useState<number>(0);
@ -91,5 +108,17 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
return () => clearTimeout(t); return () => clearTimeout(t);
}, [state]); }, [state]);
return useMemo(() => state, [debounce]); const stateDebounced = useMemo(() => state, [debounce]);
return {
store: stateDebounced,
clear: () => {
dispatch({ type: "CLEAR" });
},
append: (n: TaggedRawEvent[]) => {
dispatch({
type: "EVENT",
ev: n
});
}
}
} }

View File

@ -37,13 +37,13 @@ export default function useThreadFeed(id: u256) {
useEffect(() => { useEffect(() => {
// debounce // debounce
let t = setTimeout(() => { let t = setTimeout(() => {
let eTags = main.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat(); let eTags = main.store.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
let ids = main.notes.map(a => a.id); let ids = main.store.notes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]); let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents)); addId(Array.from(allEvents));
}, 200); }, 200);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [main.notes]); }, [main.store]);
return main; return main;
} }

View File

@ -19,13 +19,13 @@ export interface TimelineSubject {
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) { export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow(); const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60); const [window, setWindow] = useState<number>(60 * 10);
const [until, setUntil] = useState<number>(now); const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window); const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences); const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const sub = useMemo(() => { function createSub() {
if (subject.type !== "global" && subject.items.length == 0) { if (subject.type !== "global" && subject.items.length == 0) {
return null; return null;
} }
@ -43,6 +43,12 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
break; break;
} }
} }
return sub;
}
const sub = useMemo(() => {
let sub = createSub();
if (sub) {
if (options.method === "LIMIT_UNTIL") { if (options.method === "LIMIT_UNTIL") {
sub.Until = until; sub.Until = until;
sub.Limit = 10; sub.Limit = 10;
@ -54,11 +60,32 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
} }
if (pref.autoShowLatest) {
// copy properties of main sub but with limit 0
// this will put latest directly into main feed
let latestSub = new Subscriptions();
latestSub.Ids = sub.Ids;
latestSub.Kinds = sub.Kinds;
latestSub.Limit = 0;
sub.AddSubscription(latestSub);
}
}
return sub; return sub;
}, [subject.type, subject.items, until, since, window]); }, [subject.type, subject.items, until, since, window]);
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
const subRealtime = useMemo(() => {
let subLatest = createSub();
if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 0;
}
return subLatest;
}, [subject.type, subject.items]);
const latest = useSubscription(subRealtime, { leaveOpen: true });
const subNext = useMemo(() => { const subNext = useMemo(() => {
if (trackingEvents.length > 0 && pref.enableReactions) { if (trackingEvents.length > 0 && pref.enableReactions) {
let sub = new Subscriptions(); let sub = new Subscriptions();
@ -73,26 +100,32 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const others = useSubscription(subNext, { leaveOpen: true }); const others = useSubscription(subNext, { leaveOpen: true });
useEffect(() => { useEffect(() => {
if (main.notes.length > 0) { if (main.store.notes.length > 0) {
setTrackingEvent(s => { setTrackingEvent(s => {
let ids = main.notes.map(a => a.id); let ids = main.store.notes.map(a => a.id);
let temp = new Set([...s, ...ids]); let temp = new Set([...s, ...ids]);
return Array.from(temp); return Array.from(temp);
}); });
} }
}, [main.notes]); }, [main.store]);
return { return {
main: main.notes, main: main.store,
others: others.notes, related: others.store,
latest: latest.store,
loadMore: () => { loadMore: () => {
console.debug("Timeline load more!")
if (options.method === "LIMIT_UNTIL") { if (options.method === "LIMIT_UNTIL") {
let oldest = main.notes.reduce((acc, v) => acc = v.created_at < acc ? v.created_at : acc, unixNow()); let oldest = main.store.notes.reduce((acc, v) => acc = v.created_at < acc ? v.created_at : acc, unixNow());
setUntil(oldest); setUntil(oldest);
} else { } else {
setUntil(s => s - window); setUntil(s => s - window);
setSince(s => s - window); setSince(s => s - window);
} }
},
showLatest: () => {
main.append(latest.store.notes);
latest.clear();
} }
}; };
} }

View File

@ -8,5 +8,5 @@ export default function EventPage() {
const id = parseId(params.id!); const id = parseId(params.id!);
const thread = useThreadFeed(id); const thread = useThreadFeed(id);
return <Thread notes={thread.notes} this={id} />; return <Thread notes={thread.store.notes} this={id} />;
} }

View File

@ -51,11 +51,11 @@ export default function NotificationsPage() {
<> <>
{sorted?.map(a => { {sorted?.map(a => {
if (a.kind === EventKind.TextNote) { if (a.kind === EventKind.TextNote) {
return <Note data={a} key={a.id} related={otherNotes?.notes ?? []} /> return <Note data={a} key={a.id} related={otherNotes?.store.notes ?? []} />
} else if (a.kind === EventKind.Reaction) { } else if (a.kind === EventKind.Reaction) {
let ev = new Event(a); let ev = new Event(a);
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo); let reactedNote = otherNotes?.store.notes?.find(c => c.id === reactedTo);
return <NoteReaction data={a} key={a.id} root={reactedNote} /> return <NoteReaction data={a} key={a.id} root={reactedNote} />
} }
return null; return null;

View File

@ -50,6 +50,15 @@ const PreferencesPage = () => {
<input type="checkbox" checked={perf.confirmReposts} onChange={e => dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} /> <input type="checkbox" checked={perf.confirmReposts} onChange={e => dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} />
</div> </div>
</div> </div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically show latest notes</div>
<small>Notes will stream in real time into global and posts tab</small>
</div>
<div>
<input type="checkbox" checked={perf.autoShowLatest} onChange={e => dispatch(setPreferences({ ...perf, autoShowLatest: e.target.checked }))} />
</div>
</div>
<div className="card flex"> <div className="card flex">
<div className="flex f-col f-grow"> <div className="flex f-col f-grow">
<div>Debug Menus</div> <div>Debug Menus</div>

View File

@ -31,6 +31,11 @@ export interface UserPreferences {
*/ */
confirmReposts: boolean, confirmReposts: boolean,
/**
* Automatically show the latests notes
*/
autoShowLatest: boolean,
/** /**
* Show debugging menus to help diagnose issues * Show debugging menus to help diagnose issues
*/ */
@ -110,7 +115,8 @@ const InitState = {
autoLoadMedia: true, autoLoadMedia: true,
theme: "system", theme: "system",
confirmReposts: false, confirmReposts: false,
showDebugMenus: false showDebugMenus: false,
autoShowLatest: false
} }
} as LoginStore; } as LoginStore;