rework metadata and contacts service

This commit is contained in:
hzrd149 2023-02-07 17:04:18 -06:00
parent 621338cffb
commit 0c25242c18
36 changed files with 734 additions and 354 deletions

View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

View File

@ -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

View File

@ -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]);
}

View File

@ -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;
}
}

View 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 };
}
}

View 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;
}
}

View 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);
}
}
}

View File

@ -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} />}
</>
);
};

View 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>
);
};

View File

@ -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

View File

@ -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">

View File

@ -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, ", "]);

View File

@ -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

View File

@ -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...";

View File

@ -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
View File

@ -0,0 +1,3 @@
export function unique<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}

View File

@ -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
View 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;
}

View File

@ -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>>({});

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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"
);

View File

@ -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
View 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;

View File

@ -1,5 +1,4 @@
import { Subject } from "rxjs";
import settings from "../settings";
import { Relay } from "./relay";
export class RelayPool {

View File

@ -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;

View File

@ -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;

View File

@ -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";
}

View File

@ -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>
);
};

View File

@ -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(

View File

@ -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">

View File

@ -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) => {};

View File

@ -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 />

View File

@ -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>

View File

@ -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>