Show unavailable events in threads

This commit is contained in:
hzrd149 2024-01-14 15:50:55 +00:00
parent 151f3c71af
commit fd6ce3ec31
14 changed files with 220 additions and 119 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show unavailable events in threads

View File

@ -5,7 +5,7 @@ import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingRequest, NostrRequestFilter, RelayQueryMap } from "../types/nostr-query";
import Relay, { IncomingEvent } from "./relay";
import relayPoolService from "../services/relay-pool";
import { isFilterEqual } from "../helpers/nostr/filter";
import { isFilterEqual, isQueryMapEqual } from "../helpers/nostr/filter";
export default class NostrMultiSubscription {
static INIT = "initial";
@ -58,7 +58,7 @@ export default class NostrMultiSubscription {
}
setQueryMap(queryMap: RelayQueryMap) {
if (isFilterEqual(this.queryMap, queryMap)) return;
if (isQueryMapEqual(this.queryMap, queryMap)) return;
// add and remove relays
for (const url of Object.keys(queryMap)) {

View File

@ -12,7 +12,13 @@ import EventStore from "./event-store";
import { isReplaceable } from "../helpers/nostr/events";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import deleteEventService from "../services/delete-events";
import { addQueryToFilter, isFilterEqual, mapQueryMap, stringifyFilter } from "../helpers/nostr/filter";
import {
addQueryToFilter,
isFilterEqual,
isQueryMapEqual,
mapQueryMap,
stringifyFilter,
} from "../helpers/nostr/filter";
import { localCacheRelay } from "../services/local-cache-relay";
import { SimpleSubscription } from "nostr-idb";
import { relayRequest } from "../helpers/relay";
@ -178,7 +184,7 @@ export default class TimelineLoader {
}
setQueryMap(queryMap: RelayQueryMap) {
if (isFilterEqual(this.queryMap, queryMap)) return;
if (isQueryMapEqual(this.queryMap, queryMap)) return;
this.log("set query map", queryMap);

View File

@ -1,6 +1,6 @@
import { Suspense, lazy } from "react";
import type { DecodeResult } from "nostr-tools/lib/types/nip19";
import { CardProps, Spinner } from "@chakra-ui/react";
import { Button, CardProps, Spinner } from "@chakra-ui/react";
import { kinds, nip19 } from "nostr-tools";
import EmbeddedNote from "./event-types/embedded-note";
@ -40,6 +40,7 @@ import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
import EmbeddedChannel from "./event-types/embedded-channel";
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
import EmbeddedFlareVideo from "./event-types/embedded-flare-video";
import LoadingNostrLink from "../loading-nostr-link";
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
export type EmbedProps = {
@ -101,17 +102,17 @@ export function EmbedEventPointer({ pointer, ...props }: { pointer: DecodeResult
switch (pointer.type) {
case "note": {
const event = useSingleEvent(pointer.data);
if (event === undefined) return <NoteLink noteId={pointer.data} />;
if (!event) return <LoadingNostrLink link={pointer} />;
return <EmbedEvent event={event} {...props} />;
}
case "nevent": {
const event = useSingleEvent(pointer.data.id, pointer.data.relays);
if (event === undefined) return <NoteLink noteId={pointer.data.id} />;
if (!event) return <LoadingNostrLink link={pointer} />;
return <EmbedEvent event={event} {...props} />;
}
case "naddr": {
const event = useReplaceableEvent(pointer.data);
if (!event) return <span>{nip19.naddrEncode(pointer.data)}</span>;
const event = useReplaceableEvent(pointer.data, pointer.data.relays);
if (!event) return <LoadingNostrLink link={pointer} />;
return <EmbedEvent event={event} {...props} />;
}
case "nrelay":

View File

@ -0,0 +1,86 @@
import { Box, Button, ButtonGroup, Link, Text, useDisclosure } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { ExternalLinkIcon, SearchIcon } from "./icons";
import { buildAppSelectUrl } from "../helpers/nostr/apps";
import UserLink from "./user-link";
import { encodeDecodeResult } from "../helpers/nip19";
export default function LoadingNostrLink({ link }: { link: nip19.DecodeResult }) {
const encoded = encodeDecodeResult(link);
const details = useDisclosure();
const renderDetails = () => {
switch (link.type) {
case "note":
return <Text>ID: {link.data}</Text>;
case "nevent":
return (
<>
<Text>ID: {link.data.id}</Text>
{link.data.kind && <Text>Kind: {link.data.kind}</Text>}
{link.data.author && (
<Text>
Pubkey: <UserLink pubkey={link.data.author} />
</Text>
)}
{link.data.relays && <Text>Relays: {link.data.relays.join(", ")}</Text>}
</>
);
case "npub":
return <Text>Pubkey: {link.data}</Text>;
case "nprofile":
return (
<>
<Text>Pubkey: {link.data.pubkey}</Text>
{link.data.relays && <Text>Relays: {link.data.relays.join(", ")}</Text>}
</>
);
case "naddr":
return (
<>
<Text>Kind: {link.data.kind}</Text>
<Text>
Pubkey: <UserLink pubkey={link.data.pubkey} />
</Text>
<Text>Identifier: {link.data.identifier}</Text>
{link.data.relays && link.data.relays.length > 0 && <Text>Relays: {link.data.relays.join(", ")}</Text>}
</>
);
}
return null;
};
return (
<>
<Button
variant="link"
color="GrayText"
maxW="lg"
textAlign="left"
fontFamily="monospace"
whiteSpace="pre"
onClick={details.onToggle}
>
[{details.isOpen ? "-" : "+"}]
<Text as="span" isTruncated>
{encoded}
</Text>
</Button>
{details.isOpen && (
<Box px="2" fontFamily="monospace" color="GrayText" fontWeight="bold" fontSize="sm">
<Text>Type: {link.type}</Text>
{renderDetails()}
<ButtonGroup variant="link" size="sm" my="1">
<Button leftIcon={<SearchIcon />} colorScheme="primary" isDisabled>
Find
</Button>
<Button as={Link} leftIcon={<ExternalLinkIcon />} href={buildAppSelectUrl(encoded)} isExternal>
Open
</Button>
</ButtonGroup>
</Box>
)}
</>
);
}

View File

@ -1,39 +1,37 @@
import { useRef } from "react";
import { Flex, Heading, Link, SkeletonText, Text } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { Flex, Heading, Link, Text } from "@chakra-ui/react";
import { kinds, nip18 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import { isETag, NostrEvent } from "../../../types/nostr-event";
import { NostrEvent } from "../../../types/nostr-event";
import { Note } from "../../note";
import NoteMenu from "../../note/note-menu";
import UserAvatar from "../../user-avatar";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import UserLink from "../../user-link";
import { TrustProvider } from "../../../providers/local/trust";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
import useSingleEvent from "../../../hooks/use-single-event";
import { EmbedEvent } from "../../embed-event";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import { parseHardcodedNoteContent } from "../../../helpers/nostr/events";
import { getEventCommunityPointer } from "../../../helpers/nostr/communities";
import LoadingNostrLink from "../../loading-nostr-link";
export default function RepostNote({ event }: { event: NostrEvent }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
const muteFilter = useUserMuteFilter();
const hardCodedNote = parseHardcodedNoteContent(event);
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
const readRelays = useReadRelayUrls(relay ? [relay] : []);
const loadedNote = useSingleEvent(eventId, readRelays);
const pointer = nip18.getRepostedEventPointer(event);
const loadedNote = useSingleEvent(pointer?.id, pointer?.relays);
const note = hardCodedNote || loadedNote;
const communityCoordinate = getEventCommunityPointer(event);
if (note && muteFilter(note)) return;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
if ((note && muteFilter(note)) || !pointer) return null;
return (
<TrustProvider event={event}>
@ -59,7 +57,7 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" ml="auto" />
</Flex>
{!note ? (
<SkeletonText />
<LoadingNostrLink link={{ type: "nevent", data: pointer }} />
) : note.kind === kinds.ShortTextNote ? (
// NOTE: tell the note not to register itself with the intersection observer. since this is an older note it will break the order of the timeline
<Note event={note} showReplyButton registerIntersectionEntity={false} />

View File

@ -51,22 +51,22 @@ export function getSharableEventAddress(event: NostrEvent) {
}
}
export function encodePointer(pointer: nip19.DecodeResult) {
switch (pointer.type) {
export function encodeDecodeResult(result: nip19.DecodeResult) {
switch (result.type) {
case "naddr":
return nip19.naddrEncode(pointer.data);
return nip19.naddrEncode(result.data);
case "nprofile":
return nip19.nprofileEncode(pointer.data);
return nip19.nprofileEncode(result.data);
case "nevent":
return nip19.neventEncode(pointer.data);
return nip19.neventEncode(result.data);
case "nrelay":
return nip19.nrelayEncode(pointer.data);
return nip19.nrelayEncode(result.data);
case "nsec":
return nip19.nsecEncode(pointer.data);
return nip19.nsecEncode(result.data);
case "npub":
return nip19.npubEncode(pointer.data);
return nip19.npubEncode(result.data);
case "note":
return nip19.noteEncode(pointer.data);
return nip19.noteEncode(result.data);
}
}

View File

@ -16,6 +16,10 @@ export function isFilterEqual(a: NostrRequestFilter, b: NostrRequestFilter) {
return stringifyFilter(a) === stringifyFilter(b);
}
export function isQueryMapEqual(a: RelayQueryMap, b: RelayQueryMap) {
return stringify(a) === stringify(b);
}
export function mapQueryMap(queryMap: RelayQueryMap, fn: (filter: NostrRequestFilter) => NostrRequestFilter) {
const newMap: RelayQueryMap = {};
for (const [relay, filter] of Object.entries(queryMap)) newMap[relay] = fn(filter);

View File

@ -15,17 +15,17 @@ export default function useThreadTimelineLoader(
kind: number = kinds.ShortTextNote,
) {
const refs = focusedEvent && getReferences(focusedEvent);
const rootId = refs?.root?.e?.id || focusedEvent?.id;
const rootPointer = refs?.root?.e || (focusedEvent && { id: focusedEvent?.id });
const readRelays = unique([...relays, ...(refs?.root?.e?.relays ?? [])]);
const readRelays = unique([...relays, ...(rootPointer?.relays ?? [])]);
const timelineId = `${rootId}-replies`;
const timelineId = `${rootPointer?.id}-replies`;
const timeline = useTimelineLoader(
timelineId,
readRelays,
rootId
rootPointer
? {
"#e": [rootId],
"#e": [rootPointer.id],
kinds: [kind],
}
: undefined,
@ -38,7 +38,7 @@ export default function useThreadTimelineLoader(
for (const e of events) singleEventService.handleEvent(e);
}, [events]);
const rootEvent = useSingleEvent(refs?.root?.e?.id, refs?.root?.e?.relays);
const rootEvent = useSingleEvent(rootPointer?.id, rootPointer?.relays);
const allEvents = useMemo(() => {
const arr = Array.from(events);
if (focusedEvent) arr.push(focusedEvent);
@ -46,5 +46,5 @@ export default function useThreadTimelineLoader(
return arr;
}, [events, rootEvent, focusedEvent]);
return { events: allEvents, rootEvent, rootId, timeline };
return { events: allEvents, rootEvent, rootPointer, timeline };
}

View File

@ -11,12 +11,6 @@ import { NostrEvent } from "../types/nostr-event";
import { LOCAL_CACHE_RELAY, LOCAL_CACHE_RELAY_ENABLED } from "./local-cache-relay";
function hashFilter(filter: NostrRequestFilter) {
// const encoder = new TextEncoder();
// const data = encoder.encode(stringify(filter));
// const hash = await window.crypto.subtle.digest("SHA-256", data);
// const hashArray = Array.from(new Uint8Array(hash));
// const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
// return hashHex;
return stringify(filter);
}

View File

@ -1,48 +1,16 @@
import { Filter } from "nostr-tools";
import { NostrEvent } from "./nostr-event";
export type NostrOutgoingEvent = ["EVENT", NostrEvent];
export type NostrOutgoingRequest = ["REQ", string, ...NostrQuery[]];
export type NostrOutgoingCount = ["COUNT", string, ...NostrQuery[]];
export type NostrOutgoingRequest = ["REQ", string, ...Filter[]];
export type NostrOutgoingCount = ["COUNT", string, ...Filter[]];
export type NostrOutgoingClose = ["CLOSE", string];
export type NostrOutgoingMessage = NostrOutgoingEvent | NostrOutgoingRequest | NostrOutgoingClose | NostrOutgoingCount;
export type NostrQuery = {
ids?: string[];
authors?: string[];
kinds?: number[];
"#a"?: string[];
"#b"?: string[];
"#c"?: string[];
"#d"?: string[];
"#e"?: string[];
"#f"?: string[];
"#g"?: string[];
"#h"?: string[];
"#i"?: string[];
"#j"?: string[];
"#k"?: string[];
"#l"?: string[];
"#m"?: string[];
"#n"?: string[];
"#o"?: string[];
"#p"?: string[];
"#q"?: string[];
"#r"?: string[];
"#s"?: string[];
"#t"?: string[];
"#u"?: string[];
"#v"?: string[];
"#w"?: string[];
"#x"?: string[];
"#y"?: string[];
"#z"?: string[];
since?: number;
until?: number;
limit?: number;
search?: string;
};
/** @deprecated use Filter instead */
export type NostrQuery = Filter;
export type NostrRequestFilter = NostrQuery | NostrQuery[];
export type NostrRequestFilter = Filter | Filter[];
export type RelayQueryMap = Record<string, NostrRequestFilter>;

View File

@ -1,7 +1,7 @@
import { EmbedEventPointer } from "../../../components/embed-event";
import { getGoalEventPointers, getGoalLinks } from "../../../helpers/nostr/goal";
import { NostrEvent } from "../../../types/nostr-event";
import { encodePointer } from "../../../helpers/nip19";
import { encodeDecodeResult } from "../../../helpers/nip19";
import OpenGraphCard from "../../../components/open-graph-card";
export default function GoalContents({ goal }: { goal: NostrEvent }) {
@ -11,7 +11,7 @@ export default function GoalContents({ goal }: { goal: NostrEvent }) {
return (
<>
{pointers.map((pointer) => (
<EmbedEventPointer key={encodePointer(pointer)} pointer={pointer} />
<EmbedEventPointer key={encodeDecodeResult(pointer)} pointer={pointer} />
))}
{links.map((link) => (
<OpenGraphCard url={new URL(link)} />

View File

@ -26,7 +26,7 @@ import ListFeedButton from "../components/list-feed-button";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { COMMUNITY_DEFINITION_KIND } from "../../../helpers/nostr/communities";
import { EmbedEvent, EmbedEventPointer } from "../../../components/embed-event";
import { encodePointer } from "../../../helpers/nip19";
import { encodeDecodeResult } from "../../../helpers/nip19";
import useSingleEvent from "../../../hooks/use-single-event";
import UserAvatarLink from "../../../components/user-avatar-link";
import useParamsAddressPointer from "../../../hooks/use-params-address-pointer";
@ -136,7 +136,7 @@ function ListPage({ list }: { list: NostrEvent }) {
<Flex gap="2" direction="column">
{articles.map((pointer) => {
const decode: DecodeResult = { type: "naddr", data: pointer };
return <EmbedEventPointer key={encodePointer(decode)} pointer={decode} />;
return <EmbedEventPointer key={encodeDecodeResult(decode)} pointer={decode} />;
})}
</Flex>
</>

View File

@ -1,9 +1,9 @@
import { useMemo } from "react";
import { Button, Heading, Spinner } from "@chakra-ui/react";
import { ReactNode, useMemo } from "react";
import { Card, Flex, Heading, Link, Spinner } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import Note from "../../components/note";
import { getSharableEventAddress } from "../../helpers/nip19";
import { ThreadPost } from "./components/thread-post";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
@ -13,12 +13,57 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
import useThreadTimelineLoader from "../../hooks/use-thread-timeline-loader";
import useSingleEvent from "../../hooks/use-single-event";
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
import LoadingNostrLink from "../../components/loading-nostr-link";
import UserName from "../../components/user-name";
import { getSharableEventAddress } from "../../helpers/nip19";
import UserAvatarLink from "../../components/user-avatar-link";
import { ReplyIcon } from "../../components/icons";
function ThreadPage({ thread, rootId, focusId }: { thread: Map<string, ThreadItem>; rootId: string; focusId: string }) {
const isRoot = rootId === focusId;
function CollapsedReplies({
pointer,
thread,
root,
}: {
pointer: nip19.EventPointer;
thread: Map<string, ThreadItem>;
root: nip19.EventPointer;
}) {
const post = thread.get(pointer.id);
if (!post) return <LoadingNostrLink link={{ type: "nevent", data: pointer }} />;
let reply: ReactNode = null;
if (post.refs.reply?.e && post.refs.reply.e.id !== root.id) {
reply = <CollapsedReplies pointer={post.refs.reply.e} thread={thread} root={root} />;
}
return (
<>
{reply}
<Card gap="2" overflow="hidden" px="2" display="flex" flexDirection="row" p="2">
<UserAvatarLink pubkey={post.event.pubkey} size="xs" />
<UserName pubkey={post.event.pubkey} fontWeight="bold" />
{root.id !== pointer.id && <ReplyIcon />}
<Link as={RouterLink} to={`/n/${getSharableEventAddress(post.event)}`} isTruncated>
{post.event.content}
</Link>
</Card>
</>
);
}
function ThreadPage({
thread,
rootPointer,
focusId,
}: {
thread: Map<string, ThreadItem>;
rootPointer: nip19.EventPointer;
focusId: string;
}) {
const isRoot = rootPointer.id === focusId;
const focusedPost = thread.get(focusId);
const rootPost = thread.get(rootId);
const rootPost = thread.get(rootPointer.id);
if (isRoot && rootPost) {
return <ThreadPost post={rootPost} initShowReplies focusId={focusId} />;
}
@ -34,29 +79,22 @@ function ThreadPage({ thread, rootId, focusId }: { thread: Map<string, ThreadIte
}
}
const grandparentPointer = focusedPost.replyingTo?.refs.reply?.e;
return (
<>
{parentPosts.length > 1 && (
<Button
variant="outline"
size="lg"
h="4rem"
w="full"
as={RouterLink}
to={`/n/${getSharableEventAddress(parentPosts[0].event)}`}
>
View full thread ({parentPosts.length - 1})
</Button>
{rootPointer && focusedPost.refs.reply?.e?.id !== rootPointer.id && (
<CollapsedReplies pointer={rootPointer} thread={thread} root={rootPointer} />
)}
{focusedPost.replyingTo && (
<Note
key={focusedPost.replyingTo.event.id + "-rely"}
event={focusedPost.replyingTo.event}
hideDrawerButton
showReplyLine={false}
/>
{grandparentPointer && grandparentPointer.id !== rootPointer.id && (
<CollapsedReplies pointer={grandparentPointer} thread={thread} root={rootPointer} />
)}
<ThreadPost key={focusedPost.event.id} post={focusedPost} initShowReplies focusId={focusId} />
{focusedPost.replyingTo ? (
<Note event={focusedPost.replyingTo.event} hideDrawerButton showReplyLine={false} />
) : (
focusedPost.refs.reply?.e && <LoadingNostrLink link={{ type: "nevent", data: focusedPost.refs.reply.e }} />
)}
<ThreadPost post={focusedPost} initShowReplies focusId={focusId} />
</>
);
}
@ -66,7 +104,7 @@ export default function ThreadView() {
const readRelays = useReadRelayUrls(pointer.relays);
const focusedEvent = useSingleEvent(pointer.id, pointer.relays);
const { rootId, events, timeline } = useThreadTimelineLoader(focusedEvent, readRelays);
const { rootPointer, events, timeline } = useThreadTimelineLoader(focusedEvent, readRelays);
const thread = useMemo(() => buildThread(events), [events]);
const callback = useTimelineCurserIntersectionCallback(timeline);
@ -74,15 +112,16 @@ export default function ThreadView() {
return (
<VerticalPageLayout px={{ base: 0, md: "2" }}>
{!focusedEvent && (
<Heading mx="auto" my="4">
<Spinner /> Loading note
</Heading>
<>
<Heading my="4">
<Spinner /> Loading note
</Heading>
<LoadingNostrLink link={{ type: "nevent", data: pointer }} />
</>
)}
<IntersectionObserverProvider callback={callback}>
{focusedEvent && rootId ? (
<ThreadPage thread={thread} rootId={rootId} focusId={focusedEvent.id} />
) : (
<Spinner />
{focusedEvent && rootPointer && (
<ThreadPage thread={thread} rootPointer={rootPointer} focusId={focusedEvent.id} />
)}
</IntersectionObserverProvider>
</VerticalPageLayout>