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",
"idb": "^7.1.1",
"identicon.js": "^2.3.3",
"json-stringify-deterministic": "^1.0.11",
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0",
"light-bolt11-decoder": "^3.0.0",

View File

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

View File

@ -16,6 +16,7 @@ export type IncomingNotice = {
};
export type IncomingCount = {
type: "COUNT";
subId: string;
relay: Relay;
} & CountResponse;
export type IncomingEOSE = {
@ -116,7 +117,7 @@ export default class Relay {
this.ws?.send(JSON.stringify(json));
// record start time
if (json[0] === "REQ") {
if (json[0] === "REQ" || json[0] === "COUNT") {
this.startSubResTimer(json[1]);
}
} else this.queue.push(json);
@ -186,7 +187,7 @@ export default class Relay {
this.onNotice.next({ relay: this, type, message: data[1] });
break;
case "COUNT":
this.onCount.next({ relay: this, type, ...data[2] });
this.onCount.next({ relay: this, type, subId: data[1], ...data[2] });
break;
case "EOSE":
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 }) => {
if (paid.includes(pubkey))
return (
<UserCard pubkey={pubkey}>
<UserCard key={pubkey} pubkey={pubkey}>
<Button size="sm" variant="outline" colorScheme="green" leftIcon={<CheckIcon />}>
Paid
</Button>

View File

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

View File

@ -29,17 +29,21 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { isHexKey } from "../../helpers/nip19";
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 relayScoreboardService from "../../services/relay-scoreboard";
import { RelayMode } from "../../classes/relay";
import { AdditionalRelayProvider } from "../../providers/additional-relay-context";
import { nip19 } from "nostr-tools";
import { Kind, nip19 } from "nostr-tools";
import { unique } from "../../helpers/array";
import { RelayFavicon } from "../../components/relay-favicon";
import { useUserRelays } from "../../hooks/use-user-relays";
import Header from "./components/header";
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 = [
{ label: "About", path: "about" },
@ -94,10 +98,27 @@ const UserView = () => {
const userTopRelays = useUserTopRelays(pubkey, relayCount);
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 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 });
@ -114,12 +135,12 @@ const UserView = () => {
flexGrow="1"
isLazy
index={activeTab}
onChange={(v) => navigate(tabs[v].path, { replace: true })}
onChange={(v) => navigate(filteredTabs[v].path, { replace: true })}
colorScheme="primary"
h="full"
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
{tabs.map(({ label }) => (
{filteredTabs.map(({ label }) => (
<Tab key={label} whiteSpace="pre">
{label}
</Tab>
@ -127,7 +148,7 @@ const UserView = () => {
</TabList>
<TabPanels>
{tabs.map(({ label }) => (
{filteredTabs.map(({ label }) => (
<TabPanel key={label} p={0}>
<ErrorBoundary>
<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"
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:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"