mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
rework metadata and contacts service
This commit is contained in:
parent
621338cffb
commit
0c25242c18
@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 120
|
||||
}
|
@ -14,3 +14,7 @@
|
||||
- add emoji reaction button
|
||||
- save relay list as note
|
||||
- load relays from note
|
||||
|
||||
create a subscription manager that takes a "canMerge" function and batches requests
|
||||
create a template for a cached subscription service
|
||||
create a template for a cached request service
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Subject, Subscription as RxSubscription } from "rxjs";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { Relay } from "./relays";
|
||||
import relayPool from "./relays/relay-pool";
|
||||
import { Relay } from "../services/relays";
|
||||
import relayPool from "../services/relays/relay-pool";
|
||||
import { IncomingEvent } from "../services/relays/relay";
|
||||
|
||||
let lastId = 0;
|
||||
|
||||
const REQUEST_DEFAULT_TIMEOUT = 1000 * 20;
|
||||
export class Request {
|
||||
export class NostrRequest {
|
||||
static IDLE = "idle";
|
||||
static RUNNING = "running";
|
||||
static COMPLETE = "complete";
|
||||
@ -14,11 +17,12 @@ export class Request {
|
||||
timeout: number;
|
||||
relays: Set<Relay>;
|
||||
relayCleanup = new Map<Relay, RxSubscription[]>();
|
||||
state = Request.IDLE;
|
||||
state = NostrRequest.IDLE;
|
||||
onEvent = new Subject<NostrEvent>();
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
constructor(relayUrls: string[], timeout?: number) {
|
||||
this.id = String(Math.floor(Math.random() * 1000000));
|
||||
this.id = `request-${lastId++}`;
|
||||
this.relays = new Set(relayUrls.map((url) => relayPool.requestRelay(url)));
|
||||
|
||||
for (const relay of this.relays) {
|
||||
@ -34,8 +38,9 @@ export class Request {
|
||||
|
||||
cleanup.push(
|
||||
relay.onEvent.subscribe((event) => {
|
||||
if (event.subId === this.id) {
|
||||
if (this.state === NostrRequest.RUNNING && event.subId === this.id && !this.seenEvents.has(event.body.id)) {
|
||||
this.onEvent.next(event.body);
|
||||
this.seenEvents.add(event.body.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
@ -54,15 +59,15 @@ export class Request {
|
||||
for (const fn of cleanup) fn.unsubscribe();
|
||||
|
||||
if (this.relays.size === 0) {
|
||||
this.state = Request.COMPLETE;
|
||||
this.state = NostrRequest.COMPLETE;
|
||||
this.onEvent.complete();
|
||||
}
|
||||
}
|
||||
|
||||
start(query: NostrQuery) {
|
||||
if (this.state !== Request.IDLE) return this;
|
||||
if (this.state !== NostrRequest.IDLE) return this;
|
||||
|
||||
this.state = Request.RUNNING;
|
||||
this.state = NostrRequest.RUNNING;
|
||||
for (const relay of this.relays) {
|
||||
relay.send(["REQ", this.id, query]);
|
||||
}
|
||||
@ -74,9 +79,9 @@ export class Request {
|
||||
return this;
|
||||
}
|
||||
cancel() {
|
||||
if (this.state !== Request.COMPLETE) return this;
|
||||
if (this.state !== NostrRequest.COMPLETE) return this;
|
||||
|
||||
this.state = Request.COMPLETE;
|
||||
this.state = NostrRequest.COMPLETE;
|
||||
for (const relay of this.relays) {
|
||||
relay.send(["CLOSE", this.id]);
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import { Subject, SubscriptionLike } from "rxjs";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrOutgoingMessage, NostrQuery } from "../types/nostr-query";
|
||||
import { Relay } from "./relays";
|
||||
import { IncomingEvent } from "./relays/relay";
|
||||
import relayPool from "./relays/relay-pool";
|
||||
import { Relay } from "../services/relays";
|
||||
import { IncomingEvent } from "../services/relays/relay";
|
||||
import relayPool from "../services/relays/relay-pool";
|
||||
|
||||
export class Subscription {
|
||||
let lastId = 0;
|
||||
|
||||
export class NostrSubscription {
|
||||
static INIT = "initial";
|
||||
static OPEN = "open";
|
||||
static CLOSED = "closed";
|
||||
|
||||
@ -14,12 +17,12 @@ export class Subscription {
|
||||
query?: NostrQuery;
|
||||
relayUrls: string[];
|
||||
relays: Relay[];
|
||||
state = Subscription.CLOSED;
|
||||
state = NostrSubscription.INIT;
|
||||
onEvent = new Subject<NostrEvent>();
|
||||
cleanup: SubscriptionLike[] = [];
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
constructor(relayUrls: string[], query?: NostrQuery, name?: string) {
|
||||
this.id = String(Math.floor(Math.random() * 1000000));
|
||||
this.id = String(name||lastId++);
|
||||
this.query = query;
|
||||
this.name = name;
|
||||
this.relayUrls = relayUrls;
|
||||
@ -27,13 +30,15 @@ export class Subscription {
|
||||
this.relays = relayUrls.map((url) => relayPool.requestRelay(url));
|
||||
}
|
||||
handleOpen(relay: Relay) {
|
||||
if (!this.query) return;
|
||||
// when the relay connects send the req event
|
||||
relay.send(["REQ", this.id, this.query]);
|
||||
if (this.query) {
|
||||
// when the relay connects send the req event
|
||||
relay.send(["REQ", this.id, this.query]);
|
||||
}
|
||||
}
|
||||
handleEvent(event: IncomingEvent) {
|
||||
if (event.subId === this.id) {
|
||||
if (this.state === NostrSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) {
|
||||
this.onEvent.next(event.body);
|
||||
this.seenEvents.add(event.body.id);
|
||||
}
|
||||
}
|
||||
send(message: NostrOutgoingMessage) {
|
||||
@ -42,19 +47,9 @@ export class Subscription {
|
||||
}
|
||||
}
|
||||
|
||||
setQuery(query: NostrQuery) {
|
||||
this.query = query;
|
||||
|
||||
// if open, than update remote subscription
|
||||
if (this.state === Subscription.OPEN) {
|
||||
this.send(["REQ", this.id, this.query]);
|
||||
}
|
||||
}
|
||||
open() {
|
||||
if (this.state === Subscription.OPEN || !this.query) return;
|
||||
this.state = Subscription.OPEN;
|
||||
this.send(["REQ", this.id, this.query]);
|
||||
|
||||
cleanup: SubscriptionLike[] = [];
|
||||
/** listen for event and open events from relays */
|
||||
private subscribeToRelays() {
|
||||
for (const relay of this.relays) {
|
||||
this.cleanup.push(relay.onEvent.subscribe(this.handleEvent.bind(this)));
|
||||
this.cleanup.push(relay.onOpen.subscribe(this.handleOpen.bind(this)));
|
||||
@ -63,24 +58,63 @@ export class Subscription {
|
||||
for (const url of this.relayUrls) {
|
||||
relayPool.addClaim(url, this);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info(`Subscription: "${this.name || this.id}" opened`);
|
||||
}
|
||||
}
|
||||
close() {
|
||||
if (this.state === Subscription.CLOSED) return;
|
||||
this.state = Subscription.CLOSED;
|
||||
this.send(["CLOSE", this.id]);
|
||||
|
||||
/** listen for event and open events from relays */
|
||||
private unsubscribeToRelays() {
|
||||
this.cleanup.forEach((sub) => sub.unsubscribe());
|
||||
|
||||
for (const url of this.relayUrls) {
|
||||
relayPool.removeClaim(url, this);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!this.query) throw new Error("cant open without a query");
|
||||
if (this.state === NostrSubscription.OPEN) return this;
|
||||
|
||||
this.state = NostrSubscription.OPEN;
|
||||
this.send(["REQ", this.id, this.query]);
|
||||
|
||||
this.subscribeToRelays();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info(`Subscription: "${this.name || this.id}" opened`);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
update(query: NostrQuery) {
|
||||
this.query = query;
|
||||
if (this.state === NostrSubscription.OPEN) {
|
||||
this.send(["REQ", this.id, this.query]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
setRelays(relays: string[]) {
|
||||
this.unsubscribeToRelays();
|
||||
|
||||
// get new relays
|
||||
this.relayUrls = relays;
|
||||
this.relays = relays.map((url) => relayPool.requestRelay(url));
|
||||
|
||||
this.subscribeToRelays();
|
||||
}
|
||||
close() {
|
||||
if (this.state !== NostrSubscription.OPEN) return this;
|
||||
|
||||
// set state
|
||||
this.state = NostrSubscription.CLOSED;
|
||||
// send close message
|
||||
this.send(["CLOSE", this.id]);
|
||||
// forget all seen events
|
||||
this.seenEvents.clear();
|
||||
// unsubscribe from relay messages
|
||||
this.unsubscribeToRelays();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info(`Subscription: "${this.name || this.id}" closed`);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
41
src/classes/pubkey-request-list.ts
Normal file
41
src/classes/pubkey-request-list.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { unique } from "../helpers/array";
|
||||
|
||||
export class PubkeyRequestList {
|
||||
needsFlush = false;
|
||||
requests = new Map<string, Set<string>>();
|
||||
|
||||
hasPubkey(pubkey: string) {
|
||||
return this.requests.has(pubkey);
|
||||
}
|
||||
addPubkey(pubkey: string, relays: string[] = []) {
|
||||
const pending = this.requests.get(pubkey);
|
||||
if (pending) {
|
||||
if (relays.length > 0) {
|
||||
this.needsFlush = true;
|
||||
// get or create the list of relays
|
||||
const r = this.requests.get(pubkey) ?? new Set();
|
||||
// add new relay urls to set
|
||||
relays.forEach((url) => r.add(url));
|
||||
this.requests.set(pubkey, r);
|
||||
}
|
||||
} else {
|
||||
this.needsFlush = true;
|
||||
this.requests.set(pubkey, new Set(relays));
|
||||
}
|
||||
}
|
||||
removePubkey(pubkey: string) {
|
||||
this.requests.delete(pubkey);
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.needsFlush = false;
|
||||
const pubkeys = Array.from(this.requests.keys());
|
||||
const relays = unique(
|
||||
Array.from(this.requests.values())
|
||||
.map((set) => Array.from(set))
|
||||
.flat()
|
||||
);
|
||||
|
||||
return { pubkeys, relays };
|
||||
}
|
||||
}
|
28
src/classes/pubkey-subject-cache.ts
Normal file
28
src/classes/pubkey-subject-cache.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
export class PubkeySubjectCache<T> {
|
||||
subjects = new Map<string, BehaviorSubject<T | null>>();
|
||||
|
||||
hasSubject(pubkey: string) {
|
||||
return this.subjects.has(pubkey);
|
||||
}
|
||||
getSubject(pubkey: string) {
|
||||
let subject = this.subjects.get(pubkey);
|
||||
if (!subject) {
|
||||
subject = new BehaviorSubject<T | null>(null);
|
||||
this.subjects.set(pubkey, subject);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
prune() {
|
||||
const prunedKeys: string[] = [];
|
||||
for (const [key, subject] of this.subjects) {
|
||||
if (!subject.observed) {
|
||||
this.subjects.delete(key);
|
||||
prunedKeys.push(key);
|
||||
}
|
||||
}
|
||||
return prunedKeys;
|
||||
}
|
||||
}
|
93
src/classes/request-manager.ts
Normal file
93
src/classes/request-manager.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { NostrRequest } from "./nostr-request";
|
||||
|
||||
function mergeSets<T extends unknown>(to: Set<T>, from: Iterable<T>) {
|
||||
for (const el of from) {
|
||||
to.add(el);
|
||||
}
|
||||
}
|
||||
|
||||
export type getQueryKeyFn<QueryT> = (query: QueryT) => string;
|
||||
export type mergeQueriesFn<QueryT> = (a: QueryT, b: QueryT) => QueryT | undefined | null;
|
||||
export type getEventQueryKeyFn = (event: NostrEvent) => string;
|
||||
|
||||
type PendingRequest<QueryT = NostrQuery> = {
|
||||
query: QueryT;
|
||||
subject: Subject<NostrEvent>;
|
||||
relays: Set<string>;
|
||||
};
|
||||
|
||||
/** @deprecated incomplete */
|
||||
export class RequestManager<QueryT extends NostrQuery> {
|
||||
private getQueryKey: getQueryKeyFn<QueryT>;
|
||||
private mergeQueries: mergeQueriesFn<QueryT>;
|
||||
private getEventQueryKey: getEventQueryKeyFn;
|
||||
|
||||
private runningRequests = new Map<string, NostrRequest>();
|
||||
private requestQueue = new Map<string, PendingRequest<QueryT>>();
|
||||
|
||||
constructor(
|
||||
getQueryKey: getQueryKeyFn<QueryT>,
|
||||
mergeQueries: mergeQueriesFn<QueryT>,
|
||||
getEventQueryKey: getEventQueryKeyFn
|
||||
) {
|
||||
this.getQueryKey = getQueryKey;
|
||||
this.mergeQueries = mergeQueries;
|
||||
this.getEventQueryKey = getEventQueryKey;
|
||||
}
|
||||
|
||||
request(query: QueryT, relays: string[]) {
|
||||
const key = this.getQueryKey(query);
|
||||
if (this.runningRequests.has(key)) throw new Error("requesting a currently running query");
|
||||
|
||||
const pending = this.requestQueue.get(key);
|
||||
if (pending) {
|
||||
mergeSets(pending.relays, relays);
|
||||
return pending.subject;
|
||||
}
|
||||
|
||||
const subject = new Subject<NostrEvent>();
|
||||
this.requestQueue.set(key, {
|
||||
query,
|
||||
relays: new Set(relays),
|
||||
subject,
|
||||
});
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
batch() {
|
||||
const requests: PendingRequest<QueryT>[] = [];
|
||||
|
||||
for (const [key, pending] of this.requestQueue) {
|
||||
let wasMerged = false;
|
||||
if (requests.length > 0) {
|
||||
for (const request of requests) {
|
||||
const merged = this.mergeQueries(request.query, pending.query);
|
||||
if (merged) {
|
||||
request.query = merged;
|
||||
request.subject.subscribe(pending.subject);
|
||||
mergeSets(request.relays, pending.relays);
|
||||
wasMerged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if there are no requests. or pending failed to merge create new request
|
||||
if (!wasMerged) {
|
||||
const subject = new Subject<NostrEvent>();
|
||||
subject.subscribe(pending.subject);
|
||||
requests.push({ query: pending.query, subject, relays: pending.relays });
|
||||
}
|
||||
}
|
||||
|
||||
for (const request of requests) {
|
||||
const r = new NostrRequest(Array.from(request.relays));
|
||||
r.onEvent.subscribe(request.subject);
|
||||
r.start(request.query);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
import { Button, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { useInterval } from "react-use";
|
||||
import { Relay } from "../services/relays";
|
||||
import relayPool from "../services/relays/relay-pool";
|
||||
import { DevModel } from "./dev-modal";
|
||||
|
||||
export const ConnectedRelays = () => {
|
||||
const [relays, setRelays] = useState<Relay[]>(relayPool.getRelays());
|
||||
const { onOpen, onClose, isOpen } = useDisclosure();
|
||||
|
||||
useInterval(() => {
|
||||
setRelays(relayPool.getRelays());
|
||||
@ -15,8 +17,11 @@ export const ConnectedRelays = () => {
|
||||
const disconnected = relays.filter((relay) => !relay.okay);
|
||||
|
||||
return (
|
||||
<Text textAlign="center">
|
||||
{connected.length}/{relays.length} of relays connected
|
||||
</Text>
|
||||
<>
|
||||
<Button textAlign="center" variant="link" onClick={onOpen}>
|
||||
{connected.length}/{relays.length} of relays connected
|
||||
</Button>
|
||||
{isOpen && <DevModel isOpen={isOpen} onClose={onClose} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
47
src/components/dev-modal.tsx
Normal file
47
src/components/dev-modal.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalProps,
|
||||
StatGroup,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
useForceUpdate,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAsync, useInterval } from "react-use";
|
||||
import db from "../services/db";
|
||||
|
||||
export const DevModel = (props: Omit<ModalProps, "children">) => {
|
||||
const update = useForceUpdate();
|
||||
useInterval(update, 1000 * 5);
|
||||
|
||||
const { value: eventsSeen } = useAsync(() => db.count("events-seen"), []);
|
||||
const { value: usersSeen } = useAsync(() => db.count("user-metadata"), []);
|
||||
|
||||
return (
|
||||
<Modal {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Stats</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<StatGroup>
|
||||
<Stat>
|
||||
<StatLabel>Events Seen</StatLabel>
|
||||
<StatNumber>{eventsSeen ?? "loading..."}</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Users Seen</StatLabel>
|
||||
<StatNumber>{usersSeen ?? "loading..."}</StatNumber>
|
||||
</Stat>
|
||||
</StatGroup>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -9,9 +9,9 @@ import identity from "../services/identity";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
|
||||
const { metadata, loading } = useUserMetadata(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
if (loading || !metadata) return <SkeletonText />;
|
||||
if (!metadata) return <SkeletonText />;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
@ -29,7 +29,7 @@ export type PostProps = {
|
||||
};
|
||||
export const Post = React.memo(({ event }: PostProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { metadata } = useUserMetadata(event.pubkey);
|
||||
const metadata = useUserMetadata(event.pubkey);
|
||||
|
||||
return (
|
||||
<Card padding="2" variant="outline">
|
||||
|
@ -3,10 +3,10 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
|
||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { isPTag, NostrEvent } from "../../types/nostr-event";
|
||||
|
||||
const CC = ({ pubkey }: { pubkey: string }) => {
|
||||
const { metadata } = useUserMetadata(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@ -19,15 +19,15 @@ const CC = ({ pubkey }: { pubkey: string }) => {
|
||||
};
|
||||
|
||||
export const PostCC = ({ event }: { event: NostrEvent }) => {
|
||||
const hasCC = event.tags.some((t) => t[0] === "p");
|
||||
const hasCC = event.tags.some(isPTag);
|
||||
if (!hasCC) return null;
|
||||
|
||||
return (
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
<span>Replying to: </span>
|
||||
{event.tags
|
||||
.filter((t) => t[0] === "p")
|
||||
.map((t) => t[1] && <CC pubkey={t[1]} />)
|
||||
.filter(isPTag)
|
||||
.map((t) => t[1] && <CC key={t[1]} pubkey={t[1]} />)
|
||||
.reduce((arr, el, i, original) => {
|
||||
if (i !== original.length - 1) {
|
||||
return arr.concat([el, ", "]);
|
||||
|
@ -12,7 +12,7 @@ export type ProfileButtonProps = {
|
||||
|
||||
export const ProfileButton = ({ to }: ProfileButtonProps) => {
|
||||
const pubkey = useSubject(identity.pubkey);
|
||||
const { loading, metadata } = useUserMetadata(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
return (
|
||||
<LinkBox
|
||||
|
@ -8,7 +8,7 @@ import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
|
||||
export const UserAvatarLink = React.memo(
|
||||
({ pubkey, ...props }: UserAvatarProps) => {
|
||||
const { metadata } = useUserMetadata(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const label = metadata
|
||||
? getUserDisplayName(metadata, pubkey)
|
||||
: "Loading...";
|
||||
|
@ -5,6 +5,7 @@ import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
|
||||
const cache: Record<string, Identicon> = {};
|
||||
function getIdenticon(pubkey: string) {
|
||||
if (pubkey.length < 15) return "";
|
||||
if (!cache[pubkey]) {
|
||||
cache[pubkey] = new Identicon(pubkey, { format: "svg" });
|
||||
}
|
||||
@ -14,17 +15,13 @@ function getIdenticon(pubkey: string) {
|
||||
export type UserAvatarProps = Omit<AvatarProps, "src"> & {
|
||||
pubkey: string;
|
||||
};
|
||||
export const UserAvatar = React.memo(
|
||||
({ pubkey, ...props }: UserAvatarProps) => {
|
||||
const { metadata } = useUserMetadata(pubkey);
|
||||
export const UserAvatar = React.memo(({ pubkey, ...props }: UserAvatarProps) => {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
const url = useMemo(() => {
|
||||
return (
|
||||
metadata?.picture ??
|
||||
`data:image/svg+xml;base64,${getIdenticon(pubkey).toString()}`
|
||||
);
|
||||
}, [metadata]);
|
||||
const url = useMemo(() => {
|
||||
return metadata?.picture ?? `data:image/svg+xml;base64,${getIdenticon(pubkey).toString()}`;
|
||||
}, [metadata]);
|
||||
|
||||
return <Avatar src={url} {...props} />;
|
||||
}
|
||||
);
|
||||
return <Avatar src={url} {...props} />;
|
||||
});
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
3
src/helpers/array.ts
Normal file
3
src/helpers/array.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function unique<T>(arr: T[]): T[] {
|
||||
return Array.from(new Set(arr));
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { isETag, isPTag, NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export function isReply(event: NostrEvent) {
|
||||
return !!event.tags.find((t) => t[0] === "e");
|
||||
return !!event.tags.find(isETag);
|
||||
}
|
||||
|
||||
export function isPost(event: NostrEvent) {
|
||||
@ -11,3 +11,39 @@ export function isPost(event: NostrEvent) {
|
||||
export function truncatedId(id: string) {
|
||||
return id.substring(0, 6) + "..." + id.substring(id.length - 6);
|
||||
}
|
||||
|
||||
export type EventReferences = ReturnType<typeof getReferences>;
|
||||
export function getReferences(event: NostrEvent) {
|
||||
const eTags = event.tags.filter(isETag);
|
||||
const pTags = event.tags.filter(isPTag);
|
||||
|
||||
const events = eTags.map((t) => t[1]);
|
||||
const pubkeys = pTags.map((t) => t[1]);
|
||||
|
||||
let replyId = eTags.find((t) => t[3] === "reply")?.[1];
|
||||
let rootId = eTags.find((t) => t[3] === "root")?.[1];
|
||||
|
||||
if (rootId && !replyId) {
|
||||
// a direct reply dose not need a "reply" reference
|
||||
// https://github.com/nostr-protocol/nips/blob/master/10.md
|
||||
replyId = rootId;
|
||||
}
|
||||
|
||||
// legacy behavior
|
||||
// https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated
|
||||
if (!rootId && !replyId && eTags.length >= 1) {
|
||||
console.warn(`Using legacy threading behavior for ${event.id}`, event);
|
||||
|
||||
// first tag is the root
|
||||
rootId = eTags[0][1];
|
||||
// last tag is reply
|
||||
replyId = eTags[eTags.length - 1][1] ?? rootId;
|
||||
}
|
||||
|
||||
return {
|
||||
pubkeys,
|
||||
events,
|
||||
rootId,
|
||||
replyId,
|
||||
};
|
||||
}
|
||||
|
47
src/helpers/thread.ts
Normal file
47
src/helpers/thread.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { EventReferences, getReferences } from "./nostr-event";
|
||||
|
||||
export type LinkedEvent = {
|
||||
event: NostrEvent;
|
||||
root?: LinkedEvent;
|
||||
reply?: LinkedEvent;
|
||||
refs: EventReferences;
|
||||
children: LinkedEvent[];
|
||||
};
|
||||
|
||||
export function linkEvents(events: NostrEvent[], rootId: string) {
|
||||
const idToChildren: Record<string, NostrEvent[]> = {};
|
||||
|
||||
const replies = new Map<string, LinkedEvent>();
|
||||
for (const event of events) {
|
||||
const refs = getReferences(event);
|
||||
|
||||
if (refs.replyId) {
|
||||
idToChildren[refs.replyId] = idToChildren[refs.replyId] || [];
|
||||
idToChildren[refs.replyId].push(event);
|
||||
}
|
||||
if (refs.rootId) {
|
||||
idToChildren[refs.rootId] = idToChildren[refs.rootId] || [];
|
||||
idToChildren[refs.rootId].push(event);
|
||||
}
|
||||
|
||||
replies.set(event.id, {
|
||||
event,
|
||||
refs,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, reply] of replies) {
|
||||
reply.root = reply.refs.rootId ? replies.get(reply.refs.rootId) : undefined;
|
||||
|
||||
reply.reply = reply.refs.replyId
|
||||
? replies.get(reply.refs.replyId)
|
||||
: undefined;
|
||||
|
||||
reply.children =
|
||||
idToChildren[id]?.map((e) => replies.get(e.id) as LinkedEvent) ?? [];
|
||||
}
|
||||
|
||||
return replies;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Subscription } from "../services/subscriptions";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export function useEventDir(
|
||||
subscription: Subscription,
|
||||
subscription: NostrSubscription,
|
||||
filter?: (event: NostrEvent) => boolean
|
||||
) {
|
||||
const [events, setEvents] = useState<Record<string, NostrEvent>>({});
|
||||
|
@ -1,25 +1,26 @@
|
||||
import { useRef } from "react";
|
||||
import { useDeepCompareEffect, useMount, useUnmount } from "react-use";
|
||||
import { Subscription } from "../services/subscriptions";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
|
||||
/** @deprecated */
|
||||
export function useSubscription(
|
||||
urls: string[],
|
||||
query: NostrQuery,
|
||||
name?: string
|
||||
) {
|
||||
const sub = useRef<Subscription | null>(null);
|
||||
sub.current = sub.current || new Subscription(urls, query, name);
|
||||
const sub = useRef<NostrSubscription | null>(null);
|
||||
sub.current = sub.current || new NostrSubscription(urls, query, name);
|
||||
|
||||
useMount(() => {
|
||||
if (sub.current) sub.current.open();
|
||||
});
|
||||
useDeepCompareEffect(() => {
|
||||
if (sub.current) sub.current.setQuery(query);
|
||||
}, [query]);
|
||||
useUnmount(() => {
|
||||
if (sub.current) sub.current.close();
|
||||
if (sub.current) {
|
||||
sub.current.close();
|
||||
sub.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
return sub.current as Subscription;
|
||||
return sub.current as NostrSubscription;
|
||||
}
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import settings from "../services/settings";
|
||||
import userContacts from "../services/user-contacts";
|
||||
import userContactsService from "../services/user-contacts";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export function useUserContacts(pubkey: string) {
|
||||
const relays = useSubject(settings.relays);
|
||||
const observable = useMemo(
|
||||
() => userContacts.requestUserContacts(pubkey, relays),
|
||||
[pubkey, relays]
|
||||
);
|
||||
const observable = useMemo(() => userContactsService.requestContacts(pubkey, relays), [pubkey, relays]);
|
||||
const contacts = useSubject(observable) ?? undefined;
|
||||
|
||||
return {
|
||||
|
@ -1,16 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import userMetadata from "../services/user-metadata";
|
||||
import useSubject from "./use-subject";
|
||||
import { useObservable } from "react-use";
|
||||
import userMetadataService from "../services/user-metadata";
|
||||
|
||||
export function useUserMetadata(pubkey: string, stayOpen = false) {
|
||||
const observable = useMemo(
|
||||
() => userMetadata.requestUserMetadata(pubkey, stayOpen),
|
||||
[pubkey]
|
||||
);
|
||||
const metadata = useSubject(observable) ?? undefined;
|
||||
export function useUserMetadata(pubkey: string, relays?: string[], alwaysRequest = false) {
|
||||
const observable = useMemo(() => userMetadataService.requestMetadata(pubkey, relays, alwaysRequest), [pubkey]);
|
||||
const metadata = useObservable(observable) ?? undefined;
|
||||
|
||||
return {
|
||||
loading: !metadata,
|
||||
metadata,
|
||||
};
|
||||
return metadata;
|
||||
}
|
||||
|
@ -5,34 +5,42 @@ import { CustomSchema } from "./schema";
|
||||
|
||||
type MigrationFunction = (
|
||||
database: IDBPDatabase<CustomSchema>,
|
||||
transaction: IDBPTransaction<
|
||||
CustomSchema,
|
||||
StoreNames<CustomSchema>[],
|
||||
"versionchange"
|
||||
>,
|
||||
transaction: IDBPTransaction<CustomSchema, StoreNames<CustomSchema>[], "versionchange">,
|
||||
event: IDBVersionChangeEvent
|
||||
) => void;
|
||||
|
||||
const MIGRATIONS: MigrationFunction[] = [
|
||||
// 0 -> 1
|
||||
function (db, transaction, event) {
|
||||
db.createObjectStore("user-metadata", {
|
||||
const metadata = db.createObjectStore("user-metadata", {
|
||||
keyPath: "pubkey",
|
||||
});
|
||||
|
||||
const eventsSeen = db.createObjectStore("events-seen", { keyPath: "id" });
|
||||
eventsSeen.createIndex("lastSeen", "lastSeen");
|
||||
|
||||
db.createObjectStore("user-contacts", { autoIncrement: false });
|
||||
const contacts = db.createObjectStore("user-contacts", {
|
||||
keyPath: "pubkey",
|
||||
});
|
||||
// contacts.createIndex("created_at", "created_at");
|
||||
|
||||
const events = db.createObjectStore("text-events", {
|
||||
keyPath: "id",
|
||||
autoIncrement: false,
|
||||
});
|
||||
events.createIndex("pubkey", "pubkey");
|
||||
events.createIndex("created_at", "created_at");
|
||||
events.createIndex("kind", "kind");
|
||||
|
||||
// setup data
|
||||
const settings = db.createObjectStore("settings");
|
||||
settings.put(
|
||||
[
|
||||
"wss://nostr.rdfriedl.com",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.info",
|
||||
"wss://nostr-pub.wellorder.net",
|
||||
"wss://nostr.zebedee.cloud",
|
||||
"wss://satstacker.cloud",
|
||||
"wss://brb.io",
|
||||
],
|
||||
"relays"
|
||||
);
|
||||
|
@ -9,14 +9,20 @@ export interface CustomSchema extends DBSchema {
|
||||
"user-contacts": {
|
||||
key: string;
|
||||
value: {
|
||||
pubkey: string;
|
||||
relays: Record<string, { read: boolean; write: boolean }>;
|
||||
contacts: {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
}[];
|
||||
updated: Date;
|
||||
created_at: number;
|
||||
};
|
||||
};
|
||||
"text-events": {
|
||||
key: string;
|
||||
value: NostrEvent;
|
||||
indexes: { created_at: number; pubkey: string; kind: number };
|
||||
};
|
||||
"events-seen": {
|
||||
key: string;
|
||||
value: {
|
||||
|
56
src/services/events.ts
Normal file
56
src/services/events.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { debounce } from "../helpers/function";
|
||||
import { LinkedEvent } from "../helpers/thread";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import db from "./db";
|
||||
import { NostrRequest } from "../classes/nostr-request";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
|
||||
function requestEvent(id: string, relays: string[], alwaysRequest = false) {
|
||||
const subject = new Subject<NostrEvent>();
|
||||
|
||||
db.get("text-events", id).then((event) => {
|
||||
if (event) {
|
||||
subject.next(event);
|
||||
if (!alwaysRequest) return;
|
||||
}
|
||||
|
||||
const request = new NostrRequest(relays);
|
||||
request.start({ ids: [id] });
|
||||
request.onEvent.subscribe((event) => {
|
||||
if (event) {
|
||||
subject.next(event);
|
||||
db.put("text-events", event);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
function loadThread(rootId: string, relays: string[], alwaysRequest = false) {
|
||||
const root = requestEvent(rootId, relays, alwaysRequest);
|
||||
const replies = new Subject<LinkedEvent>();
|
||||
const events = new Map<string, NostrEvent>();
|
||||
const sub = new NostrSubscription(relays, { "#e": [rootId], kinds: [1] });
|
||||
sub.open();
|
||||
|
||||
const updateReplies = debounce(() => {
|
||||
// const linked = linkEvents([root, ...events], rootId);
|
||||
// replies.next(linked.get(rootId) as LinkedEvent);
|
||||
});
|
||||
|
||||
sub.onEvent.subscribe((event) => {
|
||||
events.set(event.id, event);
|
||||
});
|
||||
|
||||
return {
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
const eventsService = {
|
||||
requestEvent,
|
||||
};
|
||||
|
||||
export default eventsService;
|
@ -1,5 +1,4 @@
|
||||
import { Subject } from "rxjs";
|
||||
import settings from "../settings";
|
||||
import { Relay } from "./relay";
|
||||
|
||||
export class RelayPool {
|
||||
|
@ -1,90 +1,106 @@
|
||||
import { BehaviorSubject, distinctUntilKeyChanged } from "rxjs";
|
||||
import { convertTimestampToDate } from "../helpers/date";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { PubkeyRequestList } from "../classes/pubkey-request-list";
|
||||
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
import { safeParse } from "../helpers/json";
|
||||
import { unique } from "../helpers/array";
|
||||
import db from "./db";
|
||||
import { Request } from "./request";
|
||||
import settings from "./settings";
|
||||
|
||||
export type Contacts = {
|
||||
const subscription = new NostrSubscription([], undefined, "user-contacts");
|
||||
const userSubjects = new PubkeySubjectCache<UserContacts>();
|
||||
const pendingRequests = new PubkeyRequestList();
|
||||
|
||||
export type UserContacts = {
|
||||
pubkey: string;
|
||||
relays: Record<string, { read: boolean; write: boolean }>;
|
||||
contacts: {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
}[];
|
||||
updated: Date;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export class UserContactsService {
|
||||
subjects = new Map<string, BehaviorSubject<Contacts | null>>();
|
||||
requests = new Map<string, Request>();
|
||||
function parseContacts(event: NostrEvent): UserContacts {
|
||||
const keys = event.tags
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => ({ pubkey: tag[1] as string, relay: tag[2] }));
|
||||
|
||||
private getSubject(pubkey: string) {
|
||||
if (!this.subjects.has(pubkey)) {
|
||||
const subject = new BehaviorSubject<Contacts | null>(null);
|
||||
this.subjects.set(pubkey, subject);
|
||||
const relays = safeParse(event.content, {}) as UserContacts["relays"];
|
||||
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
relays,
|
||||
contacts: keys,
|
||||
created_at: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function requestContacts(pubkey: string, relays: string[] = [], alwaysRequest = false) {
|
||||
let subject = userSubjects.getSubject(pubkey);
|
||||
|
||||
db.get("user-contacts", pubkey).then((cached) => {
|
||||
if (cached) subject.next(cached);
|
||||
|
||||
if (alwaysRequest || !cached) {
|
||||
pendingRequests.addPubkey(pubkey, relays);
|
||||
}
|
||||
});
|
||||
|
||||
return this.subjects.get(pubkey) as BehaviorSubject<Contacts | null>;
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
requestUserContacts(pubkey: string, relays: string[]) {
|
||||
const subject = this.getSubject(pubkey);
|
||||
function flushRequests() {
|
||||
if (!pendingRequests.needsFlush) return;
|
||||
const { pubkeys, relays } = pendingRequests.flush();
|
||||
if (pubkeys.length === 0) return;
|
||||
|
||||
if (!subject.getValue()) {
|
||||
db.get("user-contacts", pubkey).then((contacts) => {
|
||||
if (contacts) {
|
||||
// reply with cache
|
||||
subject.next(contacts);
|
||||
} else {
|
||||
// there is no cache so request it from the relays
|
||||
if (!this.requests.has(pubkey)) {
|
||||
const request = new Request(relays);
|
||||
this.requests.set(pubkey, request);
|
||||
request.start({ authors: [pubkey], kinds: [3] });
|
||||
const systemRelays = settings.relays.getValue();
|
||||
const query: NostrQuery = { authors: pubkeys, kinds: [3] };
|
||||
|
||||
request.onEvent
|
||||
.pipe(
|
||||
// filter out duplicate events
|
||||
distinctUntilKeyChanged("id"),
|
||||
// filter out older events
|
||||
distinctUntilKeyChanged(
|
||||
"created_at",
|
||||
(prev, curr) => curr < prev
|
||||
)
|
||||
)
|
||||
.subscribe(async (event) => {
|
||||
const keys = event.tags
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => ({ pubkey: tag[1] as string, relay: tag[2] }));
|
||||
|
||||
const relays = safeParse(
|
||||
event.content,
|
||||
{}
|
||||
) as Contacts["relays"];
|
||||
|
||||
const contacts = {
|
||||
relays,
|
||||
contacts: keys,
|
||||
updated: convertTimestampToDate(event.created_at),
|
||||
};
|
||||
|
||||
db.put("user-contacts", contacts, event.pubkey);
|
||||
|
||||
subject.next(contacts);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return subject;
|
||||
subscription.setRelays(relays.length > 0 ? unique([...systemRelays, ...relays]) : systemRelays);
|
||||
subscription.update(query);
|
||||
if (subscription.state !== NostrSubscription.OPEN) {
|
||||
subscription.open();
|
||||
}
|
||||
}
|
||||
|
||||
const userContacts = new UserContactsService();
|
||||
function pruneMemoryCache() {
|
||||
const keys = userSubjects.prune();
|
||||
for (const [key] of keys) {
|
||||
pendingRequests.removePubkey(key);
|
||||
}
|
||||
}
|
||||
|
||||
subscription.onEvent.subscribe((event) => {
|
||||
if (userSubjects.hasSubject(event.pubkey)) {
|
||||
const subject = userSubjects.getSubject(event.pubkey);
|
||||
const latest = subject.getValue();
|
||||
if (!latest || event.created_at > latest.created_at) {
|
||||
const parsed = parseContacts(event);
|
||||
subject.next(parsed);
|
||||
db.put("user-contacts", parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the pending request for this pubkey
|
||||
if (pendingRequests.hasPubkey(event.pubkey)) {
|
||||
pendingRequests.removePubkey(event.pubkey);
|
||||
}
|
||||
});
|
||||
|
||||
// flush requests every second
|
||||
setInterval(() => {
|
||||
flushRequests();
|
||||
pruneMemoryCache();
|
||||
}, 1000);
|
||||
|
||||
const userContactsService = { requestContacts, flushRequests };
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userContacts = userContacts;
|
||||
window.userContacts = userContactsService;
|
||||
}
|
||||
|
||||
export default userContacts;
|
||||
export default userContactsService;
|
||||
|
@ -1,114 +1,96 @@
|
||||
import { of, BehaviorSubject, distinctUntilKeyChanged } from "rxjs";
|
||||
import { debounce } from "../helpers/function";
|
||||
import { Kind0ParsedContent } from "../types/nostr-event";
|
||||
import { BehaviorSubject, filter, map } from "rxjs";
|
||||
import db from "./db";
|
||||
import settings from "./settings";
|
||||
import { Subscription } from "./subscriptions";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
import { PubkeyRequestList } from "../classes/pubkey-request-list";
|
||||
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
|
||||
import { Kind0ParsedContent, NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { unique } from "../helpers/array";
|
||||
|
||||
class UserMetadataService {
|
||||
requests = new Set<string>();
|
||||
subjects = new Map<string, BehaviorSubject<Kind0ParsedContent | null>>();
|
||||
subscription: Subscription;
|
||||
const subscription = new NostrSubscription([], undefined, "user-metadata");
|
||||
const userMetadataSubjects = new PubkeySubjectCache<NostrEvent>();
|
||||
const pendingRequests = new PubkeyRequestList();
|
||||
|
||||
constructor(relayUrls: string[] = []) {
|
||||
this.subscription = new Subscription(relayUrls, undefined, "user-metadata");
|
||||
function requestMetadataEvent(
|
||||
pubkey: string,
|
||||
relays: string[],
|
||||
alwaysRequest = false
|
||||
): BehaviorSubject<NostrEvent | null> {
|
||||
let subject = userMetadataSubjects.getSubject(pubkey);
|
||||
|
||||
this.subscription.onEvent.subscribe(async (event) => {
|
||||
try {
|
||||
const current = await db.get("user-metadata", event.pubkey);
|
||||
if (current && current.created_at > event.created_at) {
|
||||
// ignore this event because its older
|
||||
return;
|
||||
}
|
||||
db.put("user-metadata", event);
|
||||
db.get("user-metadata", pubkey).then((cached) => {
|
||||
if (cached) subject.next(cached);
|
||||
|
||||
const metadata = JSON.parse(event.content);
|
||||
this.getUserSubject(event.pubkey).next(metadata);
|
||||
|
||||
// remove the pubkey from requests since is have the data
|
||||
this.requests.delete(event.pubkey);
|
||||
this.update();
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
this.pruneRequests();
|
||||
}, 1000 * 10);
|
||||
}
|
||||
|
||||
private getUserSubject(pubkey: string) {
|
||||
if (!this.subjects.has(pubkey)) {
|
||||
this.subjects.set(
|
||||
pubkey,
|
||||
new BehaviorSubject<Kind0ParsedContent | null>(null)
|
||||
);
|
||||
if (alwaysRequest || !cached) {
|
||||
pendingRequests.addPubkey(pubkey, relays);
|
||||
}
|
||||
return this.subjects.get(
|
||||
pubkey
|
||||
) as BehaviorSubject<Kind0ParsedContent | null>;
|
||||
}
|
||||
});
|
||||
|
||||
requestUserMetadata(pubkey: string, stayOpen = false) {
|
||||
const subject = this.getUserSubject(pubkey);
|
||||
return subject;
|
||||
}
|
||||
|
||||
const request = () => {
|
||||
if (!this.requests.has(pubkey)) {
|
||||
this.requests.add(pubkey);
|
||||
this.update();
|
||||
}
|
||||
};
|
||||
function isEvent(e: NostrEvent | null): e is NostrEvent {
|
||||
return !!e;
|
||||
}
|
||||
function parseMetadata(event: NostrEvent): Kind0ParsedContent | undefined {
|
||||
try {
|
||||
return JSON.parse(event.content);
|
||||
} catch (e) {}
|
||||
}
|
||||
function requestMetadata(pubkey: string, relays: string[] = [], alwaysRequest = false) {
|
||||
return requestMetadataEvent(pubkey, relays, alwaysRequest).pipe(filter(isEvent), map(parseMetadata));
|
||||
}
|
||||
|
||||
if (!subject.getValue()) {
|
||||
db.get("user-metadata", pubkey).then((cachedEvent) => {
|
||||
if (cachedEvent) {
|
||||
try {
|
||||
subject.next(JSON.parse(cachedEvent.content));
|
||||
} catch (e) {
|
||||
request();
|
||||
}
|
||||
} else request();
|
||||
});
|
||||
}
|
||||
function flushRequests() {
|
||||
if (!pendingRequests.needsFlush) return;
|
||||
const { pubkeys, relays } = pendingRequests.flush();
|
||||
if (pubkeys.length === 0) return;
|
||||
|
||||
if (stayOpen) request();
|
||||
const systemRelays = settings.relays.getValue();
|
||||
const query: NostrQuery = { authors: pubkeys, kinds: [0] };
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
private updateSubscription() {
|
||||
const pubkeys = Array.from(this.requests.keys());
|
||||
|
||||
if (pubkeys.length === 0) {
|
||||
this.subscription.close();
|
||||
} else {
|
||||
this.subscription.setQuery({ authors: pubkeys, kinds: [0] });
|
||||
if (this.subscription.state === Subscription.CLOSED) {
|
||||
this.subscription.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
update = debounce(this.updateSubscription.bind(this), 500);
|
||||
|
||||
pruneRequests() {
|
||||
let removed = false;
|
||||
const subjects = Array.from(this.subjects.entries());
|
||||
for (const [pubkey, subject] of subjects) {
|
||||
// if there is a request for the pubkey and no one is observing it. close the request
|
||||
if (this.requests.has(pubkey) && !subject.observed) {
|
||||
this.requests.delete(pubkey);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed) this.update();
|
||||
subscription.setRelays(relays.length > 0 ? unique([...systemRelays, ...relays]) : systemRelays);
|
||||
subscription.update(query);
|
||||
if (subscription.state !== NostrSubscription.OPEN) {
|
||||
subscription.open();
|
||||
}
|
||||
}
|
||||
|
||||
const userMetadata = new UserMetadataService(settings.relays.getValue());
|
||||
function pruneMemoryCache() {
|
||||
const keys = userMetadataSubjects.prune();
|
||||
for (const [key] of keys) {
|
||||
pendingRequests.removePubkey(key);
|
||||
}
|
||||
}
|
||||
|
||||
subscription.onEvent.subscribe((event) => {
|
||||
if (userMetadataSubjects.hasSubject(event.pubkey)) {
|
||||
const subject = userMetadataSubjects.getSubject(event.pubkey);
|
||||
const latest = subject.getValue();
|
||||
if (!latest || event.created_at > latest.created_at) {
|
||||
subject.next(event);
|
||||
db.put("user-metadata", event);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the pending request for this pubkey
|
||||
if (pendingRequests.hasPubkey(event.pubkey)) {
|
||||
pendingRequests.removePubkey(event.pubkey);
|
||||
}
|
||||
});
|
||||
|
||||
// flush requests every second
|
||||
setInterval(() => {
|
||||
flushRequests();
|
||||
pruneMemoryCache();
|
||||
}, 1000);
|
||||
|
||||
const userMetadataService = { requestMetadata, requestMetadataEvent, flushRequests };
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userMetadata = userMetadata;
|
||||
window.userMetadata = userMetadataService;
|
||||
}
|
||||
|
||||
export default userMetadata;
|
||||
export default userMetadataService;
|
||||
|
@ -1,9 +1,22 @@
|
||||
export type ETag =
|
||||
| ["e", string]
|
||||
| ["e", string, string]
|
||||
| ["e", string, string, string];
|
||||
export type PTag = ["p", string] | ["p", string, string];
|
||||
export type Tag =
|
||||
| [string]
|
||||
| [string, string]
|
||||
| [string, string, string]
|
||||
| [string, string, string, string]
|
||||
| ETag
|
||||
| PTag;
|
||||
|
||||
export type NostrEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: ([string] | [string, string] | [string, string, string])[];
|
||||
tags: Tag[];
|
||||
content: string;
|
||||
sig: string;
|
||||
};
|
||||
@ -19,3 +32,10 @@ export type Kind0ParsedContent = {
|
||||
about?: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export function isETag(tag: Tag): tag is ETag {
|
||||
return tag[0] === "e";
|
||||
}
|
||||
export function isPTag(tag: Tag): tag is PTag {
|
||||
return tag[0] === "e";
|
||||
}
|
||||
|
@ -7,12 +7,12 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import useSubject from "../hooks/use-subject";
|
||||
import settings from "../services/settings";
|
||||
import { useSubscription } from "../hooks/use-subscription";
|
||||
import { Page } from "../components/page";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { normalizeToHex } from "../helpers/nip-19";
|
||||
import { Post } from "../components/post";
|
||||
import { useEventDir } from "../hooks/use-event-dir";
|
||||
import eventsService from "../services/events";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const EventPage = () => {
|
||||
const params = useParams();
|
||||
@ -39,6 +39,13 @@ export const EventPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function useEvent(id: string, relays: string[]) {
|
||||
const sub = useMemo(() => eventsService.requestEvent(id, relays), [id]);
|
||||
const event = useSubject(sub);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
export type EventViewProps = {
|
||||
/** id of event in hex format */
|
||||
eventId: string;
|
||||
@ -47,22 +54,21 @@ export type EventViewProps = {
|
||||
export const EventView = ({ eventId }: EventViewProps) => {
|
||||
const relays = useSubject(settings.relays);
|
||||
|
||||
const eventSub = useSubscription(relays, { ids: [eventId] });
|
||||
const event = useSubject(eventSub.onEvent);
|
||||
const event = useEvent(eventId, relays);
|
||||
|
||||
const replySub = useSubscription(relays, { "#e": [eventId] });
|
||||
const { events } = useEventDir(replySub);
|
||||
// const replySub = useSubscription(relays, { "#e": [eventId], kinds: [1] });
|
||||
// const { events } = useEventDir(replySub);
|
||||
|
||||
const timeline = Object.values(events).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
// const timeline = Object.values(events).sort(
|
||||
// (a, b) => b.created_at - a.created_at
|
||||
// );
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" flexGrow="1" overflow="auto">
|
||||
{event && <Post event={event} />}
|
||||
{timeline.map((event) => (
|
||||
{/* {timeline.map((event) => (
|
||||
<Post key={event.id} event={event} />
|
||||
))}
|
||||
))} */}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
@ -13,10 +13,10 @@ import moment from "moment/moment";
|
||||
import settings from "../../services/settings";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useEventDir } from "../../hooks/use-event-dir";
|
||||
import { Subscription } from "../../services/subscriptions";
|
||||
import { NostrSubscription } from "../../classes/nostr-subscription";
|
||||
import { isPost, isReply } from "../../helpers/nostr-event";
|
||||
|
||||
const PostsTimeline = ({ sub }: { sub: Subscription }) => {
|
||||
const PostsTimeline = ({ sub }: { sub: NostrSubscription }) => {
|
||||
const { events } = useEventDir(sub, isPost);
|
||||
|
||||
const timeline = Object.values(events).sort(
|
||||
@ -38,7 +38,7 @@ const PostsTimeline = ({ sub }: { sub: Subscription }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RepliesTimeline = ({ sub }: { sub: Subscription }) => {
|
||||
const RepliesTimeline = ({ sub }: { sub: NostrSubscription }) => {
|
||||
const { events } = useEventDir(sub, isReply);
|
||||
|
||||
const timeline = Object.values(events).sort(
|
||||
|
@ -9,7 +9,7 @@ import { useSubscription } from "../../hooks/use-subscription";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import identity from "../../services/identity";
|
||||
import settings from "../../services/settings";
|
||||
import userContacts from "../../services/user-contacts";
|
||||
import userContactsService from "../../services/user-contacts";
|
||||
|
||||
function useExtendedContacts(pubkey: string) {
|
||||
const relays = useSubject(settings.relays);
|
||||
@ -20,7 +20,7 @@ function useExtendedContacts(pubkey: string) {
|
||||
if (contacts) {
|
||||
const following = contacts.contacts.map((c) => c.pubkey);
|
||||
const subscriptions = contacts.contacts.map((contact) =>
|
||||
userContacts.requestUserContacts(contact.pubkey, relays)
|
||||
userContactsService.requestContacts(contact.pubkey, relays)
|
||||
);
|
||||
|
||||
const rxSub = from(subscriptions)
|
||||
@ -28,9 +28,7 @@ function useExtendedContacts(pubkey: string) {
|
||||
.subscribe((contacts) => {
|
||||
if (contacts) {
|
||||
setExtendedContacts((value) => {
|
||||
const more = contacts.contacts
|
||||
.map((c) => c.pubkey)
|
||||
.filter((key) => !following.includes(key));
|
||||
const more = contacts.contacts.map((c) => c.pubkey).filter((key) => !following.includes(key));
|
||||
return Array.from(new Set([...value, ...more]));
|
||||
});
|
||||
}
|
||||
@ -63,9 +61,7 @@ export const DiscoverTab = () => {
|
||||
);
|
||||
|
||||
const { events } = useEventDir(sub);
|
||||
const timeline = Object.values(events).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
const timeline = Object.values(events).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return (
|
||||
<Flex direction="column" overflow="auto" gap="2">
|
||||
|
@ -75,7 +75,7 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
|
||||
|
||||
export const ProfileEditView = () => {
|
||||
const pubkey = useSubject(identity.pubkey);
|
||||
const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
|
||||
const defaultValues = useMemo<FormData>(
|
||||
() => ({
|
||||
@ -87,7 +87,7 @@ export const ProfileEditView = () => {
|
||||
[metadata]
|
||||
);
|
||||
|
||||
if (loadingMetadata) return <SkeletonText />;
|
||||
if (!metadata) return <SkeletonText />;
|
||||
|
||||
const handleSubmit = (data: FormData) => {};
|
||||
|
||||
|
@ -2,19 +2,13 @@ import { useMemo } from "react";
|
||||
import { Flex, SkeletonText, Text } from "@chakra-ui/react";
|
||||
import settings from "../../services/settings";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import userContacts from "../../services/user-contacts";
|
||||
import userContactsService from "../../services/user-contacts";
|
||||
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||
import moment from "moment";
|
||||
|
||||
export const UserFollowingTab = ({ pubkey }: { pubkey: string }) => {
|
||||
const relays = useSubject(settings.relays);
|
||||
|
||||
const sub = useMemo(
|
||||
() => userContacts.requestUserContacts(pubkey, relays),
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
const contacts = useSubject(sub);
|
||||
const observable = useMemo(() => userContactsService.requestContacts(pubkey, [], true), [pubkey]);
|
||||
const contacts = useSubject(observable);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
@ -22,13 +16,10 @@ export const UserFollowingTab = ({ pubkey }: { pubkey: string }) => {
|
||||
<>
|
||||
<Flex flexWrap="wrap" gap="2">
|
||||
{contacts.contacts.map((contact, i) => (
|
||||
<UserAvatarLink
|
||||
key={contact.pubkey + i}
|
||||
pubkey={contact.pubkey}
|
||||
/>
|
||||
<UserAvatarLink key={contact.pubkey + i} pubkey={contact.pubkey} />
|
||||
))}
|
||||
</Flex>
|
||||
<Text>{`Updated ${moment(contacts?.updated).fromNow()}`}</Text>
|
||||
<Text>{`Updated ${moment(contacts?.created_at).fromNow()}`}</Text>
|
||||
</>
|
||||
) : (
|
||||
<SkeletonText />
|
||||
|
@ -22,7 +22,6 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
import { UserRelaysTab } from "./relays";
|
||||
import { UserFollowingTab } from "./following";
|
||||
// import { UserRepliesTab } from "./replies";
|
||||
import { normalizeToHex } from "../../helpers/nip-19";
|
||||
import { Page } from "../../components/page";
|
||||
import { UserProfileMenu } from "./user-profile-menu";
|
||||
@ -37,9 +36,7 @@ export const UserPage = () => {
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Invalid pubkey</AlertTitle>
|
||||
<AlertDescription>
|
||||
"{params.pubkey}" dose not look like a valid pubkey
|
||||
</AlertDescription>
|
||||
<AlertDescription>"{params.pubkey}" dose not look like a valid pubkey</AlertDescription>
|
||||
</Alert>
|
||||
</Page>
|
||||
);
|
||||
@ -59,37 +56,24 @@ export type UserViewProps = {
|
||||
export const UserView = ({ pubkey }: UserViewProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true);
|
||||
const metadata = useUserMetadata(pubkey, [], true);
|
||||
const label = metadata && getUserDisplayName(metadata, pubkey);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
gap="2"
|
||||
overflow="hidden"
|
||||
height="100%"
|
||||
>
|
||||
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" height="100%">
|
||||
<Flex gap="4" padding="2">
|
||||
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} />
|
||||
<Flex direction="column" gap={isMobile ? 0 : 2}>
|
||||
<Heading size={isMobile ? "md" : "lg"}>{label}</Heading>
|
||||
{loadingMetadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
|
||||
{!metadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
|
||||
</Flex>
|
||||
<Box ml="auto">
|
||||
<UserProfileMenu pubkey={pubkey} />
|
||||
</Box>
|
||||
</Flex>
|
||||
<Tabs
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
flexGrow="1"
|
||||
overflow="hidden"
|
||||
isLazy
|
||||
>
|
||||
<Tabs display="flex" flexDirection="column" flexGrow="1" overflow="hidden" isLazy>
|
||||
<TabList>
|
||||
<Tab>Notes</Tab>
|
||||
{/* <Tab>Replies</Tab> */}
|
||||
<Tab>Following</Tab>
|
||||
<Tab>Relays</Tab>
|
||||
</TabList>
|
||||
@ -98,9 +82,6 @@ export const UserView = ({ pubkey }: UserViewProps) => {
|
||||
<TabPanel pr={0} pl={0}>
|
||||
<UserPostsTab pubkey={pubkey} />
|
||||
</TabPanel>
|
||||
{/* <TabPanel pr={0} pl={0}>
|
||||
<UserRepliesTab pubkey={pubkey} />
|
||||
</TabPanel> */}
|
||||
<TabPanel>
|
||||
<UserFollowingTab pubkey={pubkey} />
|
||||
</TabPanel>
|
||||
|
@ -1,26 +1,13 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Button,
|
||||
SkeletonText,
|
||||
} from "@chakra-ui/react";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td, TableContainer, Button, SkeletonText } from "@chakra-ui/react";
|
||||
import settings from "../../services/settings";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import userContacts from "../../services/user-contacts";
|
||||
import userContactsService from "../../services/user-contacts";
|
||||
|
||||
export const UserRelaysTab = ({ pubkey }: { pubkey: string }) => {
|
||||
const relays = useSubject(settings.relays);
|
||||
|
||||
const sub = useMemo(
|
||||
() => userContacts.requestUserContacts(pubkey, relays),
|
||||
[pubkey]
|
||||
);
|
||||
const sub = useMemo(() => userContactsService.requestContacts(pubkey, relays), [pubkey]);
|
||||
|
||||
const contacts = useSubject(sub);
|
||||
|
||||
@ -49,10 +36,7 @@ export const UserRelaysTab = ({ pubkey }: { pubkey: string }) => {
|
||||
<Tr key={relay}>
|
||||
<Td>{relay}</Td>
|
||||
<Td>
|
||||
<Button
|
||||
onClick={() => addRelay(relay)}
|
||||
isDisabled={relays.includes(relay)}
|
||||
>
|
||||
<Button onClick={() => addRelay(relay)} isDisabled={relays.includes(relay)}>
|
||||
Add Relay
|
||||
</Button>
|
||||
</Td>
|
||||
|
Loading…
x
Reference in New Issue
Block a user