From d66f9ab18d4f22e119539d558d784fe995db7d32 Mon Sep 17 00:00:00 2001 From: Kieran Date: Sat, 21 Jan 2023 16:09:35 +0000 Subject: [PATCH] feat: show latest --- src/Element/FollowersList.tsx | 2 +- src/Element/FollowsList.tsx | 2 +- src/Element/FollowsYou.tsx | 2 +- src/Element/Timeline.css | 5 +++ src/Element/Timeline.tsx | 22 ++++++++-- src/Feed/LoginFeed.ts | 10 ++--- src/Feed/Subscription.ts | 51 ++++++++++++++++++----- src/Feed/ThreadFeed.ts | 6 +-- src/Feed/TimelineFeed.ts | 67 ++++++++++++++++++++++-------- src/Pages/EventPage.tsx | 2 +- src/Pages/Notifications.tsx | 4 +- src/Pages/settings/Preferences.tsx | 9 ++++ src/State/Login.ts | 10 ++++- 13 files changed, 145 insertions(+), 47 deletions(-) create mode 100644 src/Element/Timeline.css diff --git a/src/Element/FollowersList.tsx b/src/Element/FollowersList.tsx index 58bc3ac7..84d0605d 100644 --- a/src/Element/FollowersList.tsx +++ b/src/Element/FollowersList.tsx @@ -12,7 +12,7 @@ export default function FollowersList({ pubkey }: FollowersListProps) { const feed = useFollowersFeed(pubkey); 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))]; }, [feed]); diff --git a/src/Element/FollowsList.tsx b/src/Element/FollowsList.tsx index 5e61ffc7..69dfa984 100644 --- a/src/Element/FollowsList.tsx +++ b/src/Element/FollowsList.tsx @@ -12,7 +12,7 @@ export default function FollowsList({ pubkey }: FollowsListProps) { const feed = useFollowsFeed(pubkey); const pubkeys = useMemo(() => { - return getFollowers(feed, pubkey); + return getFollowers(feed.store, pubkey); }, [feed]); return diff --git a/src/Element/FollowsYou.tsx b/src/Element/FollowsYou.tsx index e3b0bd81..efaad37d 100644 --- a/src/Element/FollowsYou.tsx +++ b/src/Element/FollowsYou.tsx @@ -15,7 +15,7 @@ export default function FollowsYou({ pubkey }: FollowsYouProps ) { const loginPubKey = useSelector(s => s.login.publicKey); const pubkeys = useMemo(() => { - return getFollowers(feed, pubkey); + return getFollowers(feed.store, pubkey); }, [feed]); const followsMe = pubkeys.includes(loginPubKey!) ?? false ; diff --git a/src/Element/Timeline.css b/src/Element/Timeline.css new file mode 100644 index 00000000..93b08486 --- /dev/null +++ b/src/Element/Timeline.css @@ -0,0 +1,5 @@ +.latest-notes { + cursor: pointer; + font-weight: bold; + user-select: none; +} \ No newline at end of file diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 02d4c03e..ee2ea32c 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -1,3 +1,4 @@ +import "./Timeline.css"; import { useMemo } from "react"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "Nostr"; @@ -5,6 +6,8 @@ import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFastForward, faForward } from "@fortawesome/free-solid-svg-icons"; export interface TimelineProps { postsOnly: boolean, @@ -16,18 +19,26 @@ export interface TimelineProps { * A list of notes by pubkeys */ export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { - const { main, others, loadMore } = useTimelineFeed(subject, { + const { main, related, latest, loadMore, showLatest } = useTimelineFeed(subject, { 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(() => { - 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]); + const latestFeed = useMemo(() => { + return filterPosts(latest.notes); + }, [latest]); + function eventElement(e: TaggedRawEvent) { switch (e.kind) { case EventKind.TextNote: { - return + return } case EventKind.Reaction: case EventKind.Repost: { @@ -38,6 +49,11 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin return ( <> + {latestFeed.length > 0 && (
showLatest()}> + +   + Show latest {latestFeed.length} notes +
)} {mainFeed.map(eventElement)} {mainFeed.length > 0 ? : null} diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index d4c62db8..ce643426 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -47,13 +47,13 @@ export default function useLoginFeed() { const main = useSubscription(sub, { leaveOpen: true }); useEffect(() => { - let contactList = main.notes.filter(a => a.kind === EventKind.ContactList); - let notifications = main.notes.filter(a => a.kind === EventKind.TextNote); - let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata); + let contactList = main.store.notes.filter(a => a.kind === EventKind.ContactList); + let notifications = main.store.notes.filter(a => a.kind === EventKind.TextNote); + let metadata = main.store.notes.filter(a => a.kind === EventKind.SetMetadata); let profiles = metadata.map(a => mapEventToProfile(a)) .filter(a => a !== undefined) .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) { if (cl.content !== "") { @@ -87,7 +87,7 @@ export default function useLoginFeed() { } } })().catch(console.warn); - }, [main]); + }, [main.store]); } async function makeNotification(ev: TaggedRawEvent) { diff --git a/src/Feed/Subscription.ts b/src/Feed/Subscription.ts index 0d5d77dd..76370252 100644 --- a/src/Feed/Subscription.ts +++ b/src/Feed/Subscription.ts @@ -13,27 +13,38 @@ export type UseSubscriptionOptions = { } interface ReducerArg { - type: "END" | "EVENT" - ev?: TaggedRawEvent, + type: "END" | "EVENT" | "CLEAR", + ev?: TaggedRawEvent | Array, end?: boolean } function notesReducer(state: NoteStore, arg: ReducerArg) { if (arg.type === "END") { - state.end = arg.end!; - return state; + return { + notes: state.notes, + end: arg.end! + } as NoteStore; } - let ev = arg.ev!; - if (state.notes.some(a => a.id === ev.id)) { - //state.notes.find(a => a.id == ev.id)?.relays?.push(ev.relays[0]); - return state; + if (arg.type === "CLEAR") { + return { + notes: [], + 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 { notes: [ ...state.notes, - ev + ...evs ] } as NoteStore; } @@ -43,13 +54,19 @@ const initStore: NoteStore = { end: false }; +export interface UseSubscriptionState { + store: NoteStore, + clear: () => void, + append: (notes: TaggedRawEvent[]) => void +} + /** * * @param {Subscriptions} sub * @param {any} opt * @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 [debounce, setDebounce] = useState(0); @@ -91,5 +108,17 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use return () => clearTimeout(t); }, [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 + }); + } + } } \ No newline at end of file diff --git a/src/Feed/ThreadFeed.ts b/src/Feed/ThreadFeed.ts index e13b9358..b40eec28 100644 --- a/src/Feed/ThreadFeed.ts +++ b/src/Feed/ThreadFeed.ts @@ -37,13 +37,13 @@ export default function useThreadFeed(id: u256) { useEffect(() => { // debounce let t = setTimeout(() => { - let eTags = main.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat(); - let ids = main.notes.map(a => a.id); + let eTags = main.store.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat(); + let ids = main.store.notes.map(a => a.id); let allEvents = new Set([...eTags, ...ids]); addId(Array.from(allEvents)); }, 200); return () => clearTimeout(t); - }, [main.notes]); + }, [main.store]); return main; } \ No newline at end of file diff --git a/src/Feed/TimelineFeed.ts b/src/Feed/TimelineFeed.ts index 4118d950..ba095929 100644 --- a/src/Feed/TimelineFeed.ts +++ b/src/Feed/TimelineFeed.ts @@ -19,13 +19,13 @@ export interface TimelineSubject { export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) { const now = unixNow(); - const [window, setWindow] = useState(60 * 60); + const [window, setWindow] = useState(60 * 10); const [until, setUntil] = useState(now); const [since, setSince] = useState(now - window); const [trackingEvents, setTrackingEvent] = useState([]); const pref = useSelector(s => s.login.preferences); - const sub = useMemo(() => { + function createSub() { if (subject.type !== "global" && subject.items.length == 0) { return null; } @@ -43,22 +43,49 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel break; } } - if (options.method === "LIMIT_UNTIL") { - sub.Until = until; - sub.Limit = 10; - } else { - sub.Since = since; - sub.Until = until; - if (since === undefined) { - sub.Limit = 50; + return sub; + } + + const sub = useMemo(() => { + let sub = createSub(); + if (sub) { + if (options.method === "LIMIT_UNTIL") { + sub.Until = until; + sub.Limit = 10; + } else { + sub.Since = since; + sub.Until = until; + if (since === undefined) { + sub.Limit = 50; + } + } + + 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; }, [subject.type, subject.items, until, since, window]); 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(() => { if (trackingEvents.length > 0 && pref.enableReactions) { let sub = new Subscriptions(); @@ -73,26 +100,32 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel const others = useSubscription(subNext, { leaveOpen: true }); useEffect(() => { - if (main.notes.length > 0) { + if (main.store.notes.length > 0) { 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]); return Array.from(temp); }); } - }, [main.notes]); + }, [main.store]); return { - main: main.notes, - others: others.notes, + main: main.store, + related: others.store, + latest: latest.store, loadMore: () => { + console.debug("Timeline load more!") 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); } else { setUntil(s => s - window); setSince(s => s - window); } + }, + showLatest: () => { + main.append(latest.store.notes); + latest.clear(); } }; } \ No newline at end of file diff --git a/src/Pages/EventPage.tsx b/src/Pages/EventPage.tsx index 5f17dd10..24120961 100644 --- a/src/Pages/EventPage.tsx +++ b/src/Pages/EventPage.tsx @@ -8,5 +8,5 @@ export default function EventPage() { const id = parseId(params.id!); const thread = useThreadFeed(id); - return ; + return ; } \ No newline at end of file diff --git a/src/Pages/Notifications.tsx b/src/Pages/Notifications.tsx index 703935f0..f09c269b 100644 --- a/src/Pages/Notifications.tsx +++ b/src/Pages/Notifications.tsx @@ -51,11 +51,11 @@ export default function NotificationsPage() { <> {sorted?.map(a => { if (a.kind === EventKind.TextNote) { - return + return } else if (a.kind === EventKind.Reaction) { let ev = new Event(a); 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 } return null; diff --git a/src/Pages/settings/Preferences.tsx b/src/Pages/settings/Preferences.tsx index c09800ef..4fc38561 100644 --- a/src/Pages/settings/Preferences.tsx +++ b/src/Pages/settings/Preferences.tsx @@ -50,6 +50,15 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} /> +
+
+
Automatically show latest notes
+ Notes will stream in real time into global and posts tab +
+
+ dispatch(setPreferences({ ...perf, autoShowLatest: e.target.checked }))} /> +
+
Debug Menus
diff --git a/src/State/Login.ts b/src/State/Login.ts index 5ceaac79..0af09e6a 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -31,10 +31,15 @@ export interface UserPreferences { */ confirmReposts: boolean, + /** + * Automatically show the latests notes + */ + autoShowLatest: boolean, + /** * Show debugging menus to help diagnose issues */ - showDebugMenus: boolean + showDebugMenus: boolean } export interface LoginStore { @@ -110,7 +115,8 @@ const InitState = { autoLoadMedia: true, theme: "system", confirmReposts: false, - showDebugMenus: false + showDebugMenus: false, + autoShowLatest: false } } as LoginStore;