show repost counts on notes

hide goals, streams, and articles tabs in user view if user has none
This commit is contained in:
hzrd149 2023-10-12 08:44:07 -05:00
parent c635b2bc4b
commit 5a455c7d49
12 changed files with 132 additions and 23 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show repost counts on notes

View File

@ -34,6 +34,7 @@
"hls.js": "^1.4.10", "hls.js": "^1.4.10",
"idb": "^7.1.1", "idb": "^7.1.1",
"identicon.js": "^2.3.3", "identicon.js": "^2.3.3",
"json-stringify-deterministic": "^1.0.11",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0", "leaflet.locatecontrol": "^0.79.0",
"light-bolt11-decoder": "^3.0.0", "light-bolt11-decoder": "^3.0.0",

View File

@ -1,8 +1,8 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { NostrEvent } from "../types/nostr-event"; import { CountResponse, NostrEvent } from "../types/nostr-event";
import { NostrRequestFilter } from "../types/nostr-query"; import { NostrRequestFilter } from "../types/nostr-query";
import relayPoolService from "../services/relay-pool"; import relayPoolService from "../services/relay-pool";
import Relay, { IncomingEOSE, IncomingEvent } from "./relay"; import Relay, { IncomingCount, IncomingEOSE, IncomingEvent } from "./relay";
import Subject from "./subject"; import Subject from "./subject";
import createDefer from "./deferred"; import createDefer from "./deferred";
@ -16,7 +16,8 @@ export default class NostrRequest {
timeout: number; timeout: number;
relays: Set<Relay>; relays: Set<Relay>;
state = NostrRequest.IDLE; state = NostrRequest.IDLE;
onEvent = new Subject<NostrEvent>(); onEvent = new Subject<NostrEvent>(undefined, false);
onCount = new Subject<CountResponse>(undefined, false);
onComplete = createDefer<void>(); onComplete = createDefer<void>();
seenEvents = new Set<string>(); seenEvents = new Set<string>();
@ -27,6 +28,7 @@ export default class NostrRequest {
for (const relay of this.relays) { for (const relay of this.relays) {
relay.onEOSE.subscribe(this.handleEOSE, this); relay.onEOSE.subscribe(this.handleEOSE, this);
relay.onEvent.subscribe(this.handleEvent, this); relay.onEvent.subscribe(this.handleEvent, this);
relay.onCount.subscribe(this.handleCount, this);
} }
this.timeout = timeout ?? REQUEST_DEFAULT_TIMEOUT; this.timeout = timeout ?? REQUEST_DEFAULT_TIMEOUT;
@ -57,8 +59,13 @@ export default class NostrRequest {
this.seenEvents.add(incomingEvent.body.id); this.seenEvents.add(incomingEvent.body.id);
} }
} }
handleCount(incomingCount: IncomingCount) {
if (incomingCount.subId === this.id) {
this.onCount.next({ count: incomingCount.count, approximate: incomingCount.approximate });
}
}
start(filter: NostrRequestFilter) { start(filter: NostrRequestFilter, type: "REQ" | "COUNT" = "REQ") {
if (this.state !== NostrRequest.IDLE) { if (this.state !== NostrRequest.IDLE) {
throw new Error("cant restart a nostr request"); throw new Error("cant restart a nostr request");
} }
@ -66,8 +73,8 @@ export default class NostrRequest {
this.state = NostrRequest.RUNNING; this.state = NostrRequest.RUNNING;
for (const relay of this.relays) { for (const relay of this.relays) {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
relay.send(["REQ", this.id, ...filter]); relay.send([type, this.id, ...filter]);
} else relay.send(["REQ", this.id, filter]); } else relay.send([type, this.id, filter]);
} }
setTimeout(() => this.complete(), this.timeout); setTimeout(() => this.complete(), this.timeout);

View File

@ -16,6 +16,7 @@ export type IncomingNotice = {
}; };
export type IncomingCount = { export type IncomingCount = {
type: "COUNT"; type: "COUNT";
subId: string;
relay: Relay; relay: Relay;
} & CountResponse; } & CountResponse;
export type IncomingEOSE = { export type IncomingEOSE = {
@ -116,7 +117,7 @@ export default class Relay {
this.ws?.send(JSON.stringify(json)); this.ws?.send(JSON.stringify(json));
// record start time // record start time
if (json[0] === "REQ") { if (json[0] === "REQ" || json[0] === "COUNT") {
this.startSubResTimer(json[1]); this.startSubResTimer(json[1]);
} }
} else this.queue.push(json); } else this.queue.push(json);
@ -186,7 +187,7 @@ export default class Relay {
this.onNotice.next({ relay: this, type, message: data[1] }); this.onNotice.next({ relay: this, type, message: data[1] });
break; break;
case "COUNT": case "COUNT":
this.onCount.next({ relay: this, type, ...data[2] }); this.onCount.next({ relay: this, type, subId: data[1], ...data[2] });
break; break;
case "EOSE": case "EOSE":
this.onEOSE.next({ relay: this, type, subId: data[1] }); this.onEOSE.next({ relay: this, type, subId: data[1] });

View File

@ -116,7 +116,7 @@ export default function PayStep({ callbacks, onComplete }: { callbacks: PayReque
{callbacks.map(({ pubkey, invoice, error }) => { {callbacks.map(({ pubkey, invoice, error }) => {
if (paid.includes(pubkey)) if (paid.includes(pubkey))
return ( return (
<UserCard pubkey={pubkey}> <UserCard key={pubkey} pubkey={pubkey}>
<Button size="sm" variant="outline" colorScheme="green" leftIcon={<CheckIcon />}> <Button size="sm" variant="outline" colorScheme="green" leftIcon={<CheckIcon />}>
Paid Paid
</Button> </Button>

View File

@ -23,6 +23,7 @@ import { useSigningContext } from "../../../providers/signing-provider";
import { EmbedEvent } from "../../embed-event"; import { EmbedEvent } from "../../embed-event";
import relayScoreboardService from "../../../services/relay-scoreboard"; import relayScoreboardService from "../../../services/relay-scoreboard";
import { getEventRelays } from "../../../services/event-relays"; import { getEventRelays } from "../../../services/event-relays";
import useEventCount from "../../../hooks/use-event-count";
function buildRepost(event: NostrEvent): DraftNostrEvent { function buildRepost(event: NostrEvent): DraftNostrEvent {
const relays = getEventRelays(event.id).value; const relays = getEventRelays(event.id).value;
@ -45,6 +46,8 @@ export function RepostButton({ event }: { event: NostrEvent }) {
const toast = useToast(); const toast = useToast();
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const repostCount = useEventCount({ "#e": [event.id], kinds: [6] });
const handleClick = async () => { const handleClick = async () => {
try { try {
setLoading(true); setLoading(true);
@ -61,13 +64,19 @@ export function RepostButton({ event }: { event: NostrEvent }) {
return ( return (
<> <>
<IconButton {repostCount !== undefined && repostCount > 0 ? (
icon={<RepostIcon />} <Button leftIcon={<RepostIcon />} onClick={onOpen} title="Repost Note" isLoading={loading}>
onClick={onOpen} {repostCount}
aria-label="Repost Note" </Button>
title="Repost Note" ) : (
isLoading={loading} <IconButton
/> icon={<RepostIcon />}
onClick={onOpen}
aria-label="Repost Note"
title="Repost Note"
isLoading={loading}
/>
)}
{isOpen && ( {isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="2xl"> <Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay /> <ModalOverlay />

View File

@ -0,0 +1,10 @@
import { useMemo } from "react";
import eventCountService from "../services/event-count";
import { NostrRequestFilter } from "../types/nostr-query";
import useSubject from "./use-subject";
export default function useEventCount(filter?: NostrRequestFilter, alwaysRequest = false) {
const key = filter ? eventCountService.stringifyFilter(filter) : "empty";
const subject = useMemo(() => filter && eventCountService.requestCount(filter, alwaysRequest), [key, alwaysRequest]);
return useSubject(subject);
}

View File

@ -0,0 +1,5 @@
import useEventCount from "./use-event-count";
export default function useUserEventKindCount(pubkey: string, kind: number) {
return useEventCount({ authors: [pubkey], kinds: [kind] });
}

View File

@ -0,0 +1,44 @@
import stringify from "json-stringify-deterministic";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrRequestFilter } from "../types/nostr-query";
import NostrRequest from "../classes/nostr-request";
// TODO: move this to settings
const COUNT_RELAY = "wss://relay.nostr.band";
class EventCountService {
subjects = new SuperMap<string, Subject<number>>(() => new Subject<number>());
stringifyFilter(filter: NostrRequestFilter) {
return stringify(filter);
}
requestCount(filter: NostrRequestFilter, alwaysRequest = false) {
const key = this.stringifyFilter(filter);
const sub = this.subjects.get(key);
if (sub.value === undefined || alwaysRequest) {
const request = new NostrRequest([COUNT_RELAY]);
request.onCount.subscribe((c) => sub.next(c.count));
request.start(filter, "COUNT");
}
return sub;
}
getCount(filter: NostrRequestFilter) {
const key = this.stringifyFilter(filter);
const sub = this.subjects.get(key);
return sub;
}
}
const eventCountService = new EventCountService();
if (import.meta.env.DEV) {
// @ts-ignore
window.eventCountService = eventCountService;
}
export default eventCountService;

View File

@ -2,9 +2,10 @@ import { NostrEvent } from "./nostr-event";
export type NostrOutgoingEvent = ["EVENT", NostrEvent]; export type NostrOutgoingEvent = ["EVENT", NostrEvent];
export type NostrOutgoingRequest = ["REQ", string, ...NostrQuery[]]; export type NostrOutgoingRequest = ["REQ", string, ...NostrQuery[]];
export type NostrOutgoingCount = ["COUNT", string, ...NostrQuery[]];
export type NostrOutgoingClose = ["CLOSE", string]; export type NostrOutgoingClose = ["CLOSE", string];
export type NostrOutgoingMessage = NostrOutgoingEvent | NostrOutgoingRequest | NostrOutgoingClose; export type NostrOutgoingMessage = NostrOutgoingEvent | NostrOutgoingRequest | NostrOutgoingClose | NostrOutgoingCount;
export type NostrQuery = { export type NostrQuery = {
ids?: string[]; ids?: string[];

View File

@ -29,17 +29,21 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata"; import { getUserDisplayName } from "../../helpers/user-metadata";
import { isHexKey } from "../../helpers/nip19"; import { isHexKey } from "../../helpers/nip19";
import { useAppTitle } from "../../hooks/use-app-title"; import { useAppTitle } from "../../hooks/use-app-title";
import { Suspense, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import relayScoreboardService from "../../services/relay-scoreboard"; import relayScoreboardService from "../../services/relay-scoreboard";
import { RelayMode } from "../../classes/relay"; import { RelayMode } from "../../classes/relay";
import { AdditionalRelayProvider } from "../../providers/additional-relay-context"; import { AdditionalRelayProvider } from "../../providers/additional-relay-context";
import { nip19 } from "nostr-tools"; import { Kind, nip19 } from "nostr-tools";
import { unique } from "../../helpers/array"; import { unique } from "../../helpers/array";
import { RelayFavicon } from "../../components/relay-favicon"; import { RelayFavicon } from "../../components/relay-favicon";
import { useUserRelays } from "../../hooks/use-user-relays"; import { useUserRelays } from "../../hooks/use-user-relays";
import Header from "./components/header"; import Header from "./components/header";
import { ErrorBoundary } from "../../components/error-boundary"; import { ErrorBoundary } from "../../components/error-boundary";
import useUserEventKindCount from "../../hooks/use-user-event-kind-count";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import { useMeasure } from "react-use";
const tabs = [ const tabs = [
{ label: "About", path: "about" }, { label: "About", path: "about" },
@ -94,10 +98,27 @@ const UserView = () => {
const userTopRelays = useUserTopRelays(pubkey, relayCount); const userTopRelays = useUserTopRelays(pubkey, relayCount);
const relayModal = useDisclosure(); const relayModal = useDisclosure();
const articleCount = useUserEventKindCount(pubkey, Kind.Article);
const streamCount = useUserEventKindCount(pubkey, STREAM_KIND);
const goalCount = useUserEventKindCount(pubkey, GOAL_KIND);
const filteredTabs = useMemo(
() =>
tabs.filter((t) => {
if (t.path === "streams" && streamCount === 0) return false;
if (t.path === "goals" && goalCount === 0) return false;
if (t.path === "articles" && articleCount === 0) return false;
return true;
}),
[streamCount, goalCount, articleCount],
);
const matches = useMatches(); const matches = useMatches();
const lastMatch = matches[matches.length - 1]; const lastMatch = matches[matches.length - 1];
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.endsWith(t.path)) ?? tabs[0]); const activeTab = filteredTabs.indexOf(
filteredTabs.find((t) => lastMatch.pathname.endsWith(t.path)) ?? filteredTabs[0],
);
const metadata = useUserMetadata(pubkey, userTopRelays, { alwaysRequest: true }); const metadata = useUserMetadata(pubkey, userTopRelays, { alwaysRequest: true });
@ -114,12 +135,12 @@ const UserView = () => {
flexGrow="1" flexGrow="1"
isLazy isLazy
index={activeTab} index={activeTab}
onChange={(v) => navigate(tabs[v].path, { replace: true })} onChange={(v) => navigate(filteredTabs[v].path, { replace: true })}
colorScheme="primary" colorScheme="primary"
h="full" h="full"
> >
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}> <TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
{tabs.map(({ label }) => ( {filteredTabs.map(({ label }) => (
<Tab key={label} whiteSpace="pre"> <Tab key={label} whiteSpace="pre">
{label} {label}
</Tab> </Tab>
@ -127,7 +148,7 @@ const UserView = () => {
</TabList> </TabList>
<TabPanels> <TabPanels>
{tabs.map(({ label }) => ( {filteredTabs.map(({ label }) => (
<TabPanel key={label} p={0}> <TabPanel key={label} p={0}>
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<Spinner />}> <Suspense fallback={<Spinner />}>

View File

@ -5049,6 +5049,11 @@ json-schema@0.4.0, json-schema@^0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stringify-deterministic@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.11.tgz#9e53b7431fa5b41d3badedb7bbbe2647ea74f036"
integrity sha512-lsn3NoTZ6dGgJJ7W4i7BUKV4WJ+hqAJ0imqHl314MNUw2U+As++qLDudcHqBqlkCXTTH7kH3v5LUQ3CHoVM0BA==
json-stringify-safe@~5.0.1: json-stringify-safe@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"