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 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]);

View File

@ -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 <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 pubkeys = useMemo(() => {
return getFollowers(feed, pubkey);
return getFollowers(feed.store, pubkey);
}, [feed]);
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 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 <Note key={e.id} data={e} related={others} />
return <Note key={e.id} data={e} related={related.notes} />
}
case EventKind.Reaction:
case EventKind.Repost: {
@ -38,6 +49,11 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
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.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null}
</>

View File

@ -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) {

View File

@ -13,27 +13,38 @@ export type UseSubscriptionOptions = {
}
interface ReducerArg {
type: "END" | "EVENT"
ev?: TaggedRawEvent,
type: "END" | "EVENT" | "CLEAR",
ev?: TaggedRawEvent | Array<TaggedRawEvent>,
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<number>(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
});
}
}
}

View File

@ -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;
}

View File

@ -19,13 +19,13 @@ export interface TimelineSubject {
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60);
const [window, setWindow] = useState<number>(60 * 10);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(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();
}
};
}

View File

@ -8,5 +8,5 @@ export default function EventPage() {
const id = parseId(params.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 => {
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) {
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 <NoteReaction data={a} key={a.id} root={reactedNote} />
}
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 }))} />
</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="flex f-col f-grow">
<div>Debug Menus</div>

View File

@ -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;