fix timeline load more bug

This commit is contained in:
hzrd149 2023-02-19 11:14:09 -06:00
parent f52cf23963
commit 982cc9ffa5
16 changed files with 120 additions and 134 deletions

View File

@ -13,7 +13,6 @@ import { GlobalTab } from "./views/home/global-tab";
import { normalizeToHex } from "./helpers/nip-19";
import UserView from "./views/user";
import UserNotesTab from "./views/user/notes";
import UserRepliesTab from "./views/user/replies";
import UserFollowersTab from "./views/user/followers";
import UserRelaysTab from "./views/user/relays";
import UserFollowingTab from "./views/user/following";
@ -86,7 +85,6 @@ const router = createBrowserRouter([
children: [
{ path: "", element: <UserNotesTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "replies", element: <UserRepliesTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "relays", element: <UserRelaysTab /> },

View File

@ -7,7 +7,7 @@ import createDefer from "./deferred";
let lastId = 0;
const REQUEST_DEFAULT_TIMEOUT = 1000 * 20;
const REQUEST_DEFAULT_TIMEOUT = 1000 * 5;
export class NostrRequest {
static IDLE = "idle";
static RUNNING = "running";
@ -16,7 +16,6 @@ export class NostrRequest {
id: string;
timeout: number;
relays: Set<Relay>;
relayCleanup = new Map<Relay, Function>();
state = NostrRequest.IDLE;
onEvent = new Subject<NostrEvent>();
onComplete = createDefer<void>();
@ -27,76 +26,64 @@ export class NostrRequest {
this.relays = new Set(relayUrls.map((url) => relayPoolService.requestRelay(url)));
for (const relay of this.relays) {
const handleEOSE = (event: IncomingEOSE) => {
if (event.subId === this.id) {
this.handleEndOfEvents(relay);
}
};
relay.onEOSE.subscribe(handleEOSE);
const handleEvent = (event: IncomingEvent) => {
if (this.state === NostrRequest.RUNNING && event.subId === this.id && !this.seenEvents.has(event.body.id)) {
this.onEvent.next(event.body);
this.seenEvents.add(event.body.id);
}
};
relay.onEvent.subscribe(handleEvent);
this.relayCleanup.set(relay, () => {
relay.onEOSE.unsubscribe(handleEOSE);
relay.onEvent.unsubscribe(handleEvent);
});
relay.onEOSE.subscribe(this.handleEOSE, this);
relay.onEvent.subscribe(this.handleEvent, this);
}
this.timeout = timeout ?? REQUEST_DEFAULT_TIMEOUT;
}
handleEndOfEvents(relay: Relay) {
this.relays.delete(relay);
relay.send(["CLOSE", this.id]);
handleEOSE(eose: IncomingEOSE) {
if (eose.subId === this.id) {
const relay = eose.relay;
this.relays.delete(relay);
relay.send(["CLOSE", this.id]);
const cleanup = this.relayCleanup.get(relay);
if (cleanup) cleanup();
relay.onEOSE.unsubscribe(this.handleEOSE, this);
relay.onEvent.unsubscribe(this.handleEvent, this);
if (this.relays.size === 0) {
this.state = NostrRequest.COMPLETE;
this.onComplete.resolve();
if (this.relays.size === 0) {
this.state = NostrRequest.COMPLETE;
this.onComplete.resolve();
}
}
}
handleEvent(incomingEvent: IncomingEvent) {
if (
this.state === NostrRequest.RUNNING &&
incomingEvent.subId === this.id &&
!this.seenEvents.has(incomingEvent.body.id)
) {
this.onEvent.next(incomingEvent.body);
this.seenEvents.add(incomingEvent.body.id);
}
}
start(query: NostrQuery) {
if (this.state !== NostrRequest.IDLE) return this;
if (this.state !== NostrRequest.IDLE) {
throw new Error("cant restart a nostr request");
}
this.state = NostrRequest.RUNNING;
for (const relay of this.relays) {
relay.send(["REQ", this.id, query]);
}
setTimeout(() => {
console.log(`NostrRequest: ${this.id} timed out`);
this.cancel();
}, this.timeout);
console.log(`NostrRequest: ${this.id} started`);
setTimeout(() => this.complete(), this.timeout);
return this;
}
cancel() {
if (this.state !== NostrRequest.COMPLETE) return this;
complete() {
if (this.state === NostrRequest.COMPLETE) return this;
this.state = NostrRequest.COMPLETE;
for (const relay of this.relays) {
relay.send(["CLOSE", this.id]);
relay.onEOSE.unsubscribe(this.handleEOSE, this);
relay.onEvent.unsubscribe(this.handleEvent, this);
}
for (const [relay, cleanup] of this.relayCleanup) {
if (cleanup) cleanup();
}
this.relayCleanup = new Map();
this.relays = new Set();
this.onComplete.resolve();
console.log(`NostrRequest: ${this.id} complete`);
return this;
}
}

View File

@ -6,14 +6,17 @@ export type IncomingEvent = {
type: "EVENT";
subId: string;
body: NostrEvent;
relay: Relay;
};
export type IncomingNotice = {
type: "NOTICE";
message: string;
relay: Relay;
};
export type IncomingEOSE = {
type: "EOSE";
subId: string;
relay: Relay;
};
// NIP-20
export type IncomingCommandResult = {
@ -21,6 +24,7 @@ export type IncomingCommandResult = {
eventId: string;
status: boolean;
message?: string;
relay: Relay;
};
export enum RelayMode {
@ -125,16 +129,16 @@ export class Relay {
switch (type) {
case "EVENT":
this.onEvent.next({ type, subId: data[1], body: data[2] });
this.onEvent.next({ relay: this, type, subId: data[1], body: data[2] });
break;
case "NOTICE":
this.onNotice.next({ type, message: data[1] });
this.onNotice.next({ relay: this, type, message: data[1] });
break;
case "EOSE":
this.onEOSE.next({ type, subId: data[1] });
this.onEOSE.next({ relay: this, type, subId: data[1] });
break;
case "OK":
this.onCommandResult.next({ type, eventId: data[1], status: data[2], message: data[3] });
this.onCommandResult.next({ relay: this, type, eventId: data[1], status: data[2], message: data[3] });
break;
}
} catch (e) {

View File

@ -33,7 +33,7 @@ export class ThreadLoader {
this.checkAndUpdateRoot();
request.cancel();
request.complete();
this.loading.next(false);
});
request.start({ ids: [this.focusId.value] });
@ -60,7 +60,7 @@ export class ThreadLoader {
request.onEvent.subscribe((event) => {
this.events.next({ ...this.events.value, [event.id]: event });
request.cancel();
request.complete();
});
request.start({ ids: [this.rootId.value] });
}

View File

@ -19,7 +19,8 @@ export class TimelineLoader {
events = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false);
page = new PersistentSubject(0);
private seenEvents = new Set<string>();
private eventDir = new Map<string, NostrEvent>();
private subscription: NostrMultiSubscription;
private opts: Options = { pageSize: moment.duration(1, "hour").asSeconds() };
@ -48,9 +49,9 @@ export class TimelineLoader {
}
private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) {
this.events.next(this.events.value.concat(event).sort((a, b) => b.created_at - a.created_at));
this.seenEvents.add(event.id);
if (!this.eventDir.has(event.id)) {
this.eventDir.set(event.id, event);
this.events.next(Array.from(this.eventDir.values()).sort((a, b) => b.created_at - a.created_at));
if (this.loading.value) this.loading.next(false);
}
}
@ -69,7 +70,7 @@ export class TimelineLoader {
loadMore() {
if (this.loading.value) return;
const query = { ...this.query, ...this.getPageDates(this.page.value ?? 0) };
const query = { ...this.query, ...this.getPageDates(this.page.value) };
const request = new NostrRequest(this.relays);
request.onEvent.subscribe(this.handleEvent, this);
request.onComplete.then(() => {
@ -78,12 +79,12 @@ export class TimelineLoader {
request.start(query);
this.loading.next(true);
this.page.next(this.page.value ?? 0 + 1);
this.page.next(this.page.value + 1);
}
forgetEvents() {
this.events.next([]);
this.seenEvents.clear();
this.eventDir.clear();
this.subscription.forgetEvents();
}
open() {

View File

@ -14,10 +14,11 @@ import { InlineInvoiceCard } from "../inline-invoice-card";
import { TweetEmbed } from "../tweet-embed";
import { UserLink } from "../user-link";
import { normalizeToHex } from "../../helpers/nip-19";
import { NostrEvent } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../note-link";
import settings from "../../services/settings";
import styled from "@emotion/styled";
import QuoteNote from "./quote-note";
// import { ExternalLinkIcon } from "../icons";
const BlurredImage = (props: ImageProps) => {
@ -31,7 +32,7 @@ const BlurredImage = (props: ImageProps) => {
type EmbedType = {
regexp: RegExp;
render: (match: RegExpMatchArray, event?: NostrEvent, trusted?: boolean) => JSX.Element | string;
render: (match: RegExpMatchArray, event?: NostrEvent | DraftNostrEvent, trusted?: boolean) => JSX.Element | string;
name?: string;
isMedia: boolean;
};
@ -202,7 +203,7 @@ const embeds: EmbedType[] = [
return key ? <UserLink color="blue.500" pubkey={key} showAt /> : match[0];
case "note1":
const noteId = normalizeToHex(match[1]);
return noteId ? <NoteLink noteId={noteId} /> : match[0];
return noteId ? <QuoteNote noteId={noteId} /> : match[0];
default:
return match[0];
}
@ -251,7 +252,11 @@ const MediaEmbed = ({ children, type }: { children: JSX.Element | string; type:
);
};
function embedContent(content: string, event?: NostrEvent, trusted: boolean = false): (string | JSX.Element)[] {
function embedContent(
content: string,
event?: NostrEvent | DraftNostrEvent,
trusted: boolean = false
): (string | JSX.Element)[] {
for (const embedType of embeds) {
const match = content.match(embedType.regexp);
@ -278,7 +283,7 @@ const GradientOverlay = styled.div`
`;
export type NoteContentsProps = {
event: NostrEvent;
event: NostrEvent | DraftNostrEvent;
trusted?: boolean;
maxHeight?: number;
};

View File

@ -0,0 +1,10 @@
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { NoteLink } from "../note-link";
const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => {
const relays = useReadRelayUrls(relay ? [relay] : []);
return <NoteLink noteId={noteId} />;
};
export default QuoteNote;

View File

@ -22,6 +22,8 @@ const FollowingSideNav = () => {
export const Page = ({ children }: { children: React.ReactNode }) => {
const isMobile = useIsMobile();
console.log(isMobile);
return (
<Container
size="lg"

View File

@ -8,6 +8,7 @@ import {
Button,
Textarea,
Text,
useDisclosure,
} from "@chakra-ui/react";
import moment from "moment";
import React, { useState } from "react";
@ -19,6 +20,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../note-link";
import { NoteContents } from "../note/note-contents";
import { PostResults } from "./post-results";
function emptyDraft(): DraftNostrEvent {
@ -37,14 +39,12 @@ type PostModalProps = {
};
export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => {
const isMobile = useIsMobile();
const pad = isMobile ? "2" : "4";
const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls();
const [waiting, setWaiting] = useState(false);
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
const [results, resultsActions] = useList<PostResult>();
const { isOpen: showPreview, onToggle: togglePreview } = useDisclosure();
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
@ -71,32 +71,27 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const renderContent = () => {
if (signedEvent) {
return (
<ModalBody padding="4">
<PostResults event={signedEvent} results={results} onClose={onClose} />
</ModalBody>
);
return <PostResults event={signedEvent} results={results} onClose={onClose} />;
}
return (
<>
<ModalBody pr={pad} pl={pad}>
{refs.replyId && (
<Text mb="2">
Replying to: <NoteLink noteId={refs.replyId} />
</Text>
)}
{refs.replyId && (
<Text mb="2">
Replying to: <NoteLink noteId={refs.replyId} />
</Text>
)}
{showPreview ? (
<NoteContents event={draft} trusted />
) : (
<Textarea autoFocus mb="2" value={draft.content} onChange={handleContentChange} rows={5} />
</ModalBody>
<ModalFooter pr={pad} pl={pad} pb={pad} pt="0">
<Flex gap="2" alignItems="center">
<Button onClick={onClose} isDisabled={waiting} ml="auto">
Cancel
</Button>
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
Post
</Button>
</Flex>
</ModalFooter>
)}
<Flex gap="2" alignItems="center" justifyContent="flex-end">
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
<Button onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
Post
</Button>
</Flex>
</>
);
};
@ -104,7 +99,9 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>{renderContent()}</ModalContent>
<ModalContent>
<ModalBody padding="4">{renderContent()}</ModalBody>
</ModalContent>
</Modal>
);
};

View File

@ -1,7 +1,14 @@
import { useMediaQuery } from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
export function useIsMobile() {
const [isMobile] = useMediaQuery("(max-width: 1000px)");
const match = useMemo(() => window.matchMedia("(max-width: 1000px)"), []);
const [matches, setMatches] = useState(match.matches);
return isMobile;
useEffect(() => {
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
match.addEventListener("change", listener);
return () => match.removeEventListener("change", listener);
}, [match]);
return matches;
}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { useDeepCompareEffect, useUnmount } from "react-use";
import { useUnmount } from "react-use";
import { NostrQueryWithStart, TimelineLoader, TimelineLoaderOptions } from "../classes/timeline-loader";
import useSubject from "./use-subject";
@ -18,9 +18,9 @@ export function useTimelineLoader(key: string, relays: string[], query: NostrQue
loader.setQuery(query);
}, [key]);
useDeepCompareEffect(() => {
useEffect(() => {
loader.setRelays(relays);
}, [relays]);
}, [relays.join("|")]);
const enabled = opts?.enabled ?? true;
useEffect(() => {

View File

@ -73,7 +73,7 @@ export const DiscoverTab = () => {
}, [discover]);
const { events, loading, loadMore } = useTimelineLoader(
`discover`,
`${account.pubkey}-discover`,
relays,
{ authors: throttledPubkeys, kinds: [1], since: moment().subtract(1, "hour").unix() },
{ pageSize: moment.duration(1, "hour").asSeconds(), enabled: throttledPubkeys.length > 0 }

View File

@ -24,7 +24,7 @@ export const FollowingTab = () => {
const following = contacts?.contacts || [];
const { events, loading, loadMore } = useTimelineLoader(
`following-posts`,
`${account.pubkey}-following-posts`,
relays,
{ authors: following, kinds: [1], since: moment().subtract(2, "hour").unix() },
{ pageSize: moment.duration(2, "hour").asSeconds(), enabled: following.length > 0 }

View File

@ -30,7 +30,6 @@ import { useCurrentAccount } from "../../hooks/use-current-account";
const tabs = [
{ label: "Notes", path: "notes" },
{ label: "Replies", path: "replies" },
{ label: "Followers", path: "followers" },
{ label: "Following", path: "following" },
{ label: "Relays", path: "relays" },

View File

@ -1,4 +1,4 @@
import { Button, Flex, Spinner } from "@chakra-ui/react";
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
@ -9,17 +9,24 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useReadRelayUrls();
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} notes`,
`${pubkey}-notes`,
relays,
{ authors: [pubkey], kinds: [1], since: moment().subtract(1, "day").unix() },
{ pageSize: moment.duration(1, "day").asSeconds() }
);
const timeline = events.filter(isNote);
const timeline = showReplies ? events : events.filter(isNote);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={toggleReplies} />
</FormControl>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={300} />
))}

View File

@ -1,31 +0,0 @@
import { Button, Flex, Spinner } from "@chakra-ui/react";
import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
import { isReply } from "../../helpers/nostr-event";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserRepliesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useReadRelayUrls();
const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} replies`,
relays,
{ authors: [pubkey], kinds: [1], since: moment().subtract(4, "hours").unix() },
{ pageSize: moment.duration(1, "day").asSeconds() }
);
const timeline = events.filter(isReply);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={300} />
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);
};
export default UserRepliesTab;