use applesauce core for zaps

This commit is contained in:
hzrd149 2024-11-03 13:40:37 +00:00
parent 48e939e572
commit 5ea8604997
94 changed files with 464 additions and 1032 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Cleanup zap parsing

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Remove old community trending view

72
pnpm-lock.yaml generated
View File

@ -95,13 +95,13 @@ importers:
version: 4.9.2(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
applesauce-channel:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
version: link:../applesauce/packages/channel
applesauce-content:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
version: link:../applesauce/packages/content
applesauce-core:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
version: link:../applesauce/packages/core
applesauce-lists:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
@ -110,7 +110,7 @@ importers:
version: 0.8.0(typescript@5.6.2)
applesauce-react:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
version: link:../applesauce/packages/react
applesauce-signer:
specifier: ^0.8.0
version: 0.8.0(typescript@5.6.2)
@ -2310,12 +2310,6 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
applesauce-channel@0.8.0:
resolution: {integrity: sha512-WkE7A4WHGUdjCTwzfnfMms0KG2I62GN020wKNVIkvtdZAW1ummkSVVdcSPzZHSQZIabMRcJ5xv3tGf6EZ7tjxw==}
applesauce-content@0.8.0:
resolution: {integrity: sha512-AtEeClCbZUy5lLppjiSGQRmZN86Xsv311yYom1gCr5hQmz+vsqKdqiWukMrlyODq3g7zkNoWaN3ZRt7RNQ35sw==}
applesauce-core@0.8.0:
resolution: {integrity: sha512-ezH0ufSZMSS4EZlEcKSciLeq9bY2vv07pLBWLUrwZX3uCij3hcR8UtN7W78+XdWGNs0PIpSUPiWu3sEvVWFtFA==}
@ -2325,9 +2319,6 @@ packages:
applesauce-net@0.8.0:
resolution: {integrity: sha512-yT9q6Z2slFlUP52/IqR4jWfOOfrSulCUnH3svSqwtmNGEupBEkdvMD64fbKqTGKWhyT3KkydbfTiOFDzMWv4/w==}
applesauce-react@0.8.0:
resolution: {integrity: sha512-ViLRORwJ5WeLshYlmPYq6t6IYST4ieuLeRu08ssR1DS3ihBFS+hjmDEht7WyR5qSGbkqqtMyqSBdYxp4mEUF0Q==}
applesauce-signer@0.8.0:
resolution: {integrity: sha512-TunYBkFDXA4Kc6kOhuI0zCJ/+CCGzb98jZ+RXojvVmKxOb1/JJwf103R1mY6223EqhMJtvbwrRQE66n2CdPxCw==}
@ -3386,9 +3377,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkifyjs@4.1.3:
resolution: {integrity: sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -4145,9 +4133,6 @@ packages:
remark-wiki-link@2.0.1:
resolution: {integrity: sha512-F8Eut1E7GWfFm4ZDTI6/4ejeZEHZgnVk6E933Yqd/ssYsc4AyI32aGakxwsGcEzbbE7dkWi1EfLlGAdGgOZOsA==}
remark@15.0.1:
resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
@ -7265,33 +7250,6 @@ snapshots:
dependencies:
color-convert: 2.0.1
applesauce-channel@0.8.0(typescript@5.6.2):
dependencies:
applesauce-core: 0.8.0(typescript@5.6.2)
nostr-tools: 2.7.2(typescript@5.6.2)
rxjs: 7.8.1
transitivePeerDependencies:
- supports-color
- typescript
applesauce-content@0.8.0(typescript@5.6.2):
dependencies:
'@cashu/cashu-ts': 1.1.0
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
applesauce-core: 0.8.0(typescript@5.6.2)
linkifyjs: 4.1.3
mdast-util-find-and-replace: 3.0.1
nostr-tools: 2.7.2(typescript@5.6.2)
remark: 15.0.1
remark-parse: 11.0.0
unified: 11.0.5
unist-util-visit-parents: 6.0.1
transitivePeerDependencies:
- supports-color
- typescript
applesauce-core@0.8.0(typescript@5.6.2):
dependencies:
debug: 4.3.7
@ -7328,17 +7286,6 @@ snapshots:
- supports-color
- typescript
applesauce-react@0.8.0(typescript@5.6.2):
dependencies:
applesauce-content: 0.8.0(typescript@5.6.2)
applesauce-core: 0.8.0(typescript@5.6.2)
nostr-tools: 2.7.2(typescript@5.6.2)
react: 18.3.1
rxjs: 7.8.1
transitivePeerDependencies:
- supports-color
- typescript
applesauce-signer@0.8.0(typescript@5.6.2):
dependencies:
'@noble/hashes': 1.5.0
@ -8521,8 +8468,6 @@ snapshots:
lines-and-columns@1.2.4: {}
linkifyjs@4.1.3: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@ -9549,15 +9494,6 @@ snapshots:
mdast-util-wiki-link: 0.1.2
micromark-extension-wiki-link: 0.0.4
remark@15.0.1:
dependencies:
'@types/mdast': 4.0.4
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remove-accents@0.5.0: {}
repeat-string@1.6.1: {}

View File

@ -73,7 +73,6 @@ const CommunityFindByNameView = lazy(() => import("./views/community/find-by-nam
const CommunityView = lazy(() => import("./views/community/index"));
const CommunityPendingView = lazy(() => import("./views/community/views/pending"));
const CommunityNewestView = lazy(() => import("./views/community/views/newest"));
const CommunityTrendingView = lazy(() => import("./views/community/views/trending"));
import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
@ -437,7 +436,6 @@ const router = createHashRouter([
element: <CommunityView />,
children: [
{ path: "", element: <CommunityNewestView /> },
{ path: "trending", element: <CommunityTrendingView /> },
{ path: "newest", element: <CommunityNewestView /> },
{ path: "pending", element: <CommunityPendingView /> },
],

View File

@ -4,6 +4,7 @@ import _throttle from "lodash.throttle";
import debug, { Debugger } from "debug";
import { EventStore } from "applesauce-core";
import { getEventUID } from "applesauce-core/helpers";
import { Subject } from "rxjs";
import PersistentSubscription from "./persistent-subscription";
import Process from "./process";
@ -11,7 +12,6 @@ import processManager from "../services/process-manager";
import createDefer, { Deferred } from "./deferred";
import Dataflow04 from "../components/icons/dataflow-04";
import SuperMap from "./super-map";
import Subject from "./subject";
/** Batches requests for events with #d tags from a single relay */
export default class BatchIdentifierLoader {

View File

@ -2,6 +2,7 @@ import { NostrEvent } from "nostr-tools";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import _throttle from "lodash.throttle";
import debug, { Debugger } from "debug";
import { Subject } from "rxjs";
import PersistentSubscription from "./persistent-subscription";
import Process from "./process";
@ -9,7 +10,6 @@ import processManager from "../services/process-manager";
import createDefer, { Deferred } from "./deferred";
import Dataflow04 from "../components/icons/dataflow-04";
import SuperMap from "./super-map";
import Subject from "./subject";
import { eventStore } from "../services/event-store";
/** Batches requests for events that reference another event (via #e tag) from a single relay */

View File

@ -4,8 +4,8 @@ import { AbstractRelay } from "nostr-tools/abstract-relay";
import { SimpleRelay } from "nostr-idb";
import _throttle from "lodash.throttle";
import { nanoid } from "nanoid";
import { Subject } from "rxjs";
import Subject from "./subject";
import { logger } from "../helpers/debug";
import EventStore from "./event-store";
import deleteEventService from "../services/delete-events";

View File

@ -108,7 +108,9 @@ export default class EventStore {
while (true) {
const event = events.pop();
if (!event) return;
if (filter && !filter(event)) continue;
try {
if (filter && !filter(event)) continue;
} catch (error) {}
if (i === nth) return event;
i++;
}

View File

@ -1,6 +1,6 @@
import { PersistentSubject } from "../subject";
import { BehaviorSubject } from "rxjs";
export class NullableLocalStorageEntry<T = string> extends PersistentSubject<T | null> {
export class NullableLocalStorageEntry<T = string> extends BehaviorSubject<T | null> {
key: string;
decode?: (raw: string | null) => T | null;
encode?: (value: T) => string | null;
@ -44,7 +44,7 @@ export class NullableLocalStorageEntry<T = string> extends PersistentSubject<T |
}
}
export class LocalStorageEntry<T = string> extends PersistentSubject<T> {
export class LocalStorageEntry<T = string> extends BehaviorSubject<T> {
key: string;
fallback: T;
decode?: (raw: string) => T;

View File

@ -4,8 +4,8 @@ import { AbstractRelay } from "nostr-tools/abstract-relay";
import relayPoolService from "../services/relay-pool";
import createDefer from "./deferred";
import { PersistentSubject } from "./subject";
import ControlledObservable from "./controlled-observable";
import { BehaviorSubject } from "rxjs";
export type PublishResult = { relay: AbstractRelay; success: boolean; message: string };
@ -15,7 +15,7 @@ export default class PublishAction {
relays: string[];
event: NostrEvent;
results = new PersistentSubject<PublishResult[]>([]);
results = new BehaviorSubject<PublishResult[]>([]);
completePromise = createDefer();
/** @deprecated */

View File

@ -2,9 +2,9 @@ import { NostrEvent, kinds, nip18, nip25 } from "nostr-tools";
import _throttle from "lodash.throttle";
import { BehaviorSubject } from "rxjs";
import { map, throttleTime } from "rxjs/operators";
import { getZapPayment } from "applesauce-core/helpers";
import { getThreadReferences, isReply, isRepost } from "../helpers/nostr/event";
import { getParsedZap } from "../helpers/nostr/zaps";
import singleEventService from "../services/single-event";
import RelaySet from "./relay-set";
import clientRelaysService from "../services/client-relays";
@ -155,9 +155,8 @@ export default class AccountNotifications {
break;
}
case NotificationType.Zap:
const parsed = getParsedZap(e, true, true);
if (parsed instanceof Error) return false;
if (!parsed.payment.amount) return false;
const p = getZapPayment(e);
if (!p || p.amount === 0) return false;
break;
}

View File

@ -1,10 +1,10 @@
import { AbstractRelay } from "nostr-tools/abstract-relay";
import { IConnectionPool } from "applesauce-net/connection";
import dayjs from "dayjs";
import { Subject, BehaviorSubject } from "rxjs";
import { logger } from "../helpers/debug";
import { safeRelayUrl, validateRelayURL } from "../helpers/relay";
import Subject, { PersistentSubject } from "./subject";
import SuperMap from "./super-map";
import verifyEventMethod from "../services/verify-event";
import { offlineMode } from "../services/offline-mode";
@ -28,16 +28,22 @@ export default class RelayPool implements IConnectionPool {
onRelayCreated = new Subject<AbstractRelay>();
onRelayChallenge = new Subject<[AbstractRelay, string]>();
notices = new SuperMap<AbstractRelay, PersistentSubject<Notice[]>>(() => new PersistentSubject<Notice[]>([]));
notices = new SuperMap<AbstractRelay, BehaviorSubject<Notice[]>>(() => new BehaviorSubject<Notice[]>([]));
connectionErrors = new SuperMap<AbstractRelay, Error[]>(() => []);
connecting = new SuperMap<AbstractRelay, PersistentSubject<boolean>>(() => new PersistentSubject(false));
connecting = new SuperMap<AbstractRelay, BehaviorSubject<boolean>>(() => new BehaviorSubject(false));
challenges = new SuperMap<AbstractRelay, Subject<string>>(() => new Subject<string>());
authForPublish = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
authForSubscribe = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
challenges = new SuperMap<AbstractRelay, BehaviorSubject<string | undefined>>(
() => new BehaviorSubject<string | undefined>(undefined),
);
authForPublish = new SuperMap<AbstractRelay, BehaviorSubject<boolean | undefined>>(
() => new BehaviorSubject<boolean | undefined>(undefined),
);
authForSubscribe = new SuperMap<AbstractRelay, BehaviorSubject<boolean | undefined>>(
() => new BehaviorSubject<boolean | undefined>(undefined),
);
authenticated = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
authenticated = new SuperMap<AbstractRelay, BehaviorSubject<boolean>>(() => new BehaviorSubject(false));
getRelay(relayOrUrl: string | URL | AbstractRelay) {
if (typeof relayOrUrl === "string") {

View File

@ -1,81 +0,0 @@
import Observable from "zen-observable";
import { nanoid } from "nanoid";
import ControlledObservable from "./controlled-observable";
/** @deprecated use BehaviorSubject instead */
export default class Subject<T> {
private observable: ControlledObservable<T>;
id = nanoid(8);
value: T | undefined;
constructor(value?: T) {
this.observable = new ControlledObservable();
this.value = value;
this.subscribe = this.observable.subscribe.bind(this.observable);
}
next(v: T) {
this.value = v;
this.observable.next(v);
}
error(err: any) {
this.observable.error(err);
}
[Symbol.observable]() {
return this.observable;
}
subscribe: Observable<T>["subscribe"];
once(next: (value: T) => void) {
const sub = this.subscribe((v) => {
if (v !== undefined) {
next(v);
sub.unsubscribe();
}
});
return sub;
}
map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> {
const child = new Subject(defaultValue);
if (this.value !== undefined) {
try {
child.next(callback(this.value));
} catch (e) {
child.error(e);
}
}
this.subscribe((value) => {
try {
child.next(callback(value));
} catch (e) {
child.error(e);
}
});
return child;
}
/** @deprecated */
connectWithMapper<R>(
subject: Subject<R>,
map: (value: R, next: (value: T) => void, current: T | undefined) => void,
): ZenObservable.Subscription {
return subject.subscribe((value) => {
map(value, (v) => this.next(v), this.value);
});
}
}
export class PersistentSubject<T> extends Subject<T> {
value: T;
constructor(value: T) {
super();
this.value = value;
}
}

View File

@ -3,11 +3,10 @@ import { Debugger } from "debug";
import { Filter, NostrEvent } from "nostr-tools";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import _throttle from "lodash.throttle";
import { Observable, map } from "rxjs";
import { BehaviorSubject, Observable, map } from "rxjs";
import { isFilterEqual } from "applesauce-core/helpers";
import { MultiSubscription } from "applesauce-net/subscription";
import { PersistentSubject } from "./subject";
import { logger } from "../helpers/debug";
import { isReplaceable } from "../helpers/nostr/event";
import replaceableEventsService from "../services/replaceable-events";
@ -30,8 +29,8 @@ export default class TimelineLoader {
filters: Filter[] = [];
relays: AbstractRelay[] = [];
loading = new PersistentSubject(false);
complete = new PersistentSubject(false);
loading = new BehaviorSubject(false);
complete = new BehaviorSubject(false);
loadNextBlockBuffer = 2;
eventFilter?: EventFilter;
@ -66,7 +65,16 @@ export default class TimelineLoader {
if (this.eventFilter) {
// add filter
this.timeline = this.timeline.pipe(map((events) => events.filter((e) => this.eventFilter!(e))));
this.timeline = this.timeline.pipe(
map((events) =>
events.filter((e) => {
try {
return this.eventFilter!(e);
} catch (error) {}
return false;
}),
),
);
}
}

View File

@ -1,5 +1,6 @@
import { useCallback, useContext, useMemo } from "react";
import { MenuItem, useToast } from "@chakra-ui/react";
import { getZapSender } from "applesauce-core/helpers";
import { kinds, nip19 } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
@ -7,7 +8,6 @@ import { QuoteEventIcon } from "../icons";
import useUserProfile from "../../hooks/use-user-profile";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import { getSharableEventAddress } from "../../services/event-relay-hint";
import { getParsedZap } from "../../helpers/nostr/zaps";
export default function QuoteEventMenuItem({ event }: { event: NostrEvent }) {
const toast = useToast();
@ -20,8 +20,8 @@ export default function QuoteEventMenuItem({ event }: { event: NostrEvent }) {
// if its a zap, mention the original author
if (event.kind === kinds.Zap) {
const parsed = getParsedZap(event);
if (parsed) content += "nostr:" + nip19.npubEncode(parsed.event.pubkey) + "\n";
const sender = getZapSender(event);
content += "nostr:" + nip19.npubEncode(sender) + "\n";
}
content += "\nnostr:" + address;

View File

@ -35,6 +35,7 @@ import { usePublishEvent } from "../../providers/global/publish-provider";
import { EditIcon } from "../icons";
import { RelayFavicon } from "../relay-favicon";
import { Root } from "applesauce-content/nast";
import { ErrorBoundary } from "../error-boundary";
function Section({
label,
@ -145,7 +146,9 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
<JsonCode data={getThreadReferences(event)} />
</Section>
<Section label="Tags">
<DebugEventTags event={event} />
<ErrorBoundary>
<DebugEventTags event={event} />
</ErrorBoundary>
<Heading size="sm">Tags referenced in content</Heading>
<JsonCode data={getContentTagRefs(event.content, event.tags)} />
</Section>

View File

@ -1,11 +1,11 @@
import { MouseEventHandler, useCallback } from "react";
import { Card, CardProps, Flex, LinkBox, Spacer } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import useSubject from "../../../hooks/use-subject";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import { TrustProvider } from "../../../providers/local/trust-provider";
import { NoteLink } from "../../note/note-link";
@ -20,7 +20,7 @@ import useAppSettings from "../../../hooks/use-app-settings";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();
const enableDrawer = useSubject(localSettings.enableNoteThreadDrawer);
const enableDrawer = useObservable(localSettings.enableNoteThreadDrawer);
const navigate = enableDrawer ? useNavigateInDrawer() : useNavigate();
const to = `/n/${getSharableEventAddress(event)}`;

View File

@ -15,6 +15,7 @@ import {
Text,
} from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
@ -26,11 +27,10 @@ import { formatBytes } from "../../../helpers/number";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
import { getSharableEventAddress } from "../../../services/event-relay-hint";
import useSubject from "../../../hooks/use-subject";
import localSettings from "../../../services/local-settings";
export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) {
const enableDrawer = useSubject(localSettings.enableNoteThreadDrawer);
const enableDrawer = useObservable(localSettings.enableNoteThreadDrawer);
const navigate = enableDrawer ? useNavigateInDrawer() : useNavigate();
const link = `/torrents/${getSharableEventAddress(torrent)}`;

View File

@ -1,11 +1,20 @@
import { useMemo } from "react";
import { Box, ButtonGroup, Card, CardBody, CardHeader, CardProps, LinkBox, Text } from "@chakra-ui/react";
import { getPointerFromTag } from "applesauce-core/helpers";
import {
getPointerFromTag,
getZapAddressPointer,
getZapEventPointer,
getZapPayment,
getZapRecipient,
getZapRequest,
getZapSender,
isAddressPointer,
} from "applesauce-core/helpers";
import { DecodeResult } from "nostr-tools/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import UserLink from "../../user/user-link";
import Timestamp from "../../timestamp";
import { getParsedZap, getZapRecipient } from "../../../helpers/nostr/zaps";
import TextNoteContents from "../../note/timeline-note/text-note-contents";
import UserAvatar from "../../user/user-avatar";
import { LightningIcon } from "../../icons";
@ -14,37 +23,43 @@ import ZapReceiptMenu from "../../zap/zap-receipt-menu";
import { EmbedEventPointer } from "../index";
export default function EmbeddedZapRecept({ zap, ...props }: Omit<CardProps, "children"> & { zap: NostrEvent }) {
const parsed = useMemo(() => getParsedZap(zap), [zap]);
if (!parsed) return null;
const recipient = getZapRecipient(parsed.request);
if (!recipient) return null;
const sender = getZapSender(zap);
const recipient = getZapRecipient(zap);
const payment = getZapPayment(zap);
const request = getZapRequest(zap);
if (!recipient || !payment) return null;
const eTag = parsed.request.tags.find((t) => t[0] === "e" && t[1]);
const pointer = eTag && getPointerFromTag(eTag);
const pointer = useMemo(() => {
const event = getZapEventPointer(zap);
if (event) return { type: "nevent", data: event } satisfies DecodeResult;
const address = getZapAddressPointer(zap);
if (address) return { type: "naddr", data: address } satisfies DecodeResult;
}, [zap]);
return (
<Card as={LinkBox} {...props}>
<CardHeader display="flex" p="2" gap="2" alignItems="center">
<UserAvatar pubkey={parsed.request.pubkey} size="sm" />
<UserLink pubkey={parsed.request.pubkey} fontWeight="bold" />
<UserAvatar pubkey={sender} size="sm" />
<UserLink pubkey={sender} fontWeight="bold" />
<Text>Zapped</Text>
<UserLink pubkey={recipient} fontWeight="bold" />
{parsed.payment.amount && (
{payment.amount && (
<>
<LightningIcon color="yellow.500" boxSize={5} />
<Text>{readablizeSats(parsed.payment.amount / 1000)}</Text>
<Text>{readablizeSats(payment.amount / 1000)}</Text>
</>
)}
<Timestamp timestamp={parsed.event.created_at} ml="auto" />
<Timestamp timestamp={zap.created_at} ml="auto" />
<ButtonGroup size="sm" variant="ghost">
<ZapReceiptMenu zap={zap} aria-label="More Options" />
</ButtonGroup>
</CardHeader>
<CardBody px="2" pb="2" pt="0" display="flex" flexDirection="column" gap="2">
<Box>
<TextNoteContents event={parsed.request} />
<TextNoteContents event={request} />
</Box>
{pointer && <EmbedEventPointer pointer={pointer} />}

View File

@ -1,7 +1,8 @@
import { Code, CodeProps } from "@chakra-ui/react";
import { useRef } from "react";
import { Code, CodeProps } from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import { useKeyPressEvent } from "react-use";
import useSubject from "../hooks/use-subject";
import localSettings from "../services/local-settings";
export default function KeyboardShortcut({
@ -14,7 +15,7 @@ export default function KeyboardShortcut({
requireMeta?: boolean;
onPress?: (e: KeyboardEvent) => void;
} & Omit<CodeProps, "children">) {
const enableKeyboardShortcuts = useSubject(localSettings.enableKeyboardShortcuts);
const enableKeyboardShortcuts = useObservable(localSettings.enableKeyboardShortcuts);
const ref = useRef<HTMLDivElement | null>(null);
useKeyPressEvent(
(e) => (requireMeta ? e.ctrlKey || e.metaKey : true) && e.key === letter,

View File

@ -3,7 +3,6 @@ import { useNavigate, Link as RouterLink } from "react-router-dom";
import { Box, Button, ButtonGroup, Flex, IconButton, Text, useDisclosure } from "@chakra-ui/react";
import { getDisplayName } from "../../helpers/nostr/user-metadata";
import useSubject from "../../hooks/use-subject";
import useUserProfile from "../../hooks/use-user-profile";
import accountService from "../../services/account";
import { AddIcon, ChevronDownIcon, ChevronUpIcon } from "../icons";

View File

@ -2,18 +2,17 @@ import { useContext } from "react";
import { Avatar, Box, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { css } from "@emotion/react";
import { useObservable } from "applesauce-react/hooks";
import useCurrentAccount from "../../hooks/use-current-account";
import AccountSwitcher from "./account-switcher";
import NavItems from "./nav-items";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import { WritingIcon } from "../icons";
import useSubject from "../../hooks/use-subject";
import { offlineMode } from "../../services/offline-mode";
import WifiOff from "../icons/wifi-off";
import TaskManagerButtons from "./task-manager-buttons";
import localSettings from "../../services/local-settings";
import { useObservable } from "applesauce-react/hooks";
const hideScrollbar = css`
-ms-overflow-style: none;
@ -27,7 +26,7 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const offline = useObservable(offlineMode);
const showBrandLogo = useSubject(localSettings.showBrandLogo);
const showBrandLogo = useObservable(localSettings.showBrandLogo);
return (
<Flex

View File

@ -11,6 +11,7 @@ import ZapModal from "../event-zap-modal";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { getEventUID } from "../../helpers/nostr/event";
import { useReadRelays } from "../../hooks/use-client-relays";
import { getZapSender } from "applesauce-core/helpers";
export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
event: NostrEvent;
@ -21,10 +22,10 @@ export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) {
const account = useCurrentAccount();
const { metadata } = useUserLNURLMetadata(event.pubkey);
const zaps = useEventZaps(getEventUID(event));
const zaps = useEventZaps(getEventUID(event)) ?? [];
const { isOpen, onOpen, onClose } = useDisclosure();
const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey);
const hasZapped = !!account && zaps.some((zap) => getZapSender(zap) === account.pubkey);
const readRelays = useReadRelays();
const onZapped = () => {

View File

@ -2,12 +2,27 @@ import { Flex, FlexProps, Tag, TagLabel } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { getEventUID } from "nostr-idb";
import styled from "@emotion/styled";
import { getZapPayment, getZapRequest } from "applesauce-core/helpers";
import useEventZaps from "../../../../hooks/use-event-zaps";
import UserAvatar from "../../../user/user-avatar";
import { readablizeSats } from "../../../../helpers/bolt11";
import { LightningIcon } from "../../../icons";
function ZapBubble({ zap }: { zap: NostrEvent }) {
const request = getZapRequest(zap);
const payment = getZapPayment(zap);
if (!payment) return null;
return (
<Tag key={zap.id} borderRadius="full" py="1" flexShrink={0} variant="outline">
<LightningIcon mr="1" color="yellow.400" />
<TagLabel fontWeight="bold">{readablizeSats((payment.amount ?? 0) / 1000)}</TagLabel>
<UserAvatar pubkey={request.pubkey} size="xs" square={false} ml="2" />
</Tag>
);
}
const HiddenScrollbar = styled(Flex)`
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@ -19,18 +34,14 @@ const HiddenScrollbar = styled(Flex)`
export default function ZapBubbles({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
const zaps = useEventZaps(getEventUID(event));
if (zaps.length === 0) return null;
if (!zaps || zaps.length === 0) return null;
const sorted = zaps.sort((a, b) => (b.payment.amount ?? 0) - (a.payment.amount ?? 0));
const sorted = zaps.sort((a, b) => (getZapPayment(b)?.amount ?? 0) - (getZapPayment(a)?.amount ?? 0));
return (
<HiddenScrollbar overflowY="hidden" overflowX="auto" gap="2" {...props}>
{sorted.map((zap) => (
<Tag key={zap.event.id} borderRadius="full" py="1" flexShrink={0} variant="outline">
<LightningIcon mr="1" color="yellow.400" />
<TagLabel fontWeight="bold">{readablizeSats((zap.payment.amount ?? 0) / 1000)}</TagLabel>
<UserAvatar pubkey={zap.request.pubkey} size="xs" square={false} ml="2" />
</Tag>
<ZapBubble key={zap.id} zap={zap} />
))}
</HiddenScrollbar>
);

View File

@ -16,12 +16,12 @@ import {
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import { Link as RouterLink } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import NoteMenu from "../note-menu";
import UserLink from "../../user/user-link";
import NoteZapButton from "../note-zap-button";
import { ExpandProvider } from "../../../providers/local/expanded";
import useSubject from "../../../hooks/use-subject";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import RepostButton from "./components/repost-button";
import QuoteEventButton from "../quote-event-button";
@ -70,7 +70,7 @@ export function TimelineNote({
}: TimelineNoteProps) {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useAppSettings();
const hideZapBubbles = useSubject(localSettings.hideZapBubbles);
const hideZapBubbles = useObservable(localSettings.hideZapBubbles);
const replyForm = useDisclosure();
const ref = useEventIntersectionRef(event);

View File

@ -28,10 +28,9 @@ import {
import { LightboxProvider } from "../../lightbox-provider";
import MediaOwnerProvider from "../../../providers/local/media-owner-provider";
import { components } from "../../content";
import { fedimintTokens } from "../../../helpers/fedimint";
import { nipDefinitions } from "../../content/transform/nip-notation";
const transformers = [...defaultTransformers, galleries, nipDefinitions, fedimintTokens];
const transformers = [...defaultTransformers, galleries, nipDefinitions];
export type TextNoteContentsProps = {
event: NostrEvent | EventTemplate;

View File

@ -32,6 +32,7 @@ import dayjs from "dayjs";
import { useForm } from "react-hook-form";
import { kinds, UnsignedEvent } from "nostr-tools";
import { useThrottle } from "react-use";
import { useObservable } from "applesauce-react/hooks";
import { ChevronDownIcon, ChevronUpIcon, UploadImageIcon } from "../icons";
import PublishAction from "../../classes/nostr-publish-action";
@ -59,7 +60,6 @@ import useAppSettings from "../../hooks/use-app-settings";
import { ErrorBoundary } from "../error-boundary";
import { useFinalizeDraft, usePublishEvent } from "../../providers/global/publish-provider";
import { TextNoteContents } from "../note/timeline-note/text-note-contents";
import useSubject from "../../hooks/use-subject";
import localSettings from "../../services/local-settings";
import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure";
@ -92,7 +92,7 @@ export default function PostModal({
const finalizeDraft = useFinalizeDraft();
const account = useCurrentAccount()!;
const { noteDifficulty } = useAppSettings();
const addClientTag = useSubject(localSettings.addClientTag);
const addClientTag = useObservable(localSettings.addClientTag);
const promptAddClientTag = useLocalStorageDisclosure("prompt-add-client-tag", true);
const [miningTarget, setMiningTarget] = useState(0);
const [publishAction, setPublishAction] = useState<PublishAction>();

View File

@ -1,15 +1,15 @@
import { useCallback, useState } from "react";
import { IconButton, IconButtonProps, useForceUpdate, useInterval, useToast } from "@chakra-ui/react";
import { type AbstractRelay } from "nostr-tools/abstract-relay";
import { useObservable } from "applesauce-react/hooks";
import relayPoolService from "../../services/relay-pool";
import { useSigningContext } from "../../providers/global/signing-provider";
import PasscodeLock from "../icons/passcode-lock";
import useSubject from "../../hooks/use-subject";
import CheckCircleBroken from "../icons/check-circle-broken";
export function useRelayChallenge(relay: AbstractRelay) {
return useSubject(relayPoolService.challenges.get(relay));
return useObservable(relayPoolService.challenges.get(relay));
}
export function useRelayAuthMethod(relay: AbstractRelay) {
@ -17,7 +17,7 @@ export function useRelayAuthMethod(relay: AbstractRelay) {
const { requestSignature } = useSigningContext();
const challenge = useRelayChallenge(relay);
const authenticated = useSubject(relayPoolService.authenticated.get(relay));
const authenticated = useObservable(relayPoolService.authenticated.get(relay));
const [loading, setLoading] = useState(false);
const auth = useCallback(async () => {

View File

@ -1,9 +1,9 @@
import { ChangeEventHandler } from "react";
import { Switch, useForceUpdate, useInterval, useToast } from "@chakra-ui/react";
import { type AbstractRelay } from "nostr-tools/abstract-relay";
import { useObservable } from "applesauce-react/hooks";
import relayPoolService from "../../services/relay-pool";
import useSubject from "../../hooks/use-subject";
export default function RelayConnectSwitch({ relay }: { relay: string | URL | AbstractRelay }) {
const toast = useToast();
@ -14,7 +14,7 @@ export default function RelayConnectSwitch({ relay }: { relay: string | URL | Ab
const update = useForceUpdate();
useInterval(update, 500);
const connecting = useSubject(relayPoolService.connecting.get(r));
const connecting = useObservable(relayPoolService.connecting.get(r));
const onChange: ChangeEventHandler<HTMLInputElement> = async (e) => {
try {

View File

@ -1,9 +1,9 @@
import { Badge, useForceUpdate } from "@chakra-ui/react";
import { useInterval } from "react-use";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import { useObservable } from "applesauce-react/hooks";
import relayPoolService from "../../services/relay-pool";
import useSubject from "../../hooks/use-subject";
const getStatusText = (relay: AbstractRelay, connecting = false) => {
if (connecting) return "Connecting...";
@ -31,7 +31,7 @@ export const RelayStatus = ({ url, relay }: { url?: string; relay?: AbstractRela
else throw Error("Missing url or relay");
}
const connecting = useSubject(relayPoolService.connecting.get(relay!));
const connecting = useObservable(relayPoolService.connecting.get(relay!));
return <Badge colorScheme={getStatusColor(relay!, connecting)}>{getStatusText(relay!, connecting)}</Badge>;
};

View File

@ -1,11 +1,11 @@
import { Alert, AlertIcon, Button, Spinner } from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import TimelineLoader from "../../classes/timeline-loader";
import useSubject from "../../hooks/use-subject";
export default function TimelineActionAndStatus({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading);
const complete = useSubject(timeline.complete);
const loading = useObservable(timeline.loading);
const complete = useObservable(timeline.complete);
if (complete) {
return (

View File

@ -6,8 +6,6 @@ import { getDisplayName } from "../../helpers/nostr/user-metadata";
import useUserProfile from "../../hooks/use-user-profile";
import useAppSettings from "../../hooks/use-app-settings";
import useCurrentAccount from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import localSettings from "../../services/local-settings";
export type UserLinkProps = LinkProps & {
pubkey: string;

View File

@ -2,6 +2,7 @@ import { EventTemplate, kinds, validateEvent } from "nostr-tools";
import { getEventUID } from "nostr-idb";
import dayjs from "dayjs";
import { nanoid } from "nanoid";
import { getNip10References } from "applesauce-core/helpers";
import { ATag, ETag, isDTag, isETag, isPTag, NostrEvent, Tag } from "../../types/nostr-event";
import { getMatchNostrLink } from "../regexp";
@ -11,7 +12,6 @@ import { safeDecode } from "../nip19";
import { safeRelayUrl, safeRelayUrls } from "../relay";
import RelaySet from "../../classes/relay-set";
import { truncateId } from "../string";
import { getNip10References } from "./threading";
export { truncateId as truncatedId };
@ -37,14 +37,18 @@ export function pointerMatchEvent(event: NostrEvent, pointer: AddressPointer | E
const isReplySymbol = Symbol("isReply");
export function isReply(event: NostrEvent | EventTemplate) {
// @ts-expect-error
if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean;
try {
// @ts-expect-error
if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false;
const isReply = !!getNip10References(event).reply;
// @ts-expect-error
event[isReplySymbol] = isReply;
return isReply;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false;
const isReply = !!getNip10References(event).reply;
// @ts-expect-error
event[isReplySymbol] = isReply;
return isReply;
} catch (error) {
return false;
}
}
export function isPTagMentionedInContent(event: NostrEvent | EventTemplate, pubkey: string) {
return filterTagsByContentRefs(event.content, event.tags).some((t) => t[1] === pubkey);

View File

@ -1,94 +0,0 @@
import { EventTemplate, NostrEvent } from "nostr-tools";
import { AddressPointer, EventPointer } from "nostr-tools/nip19";
import { ATag, ETag, isATag, isETag } from "../../types/nostr-event";
import { aTagToAddressPointer, eTagToEventPointer, getContentTagRefs } from "./event";
export function interpretThreadTags(event: NostrEvent | EventTemplate) {
const eTags = event.tags.filter(isETag);
const aTags = event.tags.filter(isATag);
// find the root and reply tags.
let rootETag = eTags.find((t) => t[3] === "root");
let replyETag = eTags.find((t) => t[3] === "reply");
let rootATag = aTags.find((t) => t[3] === "root");
let replyATag = aTags.find((t) => t[3] === "reply");
if (!rootETag || !replyETag) {
// a direct reply does not need a "reply" reference
// https://github.com/nostr-protocol/nips/blob/master/10.md
// this is not necessarily to spec. but if there is only one id (root or reply) then assign it to both
// this handles the cases where a client only set a "reply" tag and no root
rootETag = replyETag = rootETag || replyETag;
}
if (!rootATag || !replyATag) {
rootATag = replyATag = rootATag || replyATag;
}
if (!rootETag && !replyETag) {
const contentTagRefs = getContentTagRefs(event.content, eTags);
// legacy behavior
// https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated
const legacyETags = eTags.filter((t) => {
// ignore it if there is a type
if (t[3]) return false;
if (contentTagRefs.includes(t)) return false;
return true;
});
if (legacyETags.length >= 1) {
// first tag is the root
rootETag = legacyETags[0];
// last tag is reply
replyETag = legacyETags[legacyETags.length - 1] ?? rootETag;
}
}
return {
root: rootETag || rootATag ? { e: rootETag, a: rootATag } : undefined,
reply: replyETag || replyATag ? { e: replyETag, a: replyATag } : undefined,
} as {
root?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag };
reply?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag };
};
}
export type ThreadReferences = {
root?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
reply?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
};
export const threadRefsSymbol = Symbol("threadRefs");
export type EventWithThread = (NostrEvent | EventTemplate) & { [threadRefsSymbol]: ThreadReferences };
export function getNip10References(event: NostrEvent | EventTemplate): ThreadReferences {
// @ts-expect-error
if (Object.hasOwn(event, threadRefsSymbol)) return event[threadRefsSymbol];
const e = event as EventWithThread;
const tags = interpretThreadTags(e);
const threadRef = {
root: tags.root && {
e: tags.root.e && eTagToEventPointer(tags.root.e),
a: tags.root.a && aTagToAddressPointer(tags.root.a),
},
reply: tags.reply && {
e: tags.reply.e && eTagToEventPointer(tags.reply.e),
a: tags.reply.a && aTagToAddressPointer(tags.reply.a),
},
} as ThreadReferences;
// @ts-expect-error
event[threadRefsSymbol] = threadRef;
return threadRef;
}

View File

@ -3,6 +3,7 @@ import emojiRegex from "emoji-regex";
import { truncatedId } from "./event";
import { ProfileContent } from "applesauce-core/helpers";
/** @deprecated use ProfileContent instead */
export type Kind0ParsedContent = {
pubkey?: string;
name?: string;

View File

@ -1,13 +1,11 @@
import { bech32 } from "@scure/base";
import {} from "nostr-tools/nip57";
import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
import { Kind0ParsedContent } from "./user-metadata";
import { nip57, utils } from "nostr-tools";
import verifyEvent from "../../services/verify-event";
import { utils } from "nostr-tools";
import { getZapPayment, ProfileContent } from "applesauce-core/helpers";
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
export async function getZapEndpoint(metadata: ProfileContent): Promise<null | string> {
try {
let lnurl: string = "";
let { lud06, lud16 } = metadata;
@ -42,79 +40,8 @@ export function isProfileZap(event: NostrEvent) {
return !isNoteZap(event) && event.tags.some(isPTag);
}
export function getZapRecipient(event: NostrEvent) {
return event.tags.find((t) => t[0] === "p" && t[1])?.[1];
}
export function totalZaps(zaps: ParsedZap[]) {
return zaps.reduce((t, zap) => t + (zap.payment.amount || 0), 0);
}
export type ParsedZap = {
event: NostrEvent;
request: NostrEvent;
payment: ParsedInvoice;
eventId?: string;
};
const parsedZapSymbol = Symbol("parsedZap");
type ParsedZapEvent = NostrEvent & { [parsedZapSymbol]: ParsedZap | Error };
export function getParsedZap(event: NostrEvent, quite: false, returnError?: boolean): ParsedZap;
export function getParsedZap(event: NostrEvent, quite: true, returnError: true): ParsedZap | Error;
export function getParsedZap(event: NostrEvent, quite: true, returnError: false): ParsedZap | undefined;
export function getParsedZap(event: NostrEvent, quite?: boolean, returnError?: boolean): ParsedZap | undefined;
export function getParsedZap(event: NostrEvent, quite: boolean = true, returnError?: boolean) {
const e = event as ParsedZapEvent;
if (Object.hasOwn(e, parsedZapSymbol)) {
const cached = e[parsedZapSymbol];
if (!returnError && cached instanceof Error) return undefined;
if (!quite && cached instanceof Error) throw cached;
return cached;
}
try {
return (e[parsedZapSymbol] = parseZapEvent(e));
} catch (error) {
if (error instanceof Error) {
e[parsedZapSymbol] = error;
if (quite) return returnError ? error : undefined;
else throw error;
} else throw error;
}
}
export function parseZapEvents(events: NostrEvent[]) {
const parsed: ParsedZap[] = [];
for (const event of events) {
const p = getParsedZap(event);
if (p) parsed.push(p);
}
return parsed;
}
/** @deprecated use getParsedZap instead */
export function parseZapEvent(event: NostrEvent): ParsedZap {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("No description tag");
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
if (!bolt11) throw new Error("Missing bolt11 invoice");
const error = nip57.validateZapRequest(zapRequestStr);
if (error) throw new Error(error);
const request = JSON.parse(zapRequestStr) as NostrEvent;
if (!verifyEvent(request)) throw new Error("Invalid zap request");
const payment = parsePaymentRequest(bolt11);
return {
event,
request,
payment,
};
export function totalZaps(zaps: NostrEvent[]) {
return zaps.map(getZapPayment).reduce((t, p) => t + (p?.amount ?? 0), 0);
}
export type EventSplit = { pubkey: string; percent: number; relay?: string }[];

View File

@ -1,11 +1,12 @@
import { useMemo } from "react";
import { useObservable } from "applesauce-react/hooks";
import dnsIdentityService from "../services/dns-identity";
import useSubject from "./use-subject";
export default function useDnsIdentity(address: string | undefined) {
const subject = useMemo(() => {
if (address) return dnsIdentityService.getIdentity(address);
}, [address]);
return useSubject(subject);
return useObservable(subject);
}

View File

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

View File

@ -1,11 +1,11 @@
import { useEffect } from "react";
import { NostrEvent } from "nostr-tools";
import { getEventUID } from "applesauce-core/helpers";
import { useStoreQuery } from "applesauce-react/hooks";
import { ReactionsQuery } from "applesauce-core/queries";
import eventReactionsService from "../services/event-reactions";
import { useReadRelays } from "./use-client-relays";
import { queryStore } from "../services/event-store";
import { useObservable } from "./use-observable";
import { useEffect } from "react";
export default function useEventReactions(
event: NostrEvent,
@ -18,6 +18,5 @@ export default function useEventReactions(
eventReactionsService.requestReactions(getEventUID(event), relays, alwaysRequest);
}, [event, relays, alwaysRequest]);
const observable = queryStore.reactions(event);
return useObservable(observable);
return useStoreQuery(ReactionsQuery, [event]);
}

View File

@ -1,28 +1,22 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useStoreQuery } from "applesauce-react/hooks";
import { parseCoordinate } from "applesauce-core/helpers";
import { EventZapsQuery } from "applesauce-core/queries";
import eventZapsService from "../services/event-zaps";
import { useReadRelays } from "./use-client-relays";
import useSubject from "./use-subject";
import { getParsedZap } from "../helpers/nostr/zaps";
export default function useEventZaps(eventUID: string, additionalRelays?: Iterable<string>, alwaysRequest = true) {
export default function useEventZaps(uid: string, additionalRelays?: Iterable<string>, alwaysRequest = false) {
const readRelays = useReadRelays(additionalRelays);
const subject = useMemo(
() => eventZapsService.requestZaps(eventUID, readRelays, alwaysRequest),
[eventUID, readRelays.urls.join("|"), alwaysRequest],
);
useEffect(() => {
eventZapsService.requestZaps(uid, readRelays, alwaysRequest);
}, [uid, readRelays.urls.join("|"), alwaysRequest]);
const events = useSubject(subject) || [];
const pointer = useMemo(() => {
if (uid.includes(":")) return parseCoordinate(uid, true);
return uid;
}, [uid]);
const zaps = useMemo(() => {
const parsed = [];
for (const zap of events) {
const p = getParsedZap(zap);
if (p) parsed.push(p);
}
return parsed;
}, [events]);
return zaps;
return useStoreQuery(EventZapsQuery, pointer ? [pointer] : undefined) ?? [];
}

View File

@ -1,36 +0,0 @@
import { useMemo, useState } from "react";
import eventReactionsService from "../services/event-reactions";
import { useReadRelays } from "./use-client-relays";
import { NostrEvent } from "../types/nostr-event";
import Subject from "../classes/subject";
import useSubjects from "./use-subjects";
export default function useEventsReactions(
eventIds: string[],
additionalRelays?: Iterable<string>,
alwaysRequest = true,
) {
const readRelays = useReadRelays(additionalRelays);
// get subjects
const subjects = useMemo(() => {
const dir: Record<string, Subject<NostrEvent[]>> = {};
for (const eventId of eventIds) {
dir[eventId] = eventReactionsService.requestReactions(eventId, readRelays, alwaysRequest);
}
return dir;
}, [eventIds, readRelays.urls.join("|"), alwaysRequest]);
// get values out of subjects
const reactions: Record<string, NostrEvent[]> = {};
for (const [id, subject] of Object.entries(subjects)) {
if (subject.value) reactions[id] = subject.value;
}
const [_, update] = useState(0);
// subscribe to subjects
useSubjects(Object.values(subjects));
return reactions;
}

View File

@ -1,9 +1,9 @@
import { useCallback, useMemo } from "react";
import { NostrEvent } from "nostr-tools";
import { useObservable } from "applesauce-react/hooks";
import decryptionCacheService from "../services/decryption-cache";
import useCurrentAccount from "./use-current-account";
import useSubject from "./use-subject";
import { getDMRecipient, getDMSender } from "../helpers/nostr/dms";
export function useKind4Decrypt(event: NostrEvent, pubkey?: string) {
@ -16,8 +16,8 @@ export function useKind4Decrypt(event: NostrEvent, pubkey?: string) {
[event, pubkey],
);
const plaintext = useSubject(container.plaintext);
const error = useSubject(container.error);
const plaintext = useObservable(container.plaintext);
const error = useObservable(container.error);
const requestDecrypt = useCallback(() => {
const p = decryptionCacheService.requestDecrypt(container);

View File

@ -1,3 +0,0 @@
import { useObservable } from "applesauce-react/hooks";
export { useObservable };

View File

@ -1,13 +1,14 @@
import { useCallback, useMemo } from "react";
import { useObservable } from "applesauce-react/hooks";
import readStatusService from "../services/read-status";
import useSubject from "./use-subject";
export default function useReadStatus(key: string, ttl?: number) {
const subject = useMemo(() => readStatusService.getStatus(key, ttl), [key]);
const setRead = useCallback((read = true) => readStatusService.setRead(key, read, ttl), [key, ttl]);
const read = useSubject(subject);
const read = useObservable(subject);
return [read, setRead] as const;
}

View File

@ -1,12 +1,12 @@
import { useObservable } from "applesauce-react/hooks";
import relayStatsService from "../services/relay-stats";
import useSubject from "./use-subject";
export default function useRelayStats(relay: string) {
const monitorSub = relayStatsService.requestMonitorStats(relay);
const selfReportedSub = relayStatsService.requestSelfReported(relay);
const monitor = useSubject(monitorSub);
const selfReported = useSubject(selfReportedSub);
const monitor = useObservable(monitorSub);
const selfReported = useObservable(selfReportedSub);
const stats = monitor || selfReported || undefined;
return {

View File

@ -1,19 +0,0 @@
import { useEffect, useRef, useState } from "react";
import Subject, { PersistentSubject } from "../classes/subject";
function useSubject<Value extends unknown>(subject: PersistentSubject<Value>): Value;
function useSubject<Value extends unknown>(subject?: PersistentSubject<Value>): Value | undefined;
function useSubject<Value extends unknown>(subject?: Subject<Value>): Value | undefined;
function useSubject<Value extends unknown>(subject?: Subject<Value>) {
const [_, setValue] = useState(subject?.value);
const subRef = useRef(subject);
useEffect(() => {
if (subject?.value !== undefined) setValue(subject?.value);
const sub = subject?.subscribe((v) => setValue(v));
return () => sub?.unsubscribe();
}, [subject, setValue]);
return subject?.value;
}
export default useSubject;

View File

@ -1,21 +0,0 @@
import { useEffect, useState } from "react";
import Subject, { PersistentSubject } from "../classes/subject";
function useSubjects<Value extends unknown>(
subjects: (Subject<Value> | PersistentSubject<Value> | undefined)[] = [],
): Value[] {
const values = subjects.map((sub) => sub?.value).filter((v) => v !== undefined) as Value[];
const [_, update] = useState(0);
useEffect(() => {
const listener = () => update((v) => v + 1);
const subs = subjects.map((s) => s?.subscribe(listener));
return () => {
for (const sub of subs) sub?.unsubscribe();
};
}, [subjects, update]);
return values;
}
export default useSubjects;

View File

@ -3,7 +3,7 @@ import { usePrevious, useUnmount } from "react-use";
import { Filter } from "nostr-tools";
import timelineCacheService from "../services/timeline-cache";
import TimelineLoader, { EventFilter } from "../classes/timeline-loader";
import { EventFilter } from "../classes/timeline-loader";
import { useStoreQuery } from "applesauce-react/hooks";
import { Queries } from "applesauce-core";
@ -57,7 +57,13 @@ export default function useTimelineLoader(
});
let timeline = useStoreQuery(Queries.TimelineQuery, filters && [filters]) ?? [];
if (opts?.eventFilter) timeline = timeline.filter(opts.eventFilter);
if (opts?.eventFilter)
timeline = timeline.filter((e) => {
try {
return opts.eventFilter && opts.eventFilter(e);
} catch (error) {}
return false;
});
return { loader, timeline };
}

View File

@ -122,7 +122,6 @@ export default function PublishProvider({ children }: PropsWithChildren) {
// pass it to other services
eventStore.add(signed);
if (isReplaceable(signed.kind)) replaceableEventsService.handleEvent(signed);
if (signed.kind === kinds.Reaction) eventReactionsService.handleEvent(signed);
if (signed.kind === kinds.EventDeletion) deleteEventService.handleEvent(signed);
return pub;
} catch (e) {

View File

@ -10,8 +10,7 @@ import {
useState,
} from "react";
import { useMount, useUnmount } from "react-use";
import Subject from "../../classes/subject";
import { BehaviorSubject, Subject } from "rxjs";
const IntersectionObserverContext = createContext<{
observer?: IntersectionObserver;
@ -77,7 +76,7 @@ export default function IntersectionObserverProvider({
threshold?: IntersectionObserverInit["threshold"];
callback: IntersectionObserverCallback;
}) {
const [subject] = useState(() => new Subject<IntersectionObserverEntry[]>([]));
const [subject] = useState(() => new BehaviorSubject<IntersectionObserverEntry[]>([]));
const handleIntersection = useCallback<IntersectionObserverCallback>(
(entries, observer) => {

View File

@ -1,49 +1,44 @@
import dayjs from "dayjs";
import debug, { Debugger } from "debug";
import { Debugger } from "debug";
import _throttle from "lodash.throttle";
import { Filter, kinds } from "nostr-tools";
import { getChannelPointer } from "applesauce-channel";
import NostrSubscription from "../classes/nostr-subscription";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import Subject from "../classes/subject";
import { logger } from "../helpers/debug";
import db from "./db";
import createDefer, { Deferred } from "../classes/deferred";
import { getChannelPointer } from "../helpers/nostr/channel";
import { eventStore } from "./event-store";
type Pubkey = string;
type Relay = string;
import relayPoolService from "./relay-pool";
import PersistentSubscription from "../classes/persistent-subscription";
import { localRelay } from "./local-relay";
import { isFromCache, markFromCache } from "applesauce-core/helpers";
import { AbstractRelay } from "nostr-tools/abstract-relay";
export type RequestOptions = {
/** Always request the event from the relays */
alwaysRequest?: boolean;
/** ignore the cache on initial load */
ignoreCache?: boolean;
// TODO: figure out a clean way for useReplaceableEvent hook to "unset" or "unsubscribe"
// keepAlive?: boolean;
};
const RELAY_REQUEST_BATCH_TIME = 1000;
/** This class is ued to batch requests to a single relay */
class ChannelMetadataRelayLoader {
private subscription: NostrSubscription;
private events = new SuperMap<string, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
private subscription: PersistentSubscription;
private requestNext = new Set<string>();
private requested = new Map<string, Date>();
log: Debugger;
isCache = false;
constructor(relay: string, log?: Debugger) {
this.subscription = new NostrSubscription(relay, undefined, `channel-metadata-loader`);
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
this.log = log || debug("misc");
constructor(relay: AbstractRelay, log?: Debugger) {
this.log = log || logger.extend("ChannelMetadataRelayLoader");
this.subscription = new PersistentSubscription(relay, {
onevent: (event) => this.handleEvent(event),
oneose: () => this.handleEOSE(),
});
}
private handleEvent(event: NostrEvent) {
@ -53,31 +48,18 @@ class ChannelMetadataRelayLoader {
// remove the pubkey from the waiting list
this.requested.delete(channelId);
const sub = this.events.get(channelId);
if (this.isCache) markFromCache(event);
const current = sub.value;
if (!current || event.created_at > current.created_at) {
sub.next(event);
}
eventStore.add(event);
}
private handleEOSE() {
// relays says it has nothing left
this.requested.clear();
}
getSubject(channelId: string) {
return this.events.get(channelId);
}
requestMetadata(channelId: string) {
const subject = this.events.get(channelId);
if (!subject.value) {
this.requestNext.add(channelId);
this.updateThrottle();
}
return subject;
this.requestNext.add(channelId);
this.updateThrottle();
}
updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME);
@ -109,171 +91,64 @@ class ChannelMetadataRelayLoader {
};
if (query["#e"] && query["#e"].length > 0) this.log(`Updating query`, query["#e"].length);
this.subscription.setFilters([query]);
this.subscription.filters = [query];
if (this.subscription.state !== NostrSubscription.OPEN) {
this.subscription.open();
}
} else if (this.subscription.state === NostrSubscription.OPEN) {
this.subscription.update();
} else {
this.subscription.close();
}
}
}
}
const READ_CACHE_BATCH_TIME = 250;
const WRITE_CACHE_BATCH_TIME = 250;
/** This is a clone of ReplaceableEventLoaderService to support channel metadata */
class ChannelMetadataService {
private metadata = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
private loaders = new SuperMap<Relay, ChannelMetadataRelayLoader>(
(relay) => new ChannelMetadataRelayLoader(relay, this.log.extend(relay)),
private loaders = new SuperMap<AbstractRelay, ChannelMetadataRelayLoader>(
(relay) => new ChannelMetadataRelayLoader(relay, this.log.extend(relay.url)),
);
log = logger.extend("ChannelMetadata");
dbLog = this.log.extend("database");
handleEvent(event: NostrEvent, saveToCache = true) {
constructor() {
if (localRelay) {
const loader = this.loaders.get(localRelay as AbstractRelay);
loader.isCache = true;
}
}
handleEvent(event: NostrEvent) {
eventStore.add(event);
const channelId = getChannelPointer(event)?.id;
if (!channelId) return;
const sub = this.metadata.get(channelId);
const current = sub.value;
if (!current || event.created_at > current.created_at) {
sub.next(event);
if (saveToCache) this.saveToCache(channelId, event);
}
}
getSubject(channelId: string) {
return this.metadata.get(channelId);
}
private readFromCachePromises = new Map<string, Deferred<boolean>>();
private readFromCacheThrottle = _throttle(this.readFromCache, READ_CACHE_BATCH_TIME);
private async readFromCache() {
if (this.readFromCachePromises.size === 0) return;
let read = 0;
const transaction = db.transaction("channelMetadata", "readonly");
for (const [channelId, promise] of this.readFromCachePromises) {
transaction
.objectStore("channelMetadata")
.get(channelId)
.then((cached) => {
if (cached?.event) {
this.handleEvent(cached.event, false);
promise.resolve(true);
read++;
}
promise.resolve(false);
});
}
this.readFromCachePromises.clear();
transaction.commit();
await transaction.done;
if (read > 0) this.dbLog(`Read ${read} events from database`);
}
private loadCacheDedupe = new Map<string, Promise<boolean>>();
loadFromCache(channelId: string) {
const dedupe = this.loadCacheDedupe.get(channelId);
if (dedupe) return dedupe;
// add to read queue
const promise = createDefer<boolean>();
this.readFromCachePromises.set(channelId, promise);
this.loadCacheDedupe.set(channelId, promise);
this.readFromCacheThrottle();
return promise;
}
private writeCacheQueue = new Map<string, NostrEvent>();
private writeToCacheThrottle = _throttle(this.writeToCache, WRITE_CACHE_BATCH_TIME);
private async writeToCache() {
if (this.writeCacheQueue.size === 0) return;
this.dbLog(`Writing ${this.writeCacheQueue.size} events to database`);
const transaction = db.transaction("channelMetadata", "readwrite");
for (const [channelId, event] of this.writeCacheQueue) {
transaction.objectStore("channelMetadata").put({ channelId, event, created: dayjs().unix() });
}
this.writeCacheQueue.clear();
transaction.commit();
await transaction.done;
}
private async saveToCache(channelId: string, event: NostrEvent) {
this.writeCacheQueue.set(channelId, event);
this.writeToCacheThrottle();
}
async pruneDatabaseCache() {
const keys = await db.getAllKeysFromIndex(
"channelMetadata",
// @ts-ignore
"created",
IDBKeyRange.upperBound(dayjs().subtract(1, "week").unix()),
);
if (keys.length === 0) return;
this.dbLog(`Pruning ${keys.length} expired events from database`);
const transaction = db.transaction("channelMetadata", "readwrite");
for (const key of keys) {
transaction.store.delete(key);
}
await transaction.commit();
if (!isFromCache(event)) localRelay?.publish(event);
}
private requestChannelMetadataFromRelays(relays: Iterable<string>, channelId: string) {
const sub = this.metadata.get(channelId);
const relayUrls = Array.from(relays);
for (const relay of relayUrls) {
const request = this.loaders.get(relay).requestMetadata(channelId);
sub.connectWithMapper(request, (event, next, current) => {
if (!current || event.created_at > current.created_at) {
next(event);
this.saveToCache(channelId, event);
}
});
for (const url of relayUrls) {
const relay = relayPoolService.getRelay(url);
if (relay) this.loaders.get(relay).requestMetadata(channelId);
}
return sub;
}
private loaded = new Map<string, boolean>();
requestMetadata(relays: Iterable<string>, channelId: string, opts: RequestOptions = {}) {
const sub = this.metadata.get(channelId);
const loaded = this.loaded.get(channelId);
if (!sub.value) {
this.loadFromCache(channelId).then((loaded) => {
if (!loaded && !sub.value) this.requestChannelMetadataFromRelays(relays, channelId);
});
if (!loaded && localRelay) {
this.loaders.get(localRelay as AbstractRelay).requestMetadata(channelId);
}
if (opts?.alwaysRequest || (!sub.value && opts.ignoreCache)) {
if (opts?.alwaysRequest || (!loaded && opts.ignoreCache)) {
this.requestChannelMetadataFromRelays(relays, channelId);
}
return sub;
}
}
const channelMetadataService = new ChannelMetadataService();
channelMetadataService.pruneDatabaseCache();
setInterval(
() => {
channelMetadataService.pruneDatabaseCache();
},
1000 * 60 * 60,
);
if (import.meta.env.DEV) {
//@ts-ignore
window.channelMetadataService = channelMetadataService;

View File

@ -1,15 +1,15 @@
import { openDB, deleteDB, IDBPDatabase, IDBPTransaction } from "idb";
import { clearDB, deleteDB as nostrIDBDelete } from "nostr-idb";
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7, SchemaV8, SchemaV9 } from "./schema";
import { SchemaV1, SchemaV10, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7, SchemaV9 } from "./schema";
import { logger } from "../../helpers/debug";
import { localDatabase } from "../local-relay";
const log = logger.extend("Database");
const dbName = "storage";
const version = 9;
const db = await openDB<SchemaV9>(dbName, version, {
const version = 10;
const db = await openDB<SchemaV10>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
if (oldVersion < 1) {
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
@ -178,6 +178,11 @@ const db = await openDB<SchemaV9>(dbName, version, {
const readStore = v9.createObjectStore("read", { keyPath: "key" });
readStore.createIndex("ttl", "ttl");
}
if (oldVersion < 10) {
const v9 = db as unknown as IDBPDatabase<SchemaV9>;
v9.deleteObjectStore("channelMetadata");
}
},
});
@ -187,9 +192,6 @@ export async function clearCacheData() {
log("Clearing nostr-idb");
await clearDB(localDatabase);
log("Clearing channelMetadata");
await db.clear("channelMetadata");
log("Clearing userSearch");
await db.clear("userSearch");

View File

@ -153,3 +153,5 @@ export interface SchemaV9 extends SchemaV8 {
indexes: { ttl: number };
};
}
export interface SchemaV10 extends Omit<SchemaV9, "channelMetadata"> {}

View File

@ -1,5 +1,5 @@
import Subject from "../classes/subject";
import _throttle from "lodash.throttle";
import { BehaviorSubject } from "rxjs";
import createDefer, { Deferred } from "../classes/deferred";
import signingService from "./signing";
@ -15,8 +15,8 @@ class DecryptionContainer {
pubkey: string;
cipherText: string;
plaintext = new Subject<string>();
error = new Subject<Error>();
plaintext = new BehaviorSubject<string | undefined>(undefined);
error = new BehaviorSubject<Error | undefined>(undefined);
constructor(id: string, type: EncryptionType = "nip04", pubkey: string, cipherText: string) {
this.id = id;

View File

@ -1,12 +1,12 @@
import { NostrEvent } from "nostr-tools";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import { EventStore } from "applesauce-core";
import { BehaviorSubject } from "rxjs";
import { WIKI_PAGE_KIND } from "../helpers/nostr/wiki";
import { logger } from "../helpers/debug";
import Process from "../classes/process";
import SuperMap from "../classes/super-map";
import Subject from "../classes/subject";
import BatchIdentifierLoader from "../classes/batch-identifier-loader";
import BookOpen01 from "../components/icons/book-open-01";
import processManager from "./process-manager";
@ -19,7 +19,9 @@ class DictionaryService {
process: Process;
store: EventStore;
topics = new SuperMap<string, Subject<Map<string, NostrEvent>>>(() => new Subject<Map<string, NostrEvent>>());
topics = new SuperMap<string, BehaviorSubject<Map<string, NostrEvent>>>(
() => new BehaviorSubject<Map<string, NostrEvent>>(new Map()),
);
loaders = new SuperMap<AbstractRelay, BatchIdentifierLoader>((relay) => {
const loader = new BatchIdentifierLoader(this.store, relay, [WIKI_PAGE_KIND], this.log.extend(relay.url));

View File

@ -1,10 +1,10 @@
import dayjs from "dayjs";
import db from "./db";
import _throttle from "lodash.throttle";
import { BehaviorSubject } from "rxjs";
import { fetchWithProxy } from "../helpers/request";
import SuperMap from "../classes/super-map";
import Subject from "../classes/subject";
export function parseAddress(address: string): { name?: string; domain?: string } {
const parts = address.trim().toLowerCase().split("@");
@ -43,7 +43,9 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
class DnsIdentityService {
// undefined === loading
identities = new SuperMap<string, Subject<DnsIdentity | undefined>>(() => new Subject());
identities = new SuperMap<string, BehaviorSubject<DnsIdentity | undefined>>(
() => new BehaviorSubject<DnsIdentity | undefined>(undefined),
);
async fetchIdentity(address: string): Promise<DnsIdentity> {
const { name, domain } = parseAddress(address);

View File

@ -1,12 +1,14 @@
import { Filter } from "nostr-tools";
import stringify from "json-stringify-deterministic";
import { BehaviorSubject } from "rxjs";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { localRelay } from "./local-relay";
class EventCountService {
subjects = new SuperMap<string, Subject<number>>(() => new Subject<number>());
subjects = new SuperMap<string, BehaviorSubject<number | undefined>>(
() => new BehaviorSubject<number | undefined>(undefined),
);
stringifyFilter(filter: Filter | Filter[]) {
return stringify(filter);

View File

@ -2,9 +2,7 @@ import { kinds } from "nostr-tools";
import _throttle from "lodash.throttle";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { localRelay } from "./local-relay";
import relayPoolService from "./relay-pool";
import Process from "../classes/process";
@ -12,20 +10,15 @@ import { LightningIcon } from "../components/icons";
import processManager from "./process-manager";
import BatchRelationLoader from "../classes/batch-relation-loader";
import { logger } from "../helpers/debug";
import { eventStore } from "./event-store";
class EventReactionsService {
log = logger.extend("EventReactionsService");
process: Process;
subjects = new SuperMap<string, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
private loaded = new Map<string, boolean>();
loaders = new SuperMap<AbstractRelay, BatchRelationLoader>((relay) => {
const loader = new BatchRelationLoader(relay, [kinds.Reaction], this.log.extend(relay.url));
this.process.addChild(loader.process);
loader.onEventUpdate.subscribe((id) => {
this.updateSubject(id);
});
return loader;
});
@ -37,48 +30,17 @@ class EventReactionsService {
processManager.registerProcess(this.process);
}
// merged results from all loaders for a single event
private updateSubject(id: string) {
const ids = new Set<string>();
const events: NostrEvent[] = [];
const subject = this.subjects.get(id);
for (const [relay, loader] of this.loaders) {
if (loader.references.has(id)) {
const other = loader.references.get(id);
for (const [_, e] of other) {
if (!ids.has(e.id)) {
ids.add(e.id);
events.push(e);
}
}
}
}
subject.next(events);
}
requestReactions(eventUID: string, urls: Iterable<string | URL | AbstractRelay>, alwaysRequest = true) {
const subject = this.subjects.get(eventUID);
if (subject.value && !alwaysRequest) return subject;
requestReactions(uid: string, urls: Iterable<string | URL | AbstractRelay>, alwaysRequest = false) {
if (this.loaded.get(uid) && !alwaysRequest) return;
if (localRelay) {
this.loaders.get(localRelay as AbstractRelay).requestEvents(eventUID);
this.loaders.get(localRelay as AbstractRelay).requestEvents(uid);
}
const relays = relayPoolService.getRelays(urls);
for (const relay of relays) {
this.loaders.get(relay).requestEvents(eventUID);
this.loaders.get(relay).requestEvents(uid);
}
return subject;
}
handleEvent(event: NostrEvent) {
eventStore.add(event);
// pretend it came from the local relay
if (localRelay) this.loaders.get(localRelay as AbstractRelay).handleEvent(event);
}
}

View File

@ -2,9 +2,7 @@ import { kinds } from "nostr-tools";
import _throttle from "lodash.throttle";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { localRelay } from "./local-relay";
import relayPoolService from "./relay-pool";
import Process from "../classes/process";
@ -17,14 +15,10 @@ class EventZapsService {
log = logger.extend("EventZapsService");
process: Process;
subjects = new SuperMap<string, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
private loaded = new Map<string, boolean>();
loaders = new SuperMap<AbstractRelay, BatchRelationLoader>((relay) => {
const loader = new BatchRelationLoader(relay, [kinds.Zap], this.log.extend(relay.url));
this.process.addChild(loader.process);
loader.onEventUpdate.subscribe((id) => {
this.updateSubject(id);
});
return loader;
});
@ -36,41 +30,17 @@ class EventZapsService {
processManager.registerProcess(this.process);
}
// merged results from all loaders for a single event
private updateSubject(id: string) {
const ids = new Set<string>();
const events: NostrEvent[] = [];
const subject = this.subjects.get(id);
for (const [relay, loader] of this.loaders) {
if (loader.references.has(id)) {
const other = loader.references.get(id);
for (const [_, e] of other) {
if (!ids.has(e.id)) {
ids.add(e.id);
events.push(e);
}
}
}
}
subject.next(events);
}
requestZaps(eventUID: string, urls: Iterable<string | URL | AbstractRelay>, alwaysRequest = true) {
const subject = this.subjects.get(eventUID);
if (subject.value && !alwaysRequest) return subject;
requestZaps(uid: string, urls: Iterable<string | URL | AbstractRelay>, alwaysRequest = true) {
if (this.loaded.get(uid) && !alwaysRequest) return;
if (localRelay) {
this.loaders.get(localRelay as AbstractRelay).requestEvents(eventUID);
this.loaders.get(localRelay as AbstractRelay).requestEvents(uid);
}
const relays = relayPoolService.getRelays(urls);
for (const relay of relays) {
this.loaders.get(relay).requestEvents(eventUID);
this.loaders.get(relay).requestEvents(uid);
}
return subject;
}
}

View File

@ -1,14 +1,16 @@
import dayjs from "dayjs";
import _throttle from "lodash.throttle";
import { BehaviorSubject } from "rxjs";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import db from "./db";
import { logger } from "../helpers/debug";
class ReadStatusService {
log = logger.extend("ReadStatusService");
status = new SuperMap<string, Subject<boolean>>(() => new Subject());
status = new SuperMap<string, BehaviorSubject<boolean | undefined>>(
() => new BehaviorSubject<boolean | undefined>(undefined),
);
ttl = new Map<string, number>();
private setTTL(key: string, ttl: number) {

View File

@ -1,21 +1,26 @@
import _throttle from "lodash.throttle";
import { Filter } from "nostr-tools";
import { BehaviorSubject } from "rxjs";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import relayInfoService from "./relay-info";
import { localRelay } from "./local-relay";
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
import relayPoolService from "./relay-pool";
import { Filter } from "nostr-tools";
import { alwaysVerify } from "./verify-event";
import { eventStore } from "./event-store";
const MONITOR_PUBKEY = "151c17c9d234320cf0f189af7b761f63419fd6c38c6041587a008b7682e4640f";
const MONITOR_RELAY = "wss://history.nostr.watch";
const MONITOR_RELAY = "wss://relay.nostr.watch";
class RelayStatsService {
private selfReported = new SuperMap<string, Subject<NostrEvent | null>>(() => new Subject());
private monitorStats = new SuperMap<string, Subject<NostrEvent>>(() => new Subject());
private selfReported = new SuperMap<string, BehaviorSubject<NostrEvent | null | undefined>>(
() => new BehaviorSubject<NostrEvent | null | undefined>(undefined),
);
private monitorStats = new SuperMap<string, BehaviorSubject<NostrEvent | undefined>>(
() => new BehaviorSubject<NostrEvent | undefined>(undefined),
);
constructor() {
// load all stats from cache and subscribe to future ones
@ -33,6 +38,8 @@ class RelayStatsService {
const relay = getRelayURL(event);
if (!relay) return;
eventStore.add(event);
const sub = this.monitorStats.get(relay);
if (event.kind === SELF_REPORTED_KIND) {
if (!sub.value || event.created_at > sub.value.created_at) {

View File

@ -25,7 +25,6 @@ import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelays } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
import { NostrEvent } from "../../types/nostr-event";
import { getEventCoordinate } from "../../helpers/nostr/event";
import UserAvatarLink from "../../components/user/user-avatar-link";

View File

@ -1,7 +1,6 @@
import { useOutletContext } from "react-router-dom";
import { COMMUNITY_APPROVAL_KIND, buildApprovalMap, getCommunityMods } from "../../../helpers/nostr/communities";
import useSubject from "../../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import TimelineActionAndStatus from "../../../components/timeline/timeline-action-and-status";

View File

@ -1,62 +0,0 @@
import { useMemo } from "react";
import { useOutletContext } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import {
COMMUNITY_APPROVAL_KIND,
buildApprovalMap,
getCommunityMods,
getCommunityRelays,
} from "../../../helpers/nostr/communities";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import TimelineActionAndStatus from "../../../components/timeline/timeline-action-and-status";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import useEventsReactions from "../../../hooks/use-events-reactions";
import { getEventReactionScore, groupReactions } from "../../../helpers/nostr/reactions";
import ApprovedEvent from "../components/community-approved-post";
import { RouterContext } from "../community-home";
export default function CommunityTrendingView() {
const { community, timeline } = useOutletContext<RouterContext>();
const muteFilter = useUserMuteFilter();
const mods = getCommunityMods(community);
const events = useObservable(timeline.timeline) ?? [];
const approvalMap = buildApprovalMap(events, mods);
const approved = events
.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && approvalMap.has(e.id))
.map((event) => ({ event, approvals: approvalMap.get(event.id) }))
.filter((e) => !muteFilter(e.event));
// fetch votes for approved posts
const eventReactions = useEventsReactions(
approved.map((e) => e.event.id),
getCommunityRelays(community),
);
const eventVotes = useMemo(() => {
const dir: Record<string, number> = {};
for (const [id, reactions] of Object.entries(eventReactions)) {
const grouped = groupReactions(reactions);
const { vote } = getEventReactionScore(grouped);
dir[id] = vote;
}
return dir;
}, [eventReactions]);
const sorted = approved.sort((a, b) => (eventVotes[b.event.id] ?? 0) - (eventVotes[a.event.id] ?? 0));
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<IntersectionObserverProvider callback={callback}>
{sorted.map(({ event, approvals }) => (
<ApprovedEvent key={event.id} event={event} approvals={approvals ?? []} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
</>
);
}

View File

@ -27,7 +27,6 @@ import { components } from "../../../components/content";
import { useKind4Decrypt } from "../../../hooks/use-kind4-decryption";
import { fedimintTokens } from "../../../helpers/fedimint";
const transformers = [...defaultTransformers, fedimintTokens];
const linkRenderers = [
renderSimpleXLink,
renderYoutubeURL,
@ -54,7 +53,7 @@ export default function DirectMessageContent({
...props
}: { event: NostrEvent; text: string } & BoxProps) {
const { plaintext } = useKind4Decrypt(event);
const content = useRenderedContent(plaintext, components, { transformers, linkRenderers });
const content = useRenderedContent(plaintext, components, { linkRenderers });
return (
<TrustProvider event={event}>

View File

@ -1,4 +1,5 @@
import { Flex, Progress, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { getGoalAmount, getGoalRelays } from "../../../helpers/nostr/goal";
import { LightningIcon } from "../../../components/icons";

View File

@ -1,6 +1,6 @@
import { Box, Flex, FlexProps, Text } from "@chakra-ui/react";
import { Box, Flex, FlexProps } from "@chakra-ui/react";
import { getEventUID, getZapPayment, getZapSender } from "applesauce-core/helpers";
import { getEventUID } from "../../../helpers/nostr/event";
import { getGoalRelays } from "../../../helpers/nostr/goal";
import useEventZaps from "../../../hooks/use-event-zaps";
import { NostrEvent } from "../../../types/nostr-event";
@ -16,15 +16,13 @@ export default function GoalTopZappers({
}: Omit<FlexProps, "children"> & { goal: NostrEvent; max?: number }) {
const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true);
const totals: Record<string, number> = {};
for (const zap of zaps) {
const p = zap.request.pubkey;
if (zap.payment.amount) {
totals[p] = (totals[p] || 0) + zap.payment.amount;
}
}
const totals = zaps?.reduce<Record<string, number>>((dir, z) => {
const sender = getZapSender(z);
dir[sender] = dir[sender] + (getZapPayment(z)?.amount ?? 0);
return dir;
}, {});
const sortedTotals = Array.from(Object.entries(totals)).sort((a, b) => b[1] - a[1]);
const sortedTotals = totals ? Array.from(Object.entries(totals)).sort((a, b) => b[1] - a[1]) : [];
if (max !== undefined) {
sortedTotals.length = max;
}

View File

@ -1,37 +1,47 @@
import { Box, Flex, Spacer, Text } from "@chakra-ui/react";
import { getEventUID } from "../../../helpers/nostr/event";
import { getEventUID, getZapPayment, getZapRequest, getZapSender } from "applesauce-core/helpers";
import { NostrEvent } from "nostr-tools";
import { getGoalRelays } from "../../../helpers/nostr/goal";
import useEventZaps from "../../../hooks/use-event-zaps";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import { readablizeSats } from "../../../helpers/bolt11";
import { LightningIcon } from "../../../components/icons";
import Timestamp from "../../../components/timestamp";
import TextNoteContents from "../../../components/note/timeline-note/text-note-contents";
function GoalZap({ zap }: { zap: NostrEvent }) {
const request = getZapRequest(zap);
const payment = getZapPayment(zap);
const sender = getZapSender(zap);
if (!payment?.amount) return null;
return (
<Flex gap="2">
<UserAvatarLink pubkey={sender} size="md" />
<Box>
<Text>
<UserLink fontSize="lg" fontWeight="bold" pubkey={sender} mr="2" />
<Timestamp timestamp={zap.created_at} />
</Text>
{request.content && <TextNoteContents event={request} />}
</Box>
<Spacer />
<Text>
<LightningIcon /> {readablizeSats(payment.amount / 1000)}
</Text>
</Flex>
);
}
export default function GoalZapList({ goal }: { goal: NostrEvent }) {
const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true);
const sorted = Array.from(zaps).sort((a, b) => b.event.created_at - a.event.created_at);
return (
<>
{sorted.map((zap) => (
<Flex key={zap.eventId} gap="2">
<UserAvatarLink pubkey={zap.request.pubkey} size="md" />
<Box>
<Text>
<UserLink fontSize="lg" fontWeight="bold" pubkey={zap.request.pubkey} mr="2" />
<Timestamp timestamp={zap.event.created_at} />
</Text>
{zap.request.content && <Text>{zap.request.content}</Text>}
</Box>
<Spacer />
{zap.payment.amount && (
<Text>
<LightningIcon /> {readablizeSats(zap.payment.amount / 1000)}
</Text>
)}
</Flex>
{zaps.map((zap) => (
<GoalZap key={zap.id} zap={zap} />
))}
</>
);

View File

@ -1,6 +1,7 @@
import { useCallback } from "react";
import { Button, Card, CardBody, CardHeader, CardProps, Heading, Link } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { NostrEvent } from "nostr-tools";
import { getEventUID } from "nostr-idb";
@ -9,7 +10,6 @@ import { useNotifications } from "../../../providers/global/notifications-provid
import { NotificationType, NotificationTypeSymbol } from "../../../classes/notifications";
import NotificationItem from "../../notifications/components/notification-item";
import { ErrorBoundary } from "../../../components/error-boundary";
import { useObservable } from "../../../hooks/use-observable";
export default function NotificationsCard({ ...props }: Omit<CardProps, "children">) {
const navigate = useNavigate();

View File

@ -64,7 +64,7 @@ const NotificationItem = ({
content = <RepostNotification event={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Zap:
content = <ZapNotification event={event} onClick={onClick && handleClick} />;
content = <ZapNotification zap={event} onClick={onClick && handleClick} />;
break;
case NotificationType.Message:
content = <MessageNotification event={event} onClick={onClick && handleClick} />;

View File

@ -1,10 +1,15 @@
import { ReactNode, forwardRef, useMemo } from "react";
import { ReactNode, forwardRef } from "react";
import { AvatarGroup, ButtonGroup, Flex, Text } from "@chakra-ui/react";
import {
getZapAddressPointer,
getZapEventPointer,
getZapPayment,
getZapRequest,
getZapSender,
} from "applesauce-core/helpers";
import { NostrEvent } from "nostr-tools";
import { NostrEvent, isATag, isETag } from "../../../types/nostr-event";
import { getParsedZap } from "../../../helpers/nostr/zaps";
import { readablizeSats } from "../../../helpers/bolt11";
import { parseCoordinate } from "../../../helpers/nostr/event";
import { EmbedEventPointer } from "../../../components/embed-event";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import { LightningIcon } from "../../../components/icons";
@ -12,58 +17,54 @@ import NotificationIconEntry from "./notification-icon-entry";
import ZapReceiptMenu from "../../../components/zap/zap-receipt-menu";
import TextNoteContents from "../../../components/note/timeline-note/text-note-contents";
const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
const zap = useMemo(() => getParsedZap(event), [event]);
const ZapNotification = forwardRef<HTMLDivElement, { zap: NostrEvent; onClick?: () => void }>(
({ zap, onClick }, ref) => {
const payment = getZapPayment(zap);
const request = getZapRequest(zap);
if (!payment?.amount) return null;
if (!zap || !zap.payment.amount) return null;
const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
const sender = getZapSender(zap);
const nevent = getZapEventPointer(zap);
const naddr = getZapAddressPointer(zap);
let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) {
if (naddr) {
eventJSX = (
<EmbedEventPointer
pointer={{
type: "naddr",
data: {
pubkey: parsedCoordinate.pubkey,
identifier: parsedCoordinate.identifier,
kind: parsedCoordinate.kind,
},
data: naddr,
}}
/>
);
} else if (eventId) {
eventJSX = <EmbedEventPointer pointer={{ type: "note", data: eventId }} />;
} else if (nevent) {
eventJSX = <EmbedEventPointer pointer={{ type: "nevent", data: nevent }} />;
}
return (
<NotificationIconEntry
ref={ref}
icon={<LightningIcon boxSize={6} color="yellow.400" />}
id={event.id}
pubkey={zap.request.pubkey}
timestamp={zap.request.created_at}
id={zap.id}
pubkey={sender}
timestamp={request.created_at}
summary={
<>
{readablizeSats(zap.payment.amount / 1000)} {zap.request.content}
{readablizeSats(payment.amount / 1000)} {request.content}
</>
}
onClick={onClick}
>
<Flex gap="2" alignItems="center" pl="2">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
<UserAvatarLink pubkey={sender} />
</AvatarGroup>
<Text>zapped {readablizeSats(zap.payment.amount / 1000)} sats</Text>
<Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text>
<ButtonGroup size="sm" variant="ghost" ml="auto">
<ZapReceiptMenu zap={zap.event} aria-label="More Options" />
<ZapReceiptMenu zap={zap} aria-label="More Options" />
</ButtonGroup>
</Flex>
<TextNoteContents event={zap.request} />
<TextNoteContents event={request} />
{eventJSX}
</NotificationIconEntry>
);

View File

@ -3,10 +3,11 @@ import { Button, ButtonGroup, Divider, Flex, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs, { Dayjs } from "dayjs";
import { getEventUID } from "nostr-idb";
import { BehaviorSubject } from "rxjs";
import { useObservable } from "applesauce-react/hooks";
import RequireCurrentAccount from "../../providers/route/require-current-account";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useNotifications } from "../../providers/global/notifications-provider";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
@ -24,8 +25,6 @@ import useNumberCache from "../../hooks/timeline/use-number-cache";
import { useTimelineDates } from "../../hooks/timeline/use-timeline-dates";
import useCacheEntryHeight from "../../hooks/timeline/use-cache-entry-height";
import useVimNavigation from "./use-vim-navigation";
import { PersistentSubject } from "../../classes/subject";
import { useObservable } from "../../hooks/use-observable";
function TimeMarker({ date, ids }: { date: Dayjs; ids: string[] }) {
const readAll = useCallback(() => {
@ -151,14 +150,14 @@ const NotificationsTimeline = memo(
},
);
const cachedFocus = new PersistentSubject("");
const cachedFocus = new BehaviorSubject("");
function NotificationsPage() {
const { timeline } = useNotifications();
// const { value: focused, setValue: setFocused } = useRouteStateValue("focused", "");
// const [focused, setFocused] = useState("");
const focused = useSubject(cachedFocus);
const focused = useObservable(cachedFocus);
const setFocused = useCallback((id: string) => cachedFocus.next(id), [cachedFocus]);
const focusContext = useMemo(() => ({ id: focused, focus: setFocused }), [focused, setFocused]);

View File

@ -7,7 +7,6 @@ import useCurrentAccount from "../../hooks/use-current-account";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import RequireCurrentAccount from "../../providers/route/require-current-account";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useNotifications } from "../../providers/global/notifications-provider";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
@ -31,7 +30,7 @@ import GitBranch01 from "../../components/icons/git-branch-01";
const THREAD_KINDS = [kinds.ShortTextNote, TORRENT_COMMENT_KIND];
function ReplyEntry({ event }: { event: NostrEvent }) {
const enableDrawer = useSubject(localSettings.enableNoteThreadDrawer);
const enableDrawer = useObservable(localSettings.enableNoteThreadDrawer);
const navigate = enableDrawer ? useNavigateInDrawer() : useNavigate();
const address = useShareableEventAddress(event);
const onClick = useCallback<MouseEventHandler>(

View File

@ -18,6 +18,7 @@ import {
} from "@chakra-ui/react";
import { useAsync } from "react-use";
import { NostrEvent } from "nostr-tools";
import { useObservable } from "applesauce-react/hooks";
import { localDatabase } from "../../../../services/local-relay";
import EventKindsPieChart from "../../../../components/charts/event-kinds-pie-chart";
@ -25,7 +26,6 @@ import EventKindsTable from "../../../../components/charts/event-kinds-table";
import ImportEventsButton from "./components/import-events-button";
import ExportEventsButton from "./components/export-events-button";
import { clearCacheData, deleteDatabase } from "../../../../services/db";
import useSubject from "../../../../hooks/use-subject";
import localSettings from "../../../../services/local-settings";
async function importEvents(events: NostrEvent[]) {
@ -43,7 +43,7 @@ export default function InternalDatabasePage() {
const { value: count } = useAsync(async () => await countEvents(localDatabase), []);
const { value: kinds } = useAsync(async () => await countEventsByKind(localDatabase), []);
const maxEvents = useSubject(localSettings.idbMaxEvents);
const maxEvents = useObservable(localSettings.idbMaxEvents);
const [clearing, setClearing] = useState(false);
const handleClearData = async () => {

View File

@ -15,6 +15,7 @@ import {
Text,
} from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { useObservable } from "applesauce-react/hooks";
import { localRelay } from "../../../../services/local-relay";
import WasmRelay from "../../../../services/wasm-relay";
@ -22,7 +23,6 @@ import EventKindsPieChart from "../../../../components/charts/event-kinds-pie-ch
import EventKindsTable from "../../../../components/charts/event-kinds-table";
import ImportEventsButton from "./components/import-events-button";
import ExportEventsButton from "./components/export-events-button";
import useSubject from "../../../../hooks/use-subject";
import localSettings from "../../../../services/local-settings";
export default function WasmDatabasePage() {
@ -32,7 +32,7 @@ export default function WasmDatabasePage() {
if (!worker) return null;
const [summary, setSummary] = useState<Record<string, number>>();
const persistForDays = useSubject(localSettings.wasmPersistForDays);
const persistForDays = useObservable(localSettings.wasmPersistForDays);
const total = summary ? Object.values(summary).reduce((t, v) => t + v, 0) : undefined;

View File

@ -12,6 +12,7 @@ import {
useInterval,
} from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useObservable } from "applesauce-react/hooks";
import BackButton from "../../../components/router/back-button";
import webRtcRelaysService from "../../../services/webrtc-relays";
@ -20,7 +21,6 @@ import QRCodeScannerButton from "../../../components/qr-code/qr-code-scanner-but
import UserAvatar from "../../../components/user/user-avatar";
import UserName from "../../../components/user/user-name";
import localSettings from "../../../services/local-settings";
import useSubject from "../../../hooks/use-subject";
export default function WebRtcConnectView() {
const update = useForceUpdate();
@ -46,7 +46,7 @@ export default function WebRtcConnectView() {
reset();
});
const recent = useSubject(localSettings.webRtcRecentConnections)
const recent = useObservable(localSettings.webRtcRecentConnections)
.map((uri) => ({ ...NostrWebRtcBroker.parseNostrWebRtcURI(uri), uri }))
.filter(({ pubkey }) => !webRtcRelaysService.broker.peers.has(pubkey));

View File

@ -17,22 +17,22 @@ import {
useInterval,
} from "@chakra-ui/react";
import { getPublicKey, kinds, nip19 } from "nostr-tools";
import { useForm } from "react-hook-form";
import dayjs from "dayjs";
import { useAsync } from "react-use";
import { useObservable } from "applesauce-react/hooks";
import BackButton from "../../../components/router/back-button";
import webRtcRelaysService from "../../../services/webrtc-relays";
import useSubject from "../../../hooks/use-subject";
import localSettings from "../../../services/local-settings";
import { CopyIconButton } from "../../../components/copy-icon-button";
import UserAvatar from "../../../components/user/user-avatar";
import UserName from "../../../components/user/user-name";
import QrCodeSvg from "../../../components/qr-code/qr-code-svg";
import { QrCodeIcon } from "../../../components/icons";
import { useForm } from "react-hook-form";
import dayjs from "dayjs";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import useCurrentAccount from "../../../hooks/use-current-account";
import useUserProfile from "../../../hooks/use-user-profile";
import { useAsync } from "react-use";
function NameForm() {
const publish = usePublishEvent();
@ -85,7 +85,7 @@ export default function WebRtcPairView() {
const account = useCurrentAccount();
const showQrCode = useDisclosure();
const identity = useSubject(localSettings.webRtcLocalIdentity);
const identity = useObservable(localSettings.webRtcLocalIdentity);
const pubkey = useMemo(() => getPublicKey(identity), [identity]);
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);

View File

@ -12,8 +12,8 @@ import {
Heading,
Button,
} from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import useSubject from "../../../hooks/use-subject";
import localSettings from "../../../services/local-settings";
import useSettingsForm from "../use-settings-form";
import VerticalPageLayout from "../../../components/vertical-page-layout";
@ -21,9 +21,9 @@ import VerticalPageLayout from "../../../components/vertical-page-layout";
export default function DisplaySettings() {
const { register, submit, formState } = useSettingsForm();
const hideZapBubbles = useSubject(localSettings.hideZapBubbles);
const enableNoteDrawer = useSubject(localSettings.enableNoteThreadDrawer);
const showBrandLogo = useSubject(localSettings.showBrandLogo);
const hideZapBubbles = useObservable(localSettings.hideZapBubbles);
const enableNoteDrawer = useObservable(localSettings.enableNoteThreadDrawer);
const showBrandLogo = useObservable(localSettings.showBrandLogo);
return (
<VerticalPageLayout flex={1}>

View File

@ -11,15 +11,15 @@ import {
Button,
Heading,
} from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import { safeUrl } from "../../../helpers/parse";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import useSettingsForm from "../use-settings-form";
import useSubject from "../../../hooks/use-subject";
import localSettings from "../../../services/local-settings";
function VerifyEventSettings() {
const verifyEventMethod = useSubject(localSettings.verifyEventMethod);
const verifyEventMethod = useObservable(localSettings.verifyEventMethod);
return (
<>
@ -46,7 +46,7 @@ function VerifyEventSettings() {
export default function PerformanceSettings() {
const { register, submit, formState } = useSettingsForm();
const enableKeyboardShortcuts = useSubject(localSettings.enableKeyboardShortcuts);
const enableKeyboardShortcuts = useObservable(localSettings.enableKeyboardShortcuts);
return (
<VerticalPageLayout as="form" onSubmit={submit} flex={1}>

View File

@ -22,6 +22,7 @@ import {
Switch,
} from "@chakra-ui/react";
import { matchSorter } from "match-sorter";
import { useObservable } from "applesauce-react/hooks";
import { EditIcon } from "../../../components/icons";
import { useContextEmojis } from "../../../providers/global/emoji-provider";
@ -30,7 +31,6 @@ import useCurrentAccount from "../../../hooks/use-current-account";
import useSettingsForm from "../use-settings-form";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import localSettings from "../../../services/local-settings";
import useSubject from "../../../hooks/use-subject";
export default function PostSettings() {
const account = useCurrentAccount();
@ -67,7 +67,7 @@ export default function PostSettings() {
);
};
const addClientTag = useSubject(localSettings.addClientTag);
const addClientTag = useObservable(localSettings.addClientTag);
return (
<VerticalPageLayout as="form" onSubmit={submit} flex={1}>

View File

@ -12,12 +12,13 @@ import {
Heading,
FormLabel,
} from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import { safeUrl } from "../../../helpers/parse";
import { createRequestProxyUrl } from "../../../helpers/request";
import { RelayAuthMode } from "../../../classes/relay-pool";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import useSettingsForm from "../use-settings-form";
import useSubject from "../../../hooks/use-subject";
import localSettings from "../../../services/local-settings";
async function validateInvidiousUrl(url?: string) {
@ -45,8 +46,8 @@ async function validateRequestProxy(url?: string) {
export default function PrivacySettings() {
const { register, submit, formState } = useSettingsForm();
const defaultAuthenticationMode = useSubject(localSettings.defaultAuthenticationMode);
const proactivelyAuthenticate = useSubject(localSettings.proactivelyAuthenticate);
const defaultAuthenticationMode = useObservable(localSettings.defaultAuthenticationMode);
const proactivelyAuthenticate = useObservable(localSettings.proactivelyAuthenticate);
return (
<VerticalPageLayout as="form" onSubmit={submit} flex={1}>

View File

@ -1,8 +1,7 @@
import { useMemo } from "react";
import { Flex, FlexProps, Text } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { getZapPayment, getZapSender } from "applesauce-core/helpers";
import { parseZapEvents } from "../../../helpers/nostr/zaps";
import UserLink from "../../../components/user/user-link";
import { LightningIcon } from "../../../components/icons";
import { readablizeSats } from "../../../helpers/bolt11";
@ -12,15 +11,13 @@ import UserAvatarLink from "../../../components/user/user-avatar-link";
export default function TopZappers({ stream, ...props }: FlexProps & { stream: ParsedStream }) {
const { timeline } = useStreamChatTimeline(stream);
const zaps = useMemo(() => parseZapEvents(timeline.filter((e) => e.kind === kinds.Zap)), [timeline]);
const zaps = timeline.filter((e) => e.kind === kinds.Zap);
const totals: Record<string, number> = {};
for (const zap of zaps) {
const p = zap.request.pubkey;
if (zap.payment.amount) {
totals[p] = (totals[p] || 0) + zap.payment.amount;
}
}
const totals = zaps?.reduce<Record<string, number>>((dir, z) => {
const sender = getZapSender(z);
dir[sender] = dir[sender] + (getZapPayment(z)?.amount ?? 0);
return dir;
}, {});
const sortedTotals = Array.from(Object.entries(totals)).sort((a, b) => b[1] - a[1]);

View File

@ -200,7 +200,7 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
const Layout = isMobile ? MobileStreamPage : DesktopStreamPage;
// const chatTimeline = useStreamChatTimeline(stream);
// const chatLog = useSubject(chatTimeline.timeline);
// const chatLog = useObservable(chatTimeline.timeline);
// const pubkeysInChat = useMemo(() => {
// const set = new Set<string>();
// for (const event of chatLog) {

View File

@ -1,4 +1,4 @@
import { memo, useMemo } from "react";
import { memo } from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import { ParsedStream } from "../../../../helpers/nostr/stream";
@ -6,33 +6,35 @@ import UserAvatar from "../../../../components/user/user-avatar";
import UserLink from "../../../../components/user/user-link";
import { NostrEvent } from "../../../../types/nostr-event";
import { LightningIcon } from "../../../../components/icons";
import { getParsedZap } from "../../../../helpers/nostr/zaps";
import { readablizeSats } from "../../../../helpers/bolt11";
import { TrustProvider } from "../../../../providers/local/trust-provider";
import ChatMessageContent from "./chat-message-content";
import useClientSideMuteFilter from "../../../../hooks/use-client-side-mute-filter";
import useEventIntersectionRef from "../../../../hooks/use-event-intersection-ref";
import { getZapPayment, getZapRequest, getZapSender } from "applesauce-core/helpers";
function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
const ref = useEventIntersectionRef(zap);
const parsed = useMemo(() => getParsedZap(zap), [zap]);
const sender = getZapSender(zap);
const payment = getZapPayment(zap);
const request = getZapRequest(zap);
const clientMuteFilter = useClientSideMuteFilter();
if (!parsed || !parsed.payment.amount) return null;
if (clientMuteFilter(parsed.event)) return null;
if (!payment?.amount) return null;
if (clientMuteFilter(zap)) return null;
return (
<TrustProvider event={parsed.request}>
<TrustProvider event={request}>
<Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}>
<Flex gap="2">
<LightningIcon color="yellow.400" />
<UserAvatar pubkey={parsed.request.pubkey} size="xs" />
<UserLink pubkey={parsed.request.pubkey} fontWeight="bold" color="yellow.400" />
<Text>zapped {readablizeSats(parsed.payment.amount / 1000)} sats</Text>
<UserAvatar pubkey={sender} size="xs" />
<UserLink pubkey={sender} fontWeight="bold" color="yellow.400" />
<Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text>
</Flex>
<Box>
<ChatMessageContent event={parsed.request} />
<ChatMessageContent event={request} />
</Box>
</Flex>
</TrustProvider>

View File

@ -1,3 +1,4 @@
import { Suspense } from "react";
import {
Heading,
Modal,
@ -7,21 +8,9 @@ import {
ModalOverlay,
ModalProps,
Spinner,
Tab,
TabIndicator,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import { PersistentSubject } from "../../classes/subject";
import useSubject from "../../hooks/use-subject";
import DatabaseView from "../relays/cache/database";
import TaskManagerRelays from "./relays";
import { Suspense } from "react";
type Router = ReturnType<typeof createMemoryRouter>;
export default function TaskManagerModal({

View File

@ -1,14 +1,14 @@
import { Spinner, Tag, TagLabel, TagProps } from "@chakra-ui/react";
import { useObservable } from "applesauce-react/hooks";
import PublishAction from "../../../classes/nostr-publish-action";
import useSubject from "../../../hooks/use-subject";
import { CheckIcon, ErrorIcon } from "../../../components/icons";
export default function PublishActionStatusTag({
action,
...props
}: { action: PublishAction } & Omit<TagProps, "children">) {
const results = useSubject(action.results);
const results = useObservable(action.results);
const successful = results.filter(({ success }) => success);
const failedWithMessage = results.filter(({ success, message }) => !success && !!message);

View File

@ -11,9 +11,9 @@ import {
Spinner,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import PublishAction, { PublishResult } from "../../../classes/nostr-publish-action";
import useSubject from "../../../hooks/use-subject";
import { RelayPaidTag } from "../../relays/components/relay-card";
import { EmbedEvent } from "../../../components/embed-event";
@ -39,7 +39,7 @@ function PublishResultRow({ result }: { result: PublishResult }) {
}
export function PublishDetails({ pub }: PostResultsProps & Omit<FlexProps, "children">) {
const results = useSubject(pub.results);
const results = useObservable(pub.results);
const relayResults: Record<string, PublishResult | undefined> = {};
for (const url of pub.relays) {

View File

@ -1,3 +1,4 @@
import { useMemo } from "react";
import {
Badge,
Box,
@ -20,12 +21,13 @@ import {
import { Link as RouterLink } from "react-router-dom";
import { useLocalStorage } from "react-use";
import { AbstractRelay } from "nostr-tools/abstract-relay";
import { useObservable } from "applesauce-react/hooks";
import { combineLatest, map } from "rxjs";
import relayPoolService from "../../../services/relay-pool";
import { RelayFavicon } from "../../../components/relay-favicon";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { localRelay } from "../../../services/local-relay";
import useSubjects from "../../../hooks/use-subjects";
import { IconRelayAuthButton, useRelayAuthMethod } from "../../../components/relays/relay-auth-button";
import RelayConnectSwitch from "../../../components/relays/relay-connect-switch";
import useRouteSearchValue from "../../../hooks/use-route-search-value";
@ -33,7 +35,6 @@ import processManager from "../../../services/process-manager";
import { RelayAuthMode } from "../../../classes/relay-pool";
import Timestamp from "../../../components/timestamp";
import localSettings from "../../../services/local-settings";
import useSubject from "../../../hooks/use-subject";
function RelayCard({ relay }: { relay: AbstractRelay }) {
return (
@ -52,7 +53,7 @@ function RelayCard({ relay }: { relay: AbstractRelay }) {
function RelayAuthCard({ relay }: { relay: AbstractRelay }) {
const { authenticated } = useRelayAuthMethod(relay);
const defaultMode = useSubject(localSettings.defaultAuthenticationMode);
const defaultMode = useObservable(localSettings.defaultAuthenticationMode);
const processes = processManager.getRootProcessesForRelay(relay);
const [authMode, setAuthMode] = useLocalStorage<RelayAuthMode | "">(
@ -106,9 +107,14 @@ export default function TaskManagerRelays() {
.filter((r) => r !== localRelay)
.sort((a, b) => +b.connected - +a.connected || a.url.localeCompare(b.url));
const notices = useSubjects(Array.from(relayPoolService.notices.values()))
.flat()
.sort((a, b) => b.date - a.date);
const observable = useMemo(
() =>
combineLatest(Array.from(relayPoolService.notices.values())).pipe(
map((relays) => relays.flat().sort((a, b) => b.date - a.date)),
),
[],
);
const notices = useObservable(observable) ?? [];
const challenges = Array.from(relayPoolService.challenges.entries()).filter(([r, c]) => r.connected && !!c.value);

View File

@ -14,11 +14,11 @@ import {
useInterval,
} from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import BackButton from "../../../components/router/back-button";
import relayPoolService from "../../../services/relay-pool";
import useSubject from "../../../hooks/use-subject";
import ProcessBranch from "../processes/process/process-tree";
import processManager from "../../../services/process-manager";
@ -37,7 +37,7 @@ export default function InspectRelayView() {
const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]);
const rootProcesses = processManager.getRootProcessesForRelay(relay);
const notices = useSubject(relayPoolService.notices.get(relay));
const notices = useObservable(relayPoolService.notices.get(relay));
return (
<VerticalPageLayout>

View File

@ -1,8 +1,9 @@
import { memo } from "react";
import { Box, ButtonGroup, Flex, Text } from "@chakra-ui/react";
import { ThreadItem } from "applesauce-core/queries";
import { NostrEvent } from "nostr-tools";
import { getZapPayment, getZapRequest, getZapSender } from "applesauce-core/helpers";
import { ParsedZap } from "../../../../helpers/nostr/zaps";
import UserAvatarLink from "../../../../components/user/user-avatar-link";
import UserLink from "../../../../components/user/user-link";
import Timestamp from "../../../../components/timestamp";
@ -12,39 +13,42 @@ import TextNoteContents from "../../../../components/note/timeline-note/text-not
import { TrustProvider } from "../../../../providers/local/trust-provider";
import ZapReceiptMenu from "../../../../components/zap/zap-receipt-menu";
const ZapEvent = memo(({ zap }: { zap: ParsedZap }) => {
if (!zap.payment.amount) return null;
const ZapEvent = memo(({ zap }: { zap: NostrEvent }) => {
const request = getZapRequest(zap);
const payment = getZapPayment(zap);
const sender = getZapSender(zap);
if (!payment?.amount) return null;
return (
<TrustProvider event={zap.request}>
<TrustProvider event={request}>
<Flex gap="2">
<Flex direction="column" alignItems="center" minW="10">
<LightningIcon color="yellow.500" boxSize={5} />
<Text>{readablizeSats(zap.payment.amount / 1000)}</Text>
<Text>{readablizeSats(payment.amount / 1000)}</Text>
</Flex>
<UserAvatarLink pubkey={zap.request.pubkey} size="sm" ml="2" />
<UserAvatarLink pubkey={sender} size="sm" ml="2" />
<Box>
<UserLink pubkey={zap.request.pubkey} fontWeight="bold" />
<Timestamp timestamp={zap.event.created_at} ml="2" />
<TextNoteContents event={zap.request} />
<UserLink pubkey={sender} fontWeight="bold" />
<Timestamp timestamp={zap.created_at} ml="2" />
<TextNoteContents event={request} />
</Box>
<ButtonGroup ml="auto" size="sm" variant="ghost">
<ZapReceiptMenu zap={zap.event} aria-label="More Options" />
<ZapReceiptMenu zap={zap} aria-label="More Options" />
</ButtonGroup>
</Flex>
</TrustProvider>
);
});
export default function PostZapsTab({ post, zaps }: { post: ThreadItem; zaps: ParsedZap[] }) {
export default function PostZapsTab({ post, zaps }: { post: ThreadItem; zaps: NostrEvent[] }) {
return (
<Flex px="2" direction="column" gap="2" mb="2">
{Array.from(zaps)
.sort((a, b) => (b.payment.amount ?? 0) - (a.payment.amount ?? 0))
.sort((a, b) => (getZapPayment(b)?.amount ?? 0) - (getZapPayment(a)?.amount ?? 0))
.map((zap) => (
<ZapEvent key={zap.event.id} zap={zap} />
<ZapEvent key={zap.id} zap={zap} />
))}
</Flex>
);

View File

@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Box, Flex, Select, Text } from "@chakra-ui/react";
import { useRenderedContent } from "applesauce-react/hooks";
import { getZapPayment, getZapRequest } from "applesauce-core/helpers";
import dayjs from "dayjs";
import { ErrorBoundary } from "../../components/error-boundary";
@ -9,7 +10,7 @@ import { LightningIcon } from "../../components/icons";
import UserAvatarLink from "../../components/user/user-avatar-link";
import UserLink from "../../components/user/user-link";
import { readablizeSats } from "../../helpers/bolt11";
import { isProfileZap, isNoteZap, totalZaps, parseZapEvents, getParsedZap } from "../../helpers/nostr/zaps";
import { isProfileZap, isNoteZap, totalZaps } from "../../helpers/nostr/zaps";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { NostrEvent, isATag, isETag } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
@ -27,10 +28,11 @@ import { renderGenericUrl } from "../../components/content/links/common";
const linkRenderers = [renderGenericUrl];
const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
const ref = useEventIntersectionRef(zapEvent);
const Zap = ({ zap }: { zap: NostrEvent }) => {
const ref = useEventIntersectionRef(zap);
const { request, payment } = getParsedZap(zapEvent, false);
const request = getZapRequest(zap);
const payment = getZapPayment(zap);
const eventId = request.tags.find(isETag)?.[1];
const coordinate = request.tags.find(isATag)?.[1];
@ -62,7 +64,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
<UserAvatarLink pubkey={request.pubkey} size="sm" />
<UserLink pubkey={request.pubkey} fontWeight="bold" />
<Text>Zapped</Text>
{payment.amount && (
{payment?.amount && (
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>{readablizeSats(payment.amount / 1000)} sats</Text>
@ -95,13 +97,12 @@ const UserZapsTab = () => {
[filter],
);
const { loader, timeline: events } = useTimelineLoader(
const { loader, timeline: zaps } = useTimelineLoader(
`${pubkey}-zaps`,
relays,
{ "#p": [pubkey], kinds: [9735] },
{ eventFilter },
);
const zaps = useMemo(() => parseZapEvents(events), [events]);
const callback = useTimelineCurserIntersectionCallback(loader);
@ -114,19 +115,19 @@ const UserZapsTab = () => {
<option value="note">Note Zaps</option>
<option value="profile">Profile Zaps</option>
</Select>
{events.length && (
{zaps.length && (
<Flex gap="2">
<LightningIcon color="yellow.400" />
<Text>
{readablizeSats(totalZaps(zaps) / 1000)} sats in the last{" "}
{dayjs.unix(events[events.length - 1].created_at).fromNow(true)}
{dayjs.unix(zaps[zaps.length - 1].created_at).fromNow(true)}
</Text>
</Flex>
)}
</Flex>
{events.map((event) => (
<ErrorBoundary key={event.id} event={event}>
<Zap zapEvent={event} />
{zaps.map((zaps) => (
<ErrorBoundary key={zaps.id} event={zaps}>
<Zap zap={zaps} />
</ErrorBoundary>
))}

View File

@ -18,12 +18,12 @@ import { NostrEvent } from "nostr-tools";
import { ExtraProps } from "react-markdown";
import { getEventUID } from "nostr-idb";
import { Link as RouterLink } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { useReadRelays } from "../../../hooks/use-client-relays";
import { getPageDefer, getPageSummary } from "../../../helpers/nostr/wiki";
import UserName from "../../../components/user/user-name";
import dictionaryService from "../../../services/dictionary";
import useSubject from "../../../hooks/use-subject";
import { useWebOfTrust } from "../../../providers/global/web-of-trust-provider";
export default function WikiLink({
@ -47,7 +47,7 @@ export default function WikiLink({
() => (topic ? dictionaryService.requestTopic(topic, readRelays) : undefined),
[topic, readRelays],
);
const events = useSubject(subject);
const events = useObservable(subject);
const sorted = useMemo(() => {
if (!events) return [];

View File

@ -14,6 +14,7 @@ import {
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import useParamsAddressPointer from "../../hooks/use-params-address-pointer";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
@ -21,7 +22,6 @@ import VerticalPageLayout from "../../components/vertical-page-layout";
import { getPageDefer, getPageForks, getPageSummary, getPageTitle, getPageTopic } from "../../helpers/nostr/wiki";
import MarkdownContent from "./components/markdown";
import UserLink from "../../components/user/user-link";
import useSubject from "../../hooks/use-subject";
import WikiPageResult from "./components/wiki-page-result";
import Timestamp from "../../components/timestamp";
import WikiPageHeader from "./components/wiki-page-header";
@ -142,7 +142,7 @@ function WikiPageFooter({ page }: { page: NostrEvent }) {
const readRelays = useReadRelays();
const subject = useMemo(() => dictionaryService.requestTopic(topic, readRelays), [topic, readRelays]);
const pages = useSubject(subject);
const pages = useObservable(subject);
let forks = pages ? Array.from(pages.values()).filter((p) => getPageForks(p).address?.pubkey === page.pubkey) : [];
if (webOfTrust) forks = webOfTrust.sortByDistanceAndConnections(forks, (p) => p.pubkey);

View File

@ -1,9 +1,9 @@
import { Button, Flex, Heading, Link } from "@chakra-ui/react";
import { Navigate, useParams, Link as RouterLink } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { NostrEvent } from "nostr-tools";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useSubject from "../../hooks/use-subject";
import { useMemo } from "react";
import dictionaryService from "../../services/dictionary";
import { useReadRelays } from "../../hooks/use-client-relays";
@ -23,7 +23,7 @@ export default function WikiTopicView() {
const readRelays = useReadRelays();
const subject = useMemo(() => dictionaryService.requestTopic(topic, readRelays, true), [topic, readRelays]);
const pages = useSubject(subject);
const pages = useObservable(subject);
let sorted = pages ? Array.from(pages.values()) : [];
if (webOfTrust) sorted = webOfTrust.sortByDistanceAndConnections(sorted, (p) => p.pubkey);