Merge branch 'next' into master

This commit is contained in:
hzrd149 2024-04-12 15:24:12 -05:00 committed by GitHub
commit 6d9e8f1064
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
162 changed files with 3158 additions and 1693 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add date picker to notifictions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show individual zaps on notes

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for @snort/worker-relay as a cache relay

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add details tabs under thread post

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix null relay hints in DMs

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Make user avatars square

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Remove corsproxy.io as default service for CORS proxy

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add blossom media upload option

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Use Relay class from nostr-tools

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show notifications on launchpad

View File

@ -37,10 +37,13 @@
"@noble/hashes": "^1.3.2",
"@noble/secp256k1": "^1.7.0",
"@scure/base": "^1.1.6",
"@snort/worker-relay": "^1.0.10",
"@uiw/codemirror-theme-github": "^4.21.21",
"@uiw/react-codemirror": "^4.21.21",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"bech32": "^2.0.0",
"blossom-client-sdk": "^0.4.0",
"blossom-drive-sdk": "^0.1.1",
"blurhash": "^2.0.5",
"chart.js": "^4.4.1",
"cheerio": "^1.0.0-rc.12",
@ -63,7 +66,7 @@
"nanoid": "^5.0.4",
"ngeohash": "^0.6.3",
"nostr-idb": "^2.1.1",
"nostr-tools": "^2.1.3",
"nostr-tools": "^2.4.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
@ -104,8 +107,8 @@
"camelcase": "^8.0.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vite-plugin-pwa": "^0.17.4",
"vite": "^5.2.8",
"vite-plugin-pwa": "^0.19.8",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
},

View File

@ -73,6 +73,7 @@ import CacheRelayView from "./views/relays/cache";
import RelaySetView from "./views/relays/relay-set";
import AppRelays from "./views/relays/app";
import MailboxesView from "./views/relays/mailboxes";
import MediaServersView from "./views/relays/media-servers";
import NIP05RelaysView from "./views/relays/nip05";
import ContactListRelaysView from "./views/relays/contact-list";
import UserDMsTab from "./views/user/dms";
@ -90,6 +91,7 @@ import VideoDetailsView from "./views/videos/video";
import BookmarksView from "./views/bookmarks";
import LoginNostrAddressView from "./views/signin/address";
import LoginNostrAddressCreate from "./views/signin/address/create";
import DatabaseView from "./views/relays/cache/database";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@ -269,8 +271,15 @@ const router = createHashRouter([
children: [
{ path: "", element: <AppRelays /> },
{ path: "app", element: <AppRelays /> },
{ path: "cache", element: <CacheRelayView /> },
{
path: "cache",
children: [
{ path: "database", element: <DatabaseView /> },
{ path: "", element: <CacheRelayView /> },
],
},
{ path: "mailboxes", element: <MailboxesView /> },
{ path: "media-servers", element: <MediaServersView /> },
{ path: "nip05", element: <NIP05RelaysView /> },
{ path: "contacts", element: <ContactListRelaysView /> },
{ path: "sets", element: <BrowseRelaySetsView /> },

View File

@ -1,9 +1,8 @@
import dayjs from "dayjs";
import { Filter, NostrEvent } from "nostr-tools";
import { Filter, NostrEvent, Relay, Subscription } from "nostr-tools";
import _throttle from "lodash.throttle";
import debug, { Debugger } from "debug";
import NostrSubscription from "./nostr-subscription";
import EventStore from "./event-store";
import { getEventCoordinate } from "../helpers/nostr/event";
@ -15,20 +14,17 @@ const RELAY_REQUEST_BATCH_TIME = 500;
/** This class is ued to batch requests by kind to a single relay */
export default class BatchKindLoader {
private subscription: NostrSubscription;
private subscription: Subscription | null = null;
events = new EventStore();
relay: Relay;
private requestNext = new Set<string>();
private requested = new Map<string, Date>();
log: Debugger;
constructor(relay: string, log?: Debugger) {
this.subscription = new NostrSubscription(relay, undefined, `replaceable-event-loader`);
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
constructor(relay: Relay, log?: Debugger) {
this.relay = relay;
this.log = log || debug("RelayBatchLoader");
}
@ -105,12 +101,17 @@ export default class BatchKindLoader {
.map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`)
.join(", "),
);
this.subscription.setFilters(query);
if (this.subscription.state !== NostrSubscription.OPEN) {
this.subscription.open();
if (!this.subscription || this.subscription.closed) {
this.subscription = this.relay.subscribe(query, {
onevent: (event) => this.handleEvent(event),
oneose: () => this.handleEOSE(),
});
} else {
this.subscription.filters = query;
this.subscription.fire();
}
} else if (this.subscription.state === NostrSubscription.OPEN) {
} else if (this.subscription && !this.subscription.closed) {
this.subscription.close();
}
}

View File

@ -1,21 +1,21 @@
import { Debugger } from "debug";
import { Filter, NostrEvent, matchFilters } from "nostr-tools";
import { Filter, NostrEvent, Relay, matchFilters } from "nostr-tools";
import _throttle from "lodash.throttle";
import NostrRequest from "./nostr-request";
import Subject from "./subject";
import { logger } from "../helpers/debug";
import EventStore from "./event-store";
import deleteEventService from "../services/delete-events";
import { mergeFilter } from "../helpers/nostr/filter";
import { isATag, isETag } from "../types/nostr-event";
import relayPoolService from "../services/relay-pool";
const DEFAULT_CHUNK_SIZE = 100;
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
export default class ChunkedRequest {
relay: string;
relay: Relay;
filters: Filter[];
chunkSize = DEFAULT_CHUNK_SIZE;
private log: Debugger;
@ -28,12 +28,12 @@ export default class ChunkedRequest {
onChunkFinish = new Subject<number>();
constructor(relay: string, filters: Filter[], log?: Debugger) {
constructor(relay: Relay, filters: Filter[], log?: Debugger) {
this.relay = relay;
this.filters = filters;
this.log = log || logger.extend(relay);
this.events = new EventStore(relay);
this.log = log || logger.extend(relay.url);
this.events = new EventStore(relay.url);
// TODO: find a better place for this
this.subs.push(deleteEventService.stream.subscribe((e) => this.handleDeleteEvent(e)));
@ -47,23 +47,25 @@ export default class ChunkedRequest {
filters = mergeFilter(filters, { until: oldestEvent.created_at - 1 });
}
const request = new NostrRequest([this.relay]);
relayPoolService.addClaim(this.relay, this);
let gotEvents = 0;
request.onEvent.subscribe((e) => {
this.handleEvent(e);
gotEvents++;
const sub = this.relay.subscribe(filters, {
onevent: (event) => {
this.handleEvent(event);
gotEvents++;
},
oneose: () => {
this.loading = false;
if (gotEvents === 0) {
this.complete = true;
this.log("Complete");
} else this.log(`Got ${gotEvents} events`);
this.onChunkFinish.next(gotEvents);
sub.close();
relayPoolService.removeClaim(this.relay, this);
},
});
request.onComplete.then(() => {
this.loading = false;
if (gotEvents === 0) {
this.complete = true;
this.log("Complete");
} else this.log(`Got ${gotEvents} events`);
this.onChunkFinish.next(gotEvents);
});
request.start(filters);
}
private handleEvent(event: NostrEvent) {

View File

@ -1,12 +1,11 @@
import { nanoid } from "nanoid";
import { NostrEvent } from "../types/nostr-event";
import { NostrRequestFilter, RelayQueryMap } from "../types/nostr-relay";
import Relay, { IncomingEvent, OutgoingRequest } from "./relay";
import { RelayQueryMap } from "../types/nostr-relay";
import relayPoolService from "../services/relay-pool";
import { isFilterEqual, isQueryMapEqual } from "../helpers/nostr/filter";
import ControlledObservable from "./controlled-observable";
import SuperMap from "./super-map";
import { Relay, Subscription } from "nostr-tools";
export default class NostrMultiSubscription {
static INIT = "initial";
@ -18,6 +17,8 @@ export default class NostrMultiSubscription {
queryMap: RelayQueryMap = {};
relays: Relay[] = [];
subscriptions = new Map<Relay, Subscription>();
state = NostrMultiSubscription.INIT;
onEvent = new ControlledObservable<NostrEvent>();
seenEvents = new Set<string>();
@ -26,38 +27,17 @@ export default class NostrMultiSubscription {
this.id = nanoid();
this.name = name;
}
private handleMessage(incomingEvent: IncomingEvent) {
if (
this.state === NostrMultiSubscription.OPEN &&
incomingEvent[1] === this.id &&
!this.seenEvents.has(incomingEvent[2].id)
) {
this.onEvent.next(incomingEvent[2]);
this.seenEvents.add(incomingEvent[2].id);
}
private handleEvent(event: NostrEvent) {
if (this.seenEvents.has(event.id)) return;
this.onEvent.next(event);
this.seenEvents.add(event.id);
}
private relaySubs = new SuperMap<Relay, ZenObservable.Subscription[]>(() => []);
/** listen for event and open events from relays */
private connectToRelay(relay: Relay) {
const subs = this.relaySubs.get(relay);
subs.push(relay.onEvent.subscribe(this.handleMessage.bind(this)));
subs.push(relay.onOpen.subscribe(this.handleRelayConnect.bind(this)));
subs.push(relay.onClose.subscribe(this.handleRelayDisconnect.bind(this)));
private handleAddRelay(relay: Relay) {
relayPoolService.addClaim(relay.url, this);
}
/** stop listing to events from relays */
private disconnectFromRelay(relay: Relay) {
const subs = this.relaySubs.get(relay);
for (const sub of subs) sub.unsubscribe();
this.relaySubs.delete(relay);
private handleRemoveRelay(relay: Relay) {
relayPoolService.removeClaim(relay.url, this);
// if the subscription is open and had sent a request to the relay
if (this.state === NostrMultiSubscription.OPEN && this.relayQueries.has(relay)) {
relay.send(["CLOSE", this.id]);
}
this.relayQueries.delete(relay);
}
setQueryMap(queryMap: RelayQueryMap) {
@ -70,7 +50,7 @@ export default class NostrMultiSubscription {
// add relay
const relay = relayPoolService.requestRelay(url);
this.relays.push(relay);
this.connectToRelay(relay);
this.handleAddRelay(relay);
}
}
for (const url of Object.keys(this.queryMap)) {
@ -78,41 +58,51 @@ export default class NostrMultiSubscription {
const relay = this.relays.find((r) => r.url === url);
if (!relay) continue;
this.relays = this.relays.filter((r) => r !== relay);
this.disconnectFromRelay(relay);
this.handleRemoveRelay(relay);
}
}
this.queryMap = queryMap;
this.updateRelayQueries();
this.updateSubscriptions();
}
private relayQueries = new WeakMap<Relay, NostrRequestFilter>();
private updateRelayQueries() {
if (this.state !== NostrMultiSubscription.OPEN) return;
private updateSubscriptions() {
// close all subscriptions if not open
if (this.state !== NostrMultiSubscription.OPEN) {
for (const [relay, subscription] of this.subscriptions) {
subscription.close();
}
this.subscriptions.clear();
return;
}
// else open and update subscriptions
for (const relay of this.relays) {
const filter = this.queryMap[relay.url];
const message: OutgoingRequest = Array.isArray(filter) ? ["REQ", this.id, ...filter] : ["REQ", this.id, filter];
const filters = this.queryMap[relay.url];
const currentFilter = this.relayQueries.get(relay);
if (!currentFilter || !isFilterEqual(currentFilter, filter)) {
this.relayQueries.set(relay, filter);
relay.send(message);
let subscription = this.subscriptions.get(relay);
if (!subscription || !isFilterEqual(subscription.filters, filters)) {
if (subscription) {
subscription.filters = filters;
subscription.fire();
} else {
subscription = relay.subscribe(filters, {
onevent: (event) => this.handleEvent(event),
onclose: () => {
if (this.subscriptions.get(relay) === subscription) {
this.subscriptions.delete(relay);
}
},
});
this.subscriptions.set(relay, subscription);
}
}
}
}
private handleRelayConnect(relay: Relay) {
this.updateRelayQueries();
}
private handleRelayDisconnect(relay: Relay) {
this.relayQueries.delete(relay);
}
sendAll(event: NostrEvent) {
for (const relay of this.relays) {
relay.send(["EVENT", event]);
}
publish(event: NostrEvent) {
return Promise.all(this.relays.map((r) => r.publish(event)));
}
open() {
@ -120,14 +110,14 @@ export default class NostrMultiSubscription {
this.state = NostrMultiSubscription.OPEN;
// reconnect to all relays
for (const relay of this.relays) this.connectToRelay(relay);
for (const relay of this.relays) this.handleAddRelay(relay);
// send queries
this.updateRelayQueries();
this.updateSubscriptions();
return this;
}
waitForConnection(): Promise<void> {
return Promise.all(this.relays.map((r) => r.waitForConnection())).then((v) => void 0);
waitForAllConnection(): Promise<void> {
return Promise.all(this.relays.filter((r) => !r.connected).map((r) => r.connect())).then((v) => void 0);
}
close() {
if (this.state !== NostrMultiSubscription.OPEN) return this;
@ -135,7 +125,7 @@ export default class NostrMultiSubscription {
// forget all seen events
this.forgetEvents();
// unsubscribe from relay messages
for (const relay of this.relays) this.disconnectFromRelay(relay);
for (const relay of this.relays) this.handleRemoveRelay(relay);
// set state
this.state = NostrMultiSubscription.CLOSED;

View File

@ -1,12 +1,12 @@
import { nanoid } from "nanoid";
import { NostrEvent } from "nostr-tools";
import { NostrEvent, Relay } from "nostr-tools";
import relayPoolService from "../services/relay-pool";
import createDefer from "./deferred";
import Relay, { IncomingCommandResult } from "./relay";
import { PersistentSubject } from "./subject";
import ControlledObservable from "./controlled-observable";
import SuperMap from "./super-map";
type Result = { relay: Relay; success: boolean; message: string };
export default class NostrPublishAction {
id = nanoid();
@ -14,13 +14,12 @@ export default class NostrPublishAction {
relays: string[];
event: NostrEvent;
results = new PersistentSubject<{ relay: Relay; result: IncomingCommandResult }[]>([]);
results = new PersistentSubject<Result[]>([]);
onResult = new ControlledObservable<{ relay: Relay; result: IncomingCommandResult }>();
onComplete = createDefer<{ relay: Relay; result: IncomingCommandResult }[]>();
onResult = new ControlledObservable<Result>();
onComplete = createDefer<Result[]>();
private remaining = new Set<Relay>();
private relayResultSubs = new SuperMap<Relay, ZenObservable.Subscription[]>(() => []);
constructor(label: string, relays: Iterable<string>, event: NostrEvent, timeout: number = 5000) {
this.label = label;
@ -30,31 +29,30 @@ export default class NostrPublishAction {
for (const url of relays) {
const relay = relayPoolService.requestRelay(url);
this.remaining.add(relay);
this.relayResultSubs.get(relay).push(
relay.onCommandResult.subscribe((result) => {
if (result[1] === this.event.id) this.handleResult(result, relay);
}),
);
relay.send(["EVENT", event]);
relay
.publish(event)
.then((result) => this.handleResult(event.id, true, result, relay))
.catch((err) => {
if (err instanceof Error) this.handleResult(event.id, false, err.message, relay);
});
}
setTimeout(this.handleTimeout.bind(this), timeout);
}
private handleResult(result: IncomingCommandResult, relay: Relay) {
this.results.next([...this.results.value, { relay, result }]);
this.onResult.next({ relay, result });
private handleResult(id: string, success: boolean, message: string, relay: Relay) {
const result: Result = { relay, success, message };
this.results.next([...this.results.value, result]);
this.onResult.next(result);
this.relayResultSubs.get(relay).forEach((s) => s.unsubscribe());
this.relayResultSubs.delete(relay);
this.remaining.delete(relay);
if (this.remaining.size === 0) this.onComplete.resolve(this.results.value);
}
private handleTimeout() {
for (const relay of this.remaining) {
this.handleResult(["OK", this.event.id, false, "Timeout"], relay);
this.handleResult(this.event.id, false, "Timeout", relay);
}
}
}

View File

@ -1,95 +0,0 @@
import { nanoid } from "nanoid";
import { Filter, NostrEvent } from "nostr-tools";
import relayPoolService from "../services/relay-pool";
import Relay, { CountResponse, IncomingCount, IncomingEOSE, IncomingEvent } from "./relay";
import createDefer from "./deferred";
import ControlledObservable from "./controlled-observable";
import SuperMap from "./super-map";
const REQUEST_DEFAULT_TIMEOUT = 1000 * 5;
export default class NostrRequest {
static IDLE = "idle";
static RUNNING = "running";
static COMPLETE = "complete";
id = nanoid();
timeout: number;
relays: Set<Relay>;
state = NostrRequest.IDLE;
onEvent = new ControlledObservable<NostrEvent>();
onCount = new ControlledObservable<CountResponse>();
onComplete = createDefer<void>();
seenEvents = new Set<string>();
private relaySubs: SuperMap<Relay, ZenObservable.Subscription[]> = new SuperMap(() => []);
constructor(relayUrls: Iterable<string>, timeout?: number) {
this.relays = new Set(Array.from(relayUrls).map((url) => relayPoolService.requestRelay(url)));
for (const relay of this.relays) {
const subs = this.relaySubs.get(relay);
subs.push(relay.onEOSE.subscribe((m) => this.handleEOSE(m, relay)));
subs.push(relay.onEvent.subscribe(this.handleEvent.bind(this)));
subs.push(relay.onCount.subscribe(this.handleCount.bind(this)));
}
this.timeout = timeout ?? REQUEST_DEFAULT_TIMEOUT;
}
handleEOSE(message: IncomingEOSE, relay: Relay) {
if (message[1] === this.id) {
this.relays.delete(relay);
relay.send(["CLOSE", this.id]);
this.relaySubs.get(relay).forEach((sub) => sub.unsubscribe());
this.relaySubs.delete(relay);
if (this.relays.size === 0) {
this.state = NostrRequest.COMPLETE;
this.onComplete.resolve();
}
}
}
handleEvent(message: IncomingEvent) {
if (this.state === NostrRequest.RUNNING && message[1] === this.id && !this.seenEvents.has(message[2].id)) {
this.onEvent.next(message[2]);
this.seenEvents.add(message[2].id);
}
}
handleCount(incomingCount: IncomingCount) {
if (incomingCount[1] === this.id) {
this.onCount.next(incomingCount[2]);
}
}
start(filter: Filter | Filter[], type: "REQ" | "COUNT" = "REQ") {
if (this.state !== NostrRequest.IDLE) {
throw new Error("cant restart a nostr request");
}
this.state = NostrRequest.RUNNING;
for (const relay of this.relays) {
if (Array.isArray(filter)) {
relay.send([type, this.id, ...filter]);
} else relay.send([type, this.id, filter]);
}
setTimeout(() => this.complete(), this.timeout);
return this;
}
complete() {
if (this.state === NostrRequest.COMPLETE) return this;
this.state = NostrRequest.COMPLETE;
for (const relay of this.relays) {
relay.send(["CLOSE", this.id]);
this.relaySubs.get(relay).forEach((sub) => sub.unsubscribe());
}
this.relaySubs.clear();
this.onComplete.resolve();
return this;
}
}

View File

@ -1,10 +1,10 @@
import { nanoid } from "nanoid";
import { Filter, NostrEvent } from "nostr-tools";
import { Filter, NostrEvent, Relay, Subscription } from "nostr-tools";
import Relay, { IncomingEOSE, OutgoingMessage } from "./relay";
import relayPoolService from "../services/relay-pool";
import ControlledObservable from "./controlled-observable";
/** @deprecated use relay.subscribe instead */
export default class NostrSubscription {
static INIT = "initial";
static OPEN = "open";
@ -16,10 +16,10 @@ export default class NostrSubscription {
relay: Relay;
state = NostrSubscription.INIT;
onEvent = new ControlledObservable<NostrEvent>();
onEOSE = new ControlledObservable<IncomingEOSE>();
subscription: Subscription | null = null;
private subs: ZenObservable.Subscription[] = [];
onEvent = new ControlledObservable<NostrEvent>();
onEOSE = new ControlledObservable<number>();
constructor(relayUrl: string | URL, filters?: Filter[], name?: string) {
this.id = nanoid();
@ -27,28 +27,13 @@ export default class NostrSubscription {
this.name = name;
this.relay = relayPoolService.requestRelay(relayUrl);
this.subs.push(
this.relay.onEvent.subscribe((message) => {
if (this.state === NostrSubscription.OPEN && message[1] === this.id) {
this.onEvent.next(message[2]);
}
}),
);
this.subs.push(
this.relay.onEOSE.subscribe((eose) => {
if (this.state === NostrSubscription.OPEN && eose[1] === this.id) this.onEOSE.next(eose);
}),
);
}
send(message: OutgoingMessage) {
this.relay.send(message);
}
setFilters(filters: Filter[]) {
this.filters = filters;
if (this.state === NostrSubscription.OPEN) {
this.send(["REQ", this.id, ...this.filters]);
if (this.state === NostrSubscription.OPEN && this.subscription) {
this.subscription.filters = this.filters;
this.subscription.fire();
}
return this;
}
@ -58,7 +43,10 @@ export default class NostrSubscription {
if (this.state === NostrSubscription.OPEN) return this;
this.state = NostrSubscription.OPEN;
this.send(["REQ", this.id, ...this.filters]);
this.subscription = this.relay.subscribe(this.filters, {
onevent: (event) => this.onEvent.next(event),
oneose: () => this.onEOSE.next(Math.random()),
});
relayPoolService.addClaim(this.relay.url, this);
@ -70,13 +58,10 @@ export default class NostrSubscription {
// set state
this.state = NostrSubscription.CLOSED;
// send close message
this.send(["CLOSE", this.id]);
this.subscription?.close();
// unsubscribe from relay messages
relayPoolService.removeClaim(this.relay.url, this);
for (const sub of this.subs) sub.unsubscribe();
this.subs = [];
return this;
}
}

View File

@ -0,0 +1,151 @@
import { NostrEvent, kinds, nip18, nip25 } from "nostr-tools";
import _throttle from "lodash.throttle";
import EventStore from "./event-store";
import { PersistentSubject } from "./subject";
import { getThreadReferences, isPTagMentionedInContent, isReply, isRepost } from "../helpers/nostr/event";
import { getParsedZap } from "../helpers/nostr/zaps";
import singleEventService from "../services/single-event";
import RelaySet from "./relay-set";
import clientRelaysService from "../services/client-relays";
import { getPubkeysMentionedInContent } from "../helpers/nostr/post";
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
import { STREAM_CHAT_MESSAGE_KIND } from "../helpers/nostr/stream";
import replaceableEventsService from "../services/replaceable-events";
import { MUTE_LIST_KIND, getPubkeysFromList } from "../helpers/nostr/lists";
export const typeSymbol = Symbol("notificationType");
export enum NotificationType {
Reply = "reply",
Repost = "repost",
Zap = "zap",
Reaction = "reaction",
Mention = "mention",
}
export type CategorizedEvent = NostrEvent & { [typeSymbol]?: NotificationType };
export default class AccountNotifications {
store: EventStore;
pubkey: string;
private subs: ZenObservable.Subscription[] = [];
timeline = new PersistentSubject<CategorizedEvent[]>([]);
constructor(pubkey: string, store: EventStore) {
this.store = store;
this.pubkey = pubkey;
this.subs.push(store.onEvent.subscribe(this.handleEvent.bind(this)));
for (const [_, event] of store.events) this.handleEvent(event);
}
private categorizeEvent(event: NostrEvent): CategorizedEvent {
const e = event as CategorizedEvent;
if (event.kind === kinds.Zap) {
e[typeSymbol] = NotificationType.Zap;
} else if (event.kind === kinds.Reaction) {
e[typeSymbol] = NotificationType.Reaction;
} else if (isRepost(event)) {
e[typeSymbol] = NotificationType.Repost;
} else if (
event.kind === kinds.ShortTextNote ||
event.kind === TORRENT_COMMENT_KIND ||
event.kind === STREAM_CHAT_MESSAGE_KIND ||
event.kind === kinds.LongFormArticle
) {
// is the "p" tag directly mentioned in the content
const isMentioned = isPTagMentionedInContent(event, this.pubkey);
// is the pubkey mentioned in any way in the content
const isQuoted = getPubkeysMentionedInContent(event.content).includes(this.pubkey);
if (isMentioned || isQuoted) e[typeSymbol] = NotificationType.Mention;
else if (isReply(event)) e[typeSymbol] = NotificationType.Reply;
}
return e;
}
handleEvent(event: NostrEvent) {
const e = this.categorizeEvent(event);
const getAndSubscribe = (eventId: string, relays?: string[]) => {
const subject = singleEventService.requestEvent(
eventId,
RelaySet.from(clientRelaysService.readRelays.value, relays),
);
subject.once(this.throttleUpdateTimeline);
return subject.value;
};
switch (e[typeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (refs.reply?.e?.id) getAndSubscribe(refs.reply.e.id, refs.reply.e.relays);
break;
case NotificationType.Reaction: {
const pointer = nip25.getReactedEventPointer(e);
if (pointer?.id) getAndSubscribe(pointer.id, pointer.relays);
break;
}
}
}
throttleUpdateTimeline = _throttle(this.updateTimeline.bind(this), 200);
updateTimeline() {
const muteList = replaceableEventsService.getEvent(MUTE_LIST_KIND, this.pubkey).value;
const mutedPubkeys = muteList ? getPubkeysFromList(muteList).map((p) => p.pubkey) : [];
const sorted = this.store.getSortedEvents();
const timeline: CategorizedEvent[] = [];
for (const event of sorted) {
if (!Object.hasOwn(event, typeSymbol)) continue;
if (mutedPubkeys.includes(event.pubkey)) continue;
const e = event as CategorizedEvent;
switch (e[typeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (!refs.reply?.e?.id) break;
if (refs.reply?.e?.author && refs.reply?.e?.author !== this.pubkey) break;
const parent = singleEventService.getSubject(refs.reply.e.id).value;
if (!parent || parent.pubkey !== this.pubkey) break;
timeline.push(e);
break;
case NotificationType.Mention:
timeline.push(e);
break;
case NotificationType.Repost: {
const pointer = nip18.getRepostedEventPointer(e);
if (pointer?.author !== this.pubkey) break;
timeline.push(e);
break;
}
case NotificationType.Reaction: {
const pointer = nip25.getReactedEventPointer(e);
if (!pointer) break;
if (pointer.author !== this.pubkey) break;
if (pointer.kind === kinds.EncryptedDirectMessage) break;
const parent = singleEventService.getSubject(pointer.id).value;
if (parent && parent.kind === kinds.EncryptedDirectMessage) break;
timeline.push(e);
break;
}
case NotificationType.Zap:
const parsed = getParsedZap(e, true, true);
if (parsed instanceof Error) break;
if (!parsed.payment.amount) break;
timeline.push(e);
break;
}
}
this.timeline.next(timeline);
}
destroy() {
for (const sub of this.subs) sub.unsubscribe();
this.subs = [];
}
}

150
src/classes/pubkey-graph.ts Normal file
View File

@ -0,0 +1,150 @@
import { NostrEvent } from "nostr-tools";
export class PubkeyGraph {
/** the pubkey at the center of it all */
root: string;
connections = new Map<string, string[]>();
distance = new Map<string, number>();
// number of connections a key has at each level
connectionCount = new Map<string, number>();
constructor(root: string) {
this.root = root;
}
handleEvent(event: NostrEvent) {
const keys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
for (const key of keys) this.changed.add(key);
this.setPubkeyConnections(event.pubkey, keys);
}
setPubkeyConnections(pubkey: string, friends: string[]) {
this.connections.set(pubkey, friends);
}
getByDistance() {
const dist: Record<number, [string, number | undefined][]> = {};
for (const [key, d] of this.distance) {
dist[d] = dist[d] || [];
dist[d].push([key, this.connectionCount.get(key)]);
}
// sort keys
for (const [d, keys] of Object.entries(dist)) {
keys.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0));
}
return dist;
}
getPubkeyDistance(pubkey: string) {
const distance = this.distance.get(pubkey);
if (!distance) return;
const count = this.connectionCount.get(pubkey);
return { distance, count };
}
sortByDistanceAndConnections(keys: string[]): string[];
sortByDistanceAndConnections<T>(keys: T[], getKey: (d: T) => string): T[];
sortByDistanceAndConnections<T>(keys: T[], getKey?: (d: T) => string): T[] {
return Array.from(keys).sort((a, b) => {
const aKey = typeof a === "string" ? a : getKey?.(a) || "";
const bKey = typeof b === "string" ? b : getKey?.(b) || "";
const v = this.sortComparePubkeys(aKey, bKey);
if (v === 0) {
// tied break with original index
const ai = keys.indexOf(a);
const bi = keys.indexOf(b);
if (ai < bi) return -1;
else if (ai > bi) return 1;
return 0;
}
return v;
});
}
sortComparePubkeys(a: string, b: string) {
const aDist = this.distance.get(a);
const bDist = this.distance.get(b);
if (!aDist && !bDist) return 0;
else if (aDist && (!bDist || aDist < bDist)) return -1;
else if (bDist && (!aDist || aDist > bDist)) return 1;
// a and b are on the same level. compare connections
const aCount = this.connectionCount.get(a);
const bCount = this.connectionCount.get(b);
if (aCount === bCount) return 0;
else if (aCount && (!bCount || aCount < bCount)) return -1;
else if (bCount && (!aCount || aCount > bCount)) return 1;
return 0;
}
changed = new Set<string>();
compute() {
this.distance.clear();
const next = new Set<string>();
const refCount = new Map<string, number>();
const walkLevel = (level = 0) => {
if (next.size === 0) return;
let keys = new Set(next);
next.clear();
for (const key of keys) {
this.distance.set(key, level);
const count = refCount.get(key);
if (count) this.connectionCount.set(key, count);
}
for (const key of keys) {
const connections = this.connections.get(key);
if (connections) {
for (const child of connections) {
if (!this.distance.has(child)) {
next.add(child);
refCount.set(child, (refCount.get(child) ?? 0) + 1);
}
}
}
}
walkLevel(level + 1);
};
console.time("walk");
next.add(this.root);
walkLevel(0);
console.timeEnd("walk");
}
getPaths(pubkey: string, maxLength = 2) {
let paths: string[][] = [];
const walk = (p: string, maxLvl = 0, path: string[] = []) => {
if (path.includes(p)) return;
const connections = this.connections.get(p);
if (!connections) return;
for (const friend of connections) {
if (friend === pubkey) {
paths.push([...path, p, friend]);
} else if (maxLvl > 0) {
walk(friend, maxLvl - 1, [...path, p]);
}
}
};
walk(this.root, maxLength);
return paths;
}
}

View File

@ -1,7 +1,7 @@
import { Relay } from "nostr-tools";
import { logger } from "../helpers/debug";
import { validateRelayURL } from "../helpers/relay";
import { offlineMode } from "../services/offline-mode";
import Relay from "./relay";
import Subject from "./subject";
export default class RelayPool {
@ -34,9 +34,9 @@ export default class RelayPool {
}
const relay = this.relays.get(key) as Relay;
if (connect && !relay.okay) {
if (connect && !relay.connected) {
try {
relay.open();
relay.connect();
} catch (e) {
this.log(`Failed to connect to ${relay.url}`);
this.log(e);
@ -58,9 +58,9 @@ export default class RelayPool {
for (const [url, relay] of this.relays.entries()) {
const claims = this.getRelayClaims(url).size;
if (!relay.okay && claims > 0) {
if (!relay.connected && claims > 0) {
try {
relay.open();
relay.connect();
} catch (e) {
this.log(`Failed to connect to ${relay.url}`);
this.log(e);
@ -69,14 +69,12 @@ export default class RelayPool {
}
}
addClaim(url: string | URL, id: any) {
url = validateRelayURL(url);
const key = url.toString();
addClaim(relay: string | URL | Relay, id: any) {
const key = relay instanceof Relay ? relay.url : validateRelayURL(relay).toString();
this.getRelayClaims(key).add(id);
}
removeClaim(url: string | URL, id: any) {
url = validateRelayURL(url);
const key = url.toString();
removeClaim(relay: string | URL | Relay, id: any) {
const key = relay instanceof Relay ? relay.url : validateRelayURL(relay).toString();
this.getRelayClaims(key).delete(id);
}

View File

@ -1,211 +1,6 @@
import { Filter, NostrEvent } from "nostr-tools";
import { offlineMode } from "../services/offline-mode";
import relayScoreboardService from "../services/relay-scoreboard";
import ControlledObservable from "./controlled-observable";
import createDefer, { Deferred } from "./deferred";
import { PersistentSubject } from "./subject";
export type CountResponse = {
count: number;
approximate?: boolean;
};
export type IncomingEvent = ["EVENT", string, NostrEvent];
export type IncomingNotice = ["NOTICE", string];
export type IncomingCount = ["COUNT", string, CountResponse];
export type IncomingEOSE = ["EOSE", string];
export type IncomingCommandResult = ["OK", string, boolean] | ["OK", string, boolean, string];
export type IncomingMessage = IncomingEvent | IncomingNotice | IncomingCount | IncomingEOSE | IncomingCommandResult;
export type OutgoingEvent = ["EVENT", NostrEvent];
export type OutgoingRequest = ["REQ", string, ...Filter[]];
export type OutgoingCount = ["COUNT", string, ...Filter[]];
export type OutgoingClose = ["CLOSE", string];
export type OutgoingMessage = OutgoingEvent | OutgoingRequest | OutgoingClose | OutgoingCount;
export enum RelayMode {
NONE = 0,
READ = 1,
WRITE = 2,
ALL = 1 | 2,
}
const CONNECTION_TIMEOUT = 1000 * 30;
export default class Relay {
url: string;
ws?: WebSocket;
status = new PersistentSubject<number>(WebSocket.CLOSED);
onOpen = new ControlledObservable<Relay>();
onClose = new ControlledObservable<Relay>();
onEvent = new ControlledObservable<IncomingEvent>();
onNotice = new ControlledObservable<IncomingNotice>();
onCount = new ControlledObservable<IncomingCount>();
onEOSE = new ControlledObservable<IncomingEOSE>();
onCommandResult = new ControlledObservable<IncomingCommandResult>();
private connectionPromises: Deferred<void>[] = [];
private connectionTimer?: () => void;
private ejectTimer?: () => void;
private intentionalClose = false;
private subscriptionResTimer = new Map<string, () => void>();
private queue: OutgoingMessage[] = [];
constructor(url: string) {
this.url = url;
}
open() {
if (offlineMode.value) return;
if (this.okay) return;
this.intentionalClose = false;
this.ws = new WebSocket(this.url);
this.connectionTimer = relayScoreboardService.relayConnectionTime.get(this.url).createTimer();
const connectionTimeout: number = window.setTimeout(() => {
// end the connection timer after CONNECTION_TIMEOUT
if (this.connectionTimer) {
this.connectionTimer();
this.connectionTimer = undefined;
for (const p of this.connectionPromises) p.reject();
this.connectionPromises = [];
}
// relayScoreboardService.relayTimeouts.get(this.url).addIncident();
}, CONNECTION_TIMEOUT);
// for local dev, cancel timeout if module reloads
if (import.meta.hot) {
import.meta.hot.prune(() => {
window.clearTimeout(connectionTimeout);
this.ws?.close();
});
}
this.ws.onopen = () => {
window.clearTimeout(connectionTimeout);
this.onOpen.next(this);
this.status.next(this.ws!.readyState);
this.ejectTimer = relayScoreboardService.relayEjectTime.get(this.url).createTimer();
if (this.connectionTimer) {
this.connectionTimer();
this.connectionTimer = undefined;
}
this.sendQueued();
for (const p of this.connectionPromises) p.resolve();
this.connectionPromises = [];
};
this.ws.onclose = () => {
this.onClose.next(this);
this.status.next(this.ws!.readyState);
if (!this.intentionalClose && this.ejectTimer) {
this.ejectTimer();
this.ejectTimer = undefined;
}
};
this.ws.onmessage = this.handleMessage.bind(this);
}
send(json: OutgoingMessage) {
if (this.connected) {
this.ws?.send(JSON.stringify(json));
// record start time
if (json[0] === "REQ" || json[0] === "COUNT") {
this.startSubResTimer(json[1]);
}
} else this.queue.push(json);
}
close() {
this.ws?.close();
this.intentionalClose = true;
this.subscriptionResTimer.clear();
}
waitForConnection(): Promise<void> {
if (this.connected) return Promise.resolve();
const p = createDefer<void>();
this.connectionPromises.push(p);
return p;
}
private startSubResTimer(sub: string) {
this.subscriptionResTimer.set(sub, relayScoreboardService.relayResponseTimes.get(this.url).createTimer());
}
private endSubResTimer(sub: string) {
const endTimer = this.subscriptionResTimer.get(sub);
if (endTimer) {
endTimer();
this.subscriptionResTimer.delete(sub);
}
}
private sendQueued() {
if (this.connected) {
for (const message of this.queue) {
this.send(message);
}
this.queue = [];
}
}
get okay() {
return this.connected || this.connecting;
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
get connecting() {
return this.ws?.readyState === WebSocket.CONNECTING;
}
get closing() {
return this.ws?.readyState === WebSocket.CLOSING;
}
get closed() {
return this.ws?.readyState === WebSocket.CLOSED;
}
get state() {
return this.ws?.readyState;
}
handleMessage(message: MessageEvent<string>) {
if (!message.data) return;
try {
const data: IncomingMessage = JSON.parse(message.data);
const type = data[0];
// all messages must have an argument
if (!data[1]) return;
switch (type) {
case "EVENT":
this.onEvent.next(data);
this.endSubResTimer(data[1]);
break;
case "NOTICE":
this.onNotice.next(data);
break;
case "COUNT":
this.onCount.next(data);
break;
case "EOSE":
this.onEOSE.next(data);
this.endSubResTimer(data[1]);
break;
case "OK":
this.onCommandResult.next(data);
break;
}
} catch (e) {
console.log(`Relay: Failed to parse massage from ${this.url}`);
console.log(message.data, e);
}
}
}

View File

@ -28,6 +28,16 @@ export default class Subject<T> {
}
subscribe: Observable<T>["subscribe"];
once(next: (value: T) => void) {
const sub = this.subscribe((v) => {
if (v !== undefined) {
next(v);
sub.unsubscribe();
}
});
return sub;
}
map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> {
const child = new Subject(defaultValue);

View File

@ -15,6 +15,7 @@ import { localRelay } from "../services/local-relay";
import { relayRequest } from "../helpers/relay";
import SuperMap from "./super-map";
import ChunkedRequest from "./chunked-request";
import relayPoolService from "../services/relay-pool";
const BLOCK_SIZE = 100;
@ -124,7 +125,11 @@ export default class TimelineLoader {
}
if (!this.chunkLoaders.has(relay)) {
const loader = new ChunkedRequest(relay, Array.isArray(filter) ? filter : [filter], this.log.extend(relay));
const loader = new ChunkedRequest(
relayPoolService.requestRelay(relay),
Array.isArray(filter) ? filter : [filter],
this.log.extend(relay),
);
this.chunkLoaders.set(relay, loader);
this.connectToChunkLoader(loader);
}

View File

@ -0,0 +1,60 @@
import { useMemo } from "react";
import { useColorModeValue, useTheme } from "@chakra-ui/react";
import {
Chart as ChartJS,
ArcElement,
CategoryScale,
ChartData,
Colors,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { Pie } from "react-chartjs-2";
ChartJS.register(
ArcElement,
Tooltip,
Legend,
Colors,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
);
function createChartData(kinds: Record<string, number>) {
const sortedKinds = Object.entries(kinds)
.map(([kind, count]) => ({ kind, count }))
.sort((a, b) => b.count - a.count);
const data: ChartData<"pie", number[], string> = {
labels: sortedKinds.map(({ kind }) => String(kind)),
datasets: [{ label: "# of events", data: sortedKinds.map(({ count }) => count) }],
};
return data;
}
export default function EventKindsPieChart({ kinds }: { kinds: Record<string, number> }) {
const theme = useTheme();
const token = theme.semanticTokens.colors["chakra-body-text"];
const color = useColorModeValue(token._light, token._dark) as string;
const chartData = useMemo(() => createChartData(kinds), [kinds]);
return (
<Pie
data={chartData}
options={{
color,
plugins: { colors: { forceOverride: true } },
}}
/>
);
}

View File

@ -0,0 +1,54 @@
import { useMemo } from "react";
import { ButtonGroup, IconButton, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
import { TrashIcon } from "../icons";
export default function EventKindsTable({
kinds,
deleteKind,
}: {
kinds: Record<string, number>;
deleteKind?: (kind: string) => Promise<void>;
}) {
const sorted = useMemo(
() =>
Object.entries(kinds)
.map(([kind, count]) => ({ kind, count }))
.sort((a, b) => b.count - a.count),
[kinds],
);
return (
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th isNumeric>Kind</Th>
<Th isNumeric>Count</Th>
{deleteKind && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{sorted.map(({ kind, count }) => (
<Tr key={kind}>
<Td isNumeric>{kind}</Td>
<Td isNumeric>{count}</Td>
{deleteKind && (
<Td isNumeric>
<ButtonGroup size="xs">
<IconButton
icon={<TrashIcon />}
aria-label="Delete kind"
colorScheme="red"
variant="ghost"
onClick={() => deleteKind(kind)}
/>
</ButtonGroup>
</Td>
)}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
);
}

View File

@ -3,19 +3,26 @@ import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react";
import { CheckIcon, CopyToClipboardIcon } from "./icons";
export const CopyIconButton = ({ value, ...props }: { value?: string } & Omit<IconButtonProps, "icon">) => {
type CopyIconButtonProps = Omit<IconButtonProps, "icon" | "value"> & {
value: string | undefined | (() => string);
icon?: IconButtonProps["icon"];
};
export const CopyIconButton = ({ value, icon, ...props }: CopyIconButtonProps) => {
const toast = useToast();
const [copied, setCopied] = useState(false);
return (
<IconButton
icon={copied ? <CheckIcon /> : <CopyToClipboardIcon />}
icon={copied ? <CheckIcon /> : icon || <CopyToClipboardIcon />}
onClick={() => {
if (value && navigator.clipboard && !copied) {
navigator.clipboard.writeText(value);
const v: string | undefined = typeof value === "function" ? value() : value;
if (v && navigator.clipboard && !copied) {
navigator.clipboard.writeText(v);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else toast({ description: value, isClosable: true, duration: null });
} else toast({ description: v, isClosable: true, duration: null });
}}
{...props}
/>

View File

@ -84,7 +84,7 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
<ModalHeader p="4">{event.id}</ModalHeader>
<ModalCloseButton />
<ModalBody p="0">
<Accordion allowToggle>
<Accordion allowToggle defaultIndex={event.content ? 1 : 2}>
<Section label="IDs">
<RawValue heading="Event Id" value={event.id} />
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />

View File

@ -8,7 +8,7 @@ import { aTagToAddressPointer, eTagToEventPointer } from "../../helpers/nostr/ev
import { EmbedEventPointer } from "../embed-event";
import UserAvatarLink from "../user/user-avatar-link";
import UserLink from "../user/user-link";
import { UserDnsIdentityIcon } from "../user/user-dns-identity-icon";
import UserDnsIdentity from "../user/user-dns-identity";
function EventTag({ tag }: { tag: Tag }) {
const expand = useDisclosure();
@ -62,7 +62,7 @@ function EventTag({ tag }: { tag: Tag }) {
<Box>
<UserLink pubkey={pubkey} fontWeight="bold" />
<br />
<UserDnsIdentityIcon pubkey={pubkey} />
<UserDnsIdentity pubkey={pubkey} />
</Box>
</Flex>
)}

View File

@ -1,17 +1,5 @@
import { useContext } from "react";
import {
Box,
Card,
CardBody,
CardProps,
Flex,
Heading,
Image,
LinkBox,
LinkOverlay,
Tag,
Text,
} from "@chakra-ui/react";
import { Box, Card, CardBody, CardProps, Flex, Heading, Image, LinkBox, Tag, Text, useToast } from "@chakra-ui/react";
import {
getArticleImage,
@ -27,15 +15,21 @@ import Timestamp from "../../timestamp";
import { AppHandlerContext } from "../../../providers/route/app-handler-provider";
export default function EmbeddedArticle({ article, ...props }: Omit<CardProps, "children"> & { article: NostrEvent }) {
const toast = useToast();
const title = getArticleTitle(article);
const image = getArticleImage(article);
const summary = getArticleSummary(article);
const naddr = getSharableEventAddress(article);
const { openAddress } = useContext(AppHandlerContext);
const open = () => {
const naddr = getSharableEventAddress(article);
if (naddr) openAddress(naddr);
else toast({ status: "error", description: "Failed to get address" });
};
return (
<Card as={LinkBox} size="sm" onClick={() => naddr && openAddress(naddr)} cursor="pointer" {...props}>
<Card as={LinkBox} size="sm" onClick={open} cursor="pointer" {...props}>
{image && (
<Box
backgroundImage={image}

View File

@ -5,7 +5,6 @@ import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import { UserDnsIdentityIcon } from "../../user/user-dns-identity-icon";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../common-event/event-verification-icon";
@ -36,15 +35,14 @@ export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "child
<TrustProvider event={event}>
<Card as={LinkBox} {...props}>
<Flex p="2" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick} />
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={event} />}
<NoteLink noteId={event.id} color="current" whiteSpace="nowrap">
<Timestamp timestamp={event.created_at} />
</NoteLink>
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick} />
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={event} />}
</Flex>
<CompactNoteContent px="2" event={event} maxLength={96} />
</Card>

View File

@ -5,7 +5,7 @@ import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import { UserDnsIdentityIcon } from "../../user/user-dns-identity-icon";
import UserDnsIdentity from "../../user/user-dns-identity";
import {
embedEmoji,
embedNostrHashtags,
@ -44,7 +44,7 @@ export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "ch
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<UserDnsIdentity pubkey={event.pubkey} onlyIcon />
<Text>kind: {event.kind}</Text>
<Timestamp timestamp={event.created_at} />
<ButtonGroup ml="auto">

View File

@ -1,7 +1,6 @@
import { MouseEventHandler, MutableRefObject, forwardRef, useCallback, useMemo, useRef } from "react";
import { Image, ImageProps, Link, LinkProps } from "@chakra-ui/react";
import { useTrustContext } from "../../providers/local/trust";
import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds";
import { getMatchLink } from "../../helpers/regexp";
import { useRegisterSlide } from "../lightbox-provider";
@ -100,7 +99,7 @@ export const GalleryImage = forwardRef<HTMLImageElement, EmbeddedImageProps>(
export function ImageGallery({ images, event }: { images: string[]; event?: NostrEvent }) {
const photos = useMemo(() => {
return images.map((img) => {
const photo: PhotoWithoutSize = { src: img };
const photo: PhotoWithoutSize = { src: img, key: img };
return photo;
});
}, [images]);

View File

@ -1,104 +0,0 @@
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Button,
ModalProps,
Text,
Flex,
ButtonGroup,
Spacer,
ModalHeader,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../user/user-avatar-link";
import UserLink from "../user/user-link";
import { LightningIcon } from "../icons";
import { ParsedZap } from "../../helpers/nostr/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
import Timestamp from "../timestamp";
import { getEventUID } from "../../helpers/nostr/event";
import ReactionDetails from "./reaction-details";
import RepostDetails from "./repost-details";
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
if (!zap.payment.amount) return null;
return (
<>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
<UserLink pubkey={zap.request.pubkey} />
<Timestamp timestamp={zap.event.created_at} />
<Spacer />
<LightningIcon color="yellow.500" />
<Text fontWeight="bold">{readablizeSats(zap.payment.amount / 1000)}</Text>
</Flex>
<Text>{zap.request.content}</Text>
</>
);
});
export default function EventInteractionDetailsModal({
isOpen,
onClose,
event,
size = "2xl",
...props
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
const uuid = getEventUID(event);
const zaps = useEventZaps(uuid, [], true) ?? [];
const reactions = useEventReactions(uuid, [], true) ?? [];
const [tab, setTab] = useState(zaps.length > 0 ? "zaps" : "reactions");
const renderTab = () => {
switch (tab) {
case "reposts":
return <RepostDetails event={event} />;
case "reactions":
return <ReactionDetails reactions={reactions} />;
case "zaps":
return (
<>
{zaps
.sort((a, b) => b.request.created_at - a.request.created_at)
.map((zap) => (
<ZapEvent key={zap.request.id} zap={zap} />
))}
</>
);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size={size} {...props}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalHeader p={["2", "4"]}>
<ButtonGroup>
<Button size="sm" variant={tab === "zaps" ? "solid" : "outline"} onClick={() => setTab("zaps")}>
Zaps ({zaps.length})
</Button>
<Button size="sm" variant={tab === "reactions" ? "solid" : "outline"} onClick={() => setTab("reactions")}>
Reactions ({reactions.length})
</Button>
<Button size="sm" variant={tab === "reposts" ? "solid" : "outline"} onClick={() => setTab("reposts")}>
Reposts
</Button>
</ButtonGroup>
</ModalHeader>
<ModalBody px={["2", "4"]} pt="0" pb={["2", "4"]} display="flex" flexDirection="column" gap="2">
{renderTab()}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -1,33 +0,0 @@
import { Flex, Text } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../user/user-avatar-link";
import UserLink from "../user/user-link";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelays } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import Timestamp from "../timestamp";
export default function RepostDetails({ event }: { event: NostrEvent }) {
const readRelays = useReadRelays();
const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, {
kinds: [kinds.Repost, kinds.GenericRepost],
"#e": [event.id],
});
const reposts = useSubject(timeline.timeline);
return (
<>
{reposts.map((repost) => (
<Flex key={repost.id} gap="2" alignItems="center">
<UserAvatarLink pubkey={repost.pubkey} size="sm" />
<UserLink pubkey={repost.pubkey} fontWeight="bold" />
<Text>Shared</Text>
<Timestamp timestamp={repost.created_at} />
</Flex>
))}
</>
);
}

View File

@ -95,6 +95,7 @@ export const ChevronRightIcon = ChevronRight;
export const LightningIcon = Zap;
export const RelayIcon = Modem02;
export const MediaServerIcon = Database01;
export const BroadcastEventIcon = Share07;
export const ShareIcon = Share07;
export const PinIcon = Pin01;

View File

@ -56,14 +56,13 @@ export default function AccountSwitcher() {
<Flex direction="column" gap="2">
<Box
as="button"
borderRadius="30"
borderRadius="lg"
borderWidth={1}
display="flex"
gap="2"
mb="2"
alignItems="center"
flexGrow={1}
overflow="hidden"
onClick={onToggle}
>
<UserAvatar pubkey={account.pubkey} noProxy size="md" />

View File

@ -32,7 +32,6 @@ declare module "yet-another-react-lightbox" {
import { NostrEvent } from "../types/nostr-event";
import UserAvatarLink from "./user/user-avatar-link";
import UserLink from "./user/user-link";
import { UserDnsIdentityIcon } from "./user/user-dns-identity-icon";
import styled from "@emotion/styled";
import { getSharableEventAddress } from "../helpers/nip19";
@ -107,7 +106,6 @@ function EventSlideHeader({ event, ...props }: { event: NostrEvent } & Omit<Flex
<Flex gap="2" alignItems="center" p="2" {...props}>
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Spacer />
<Button as={RouterLink} to={`/n/${encoded}`} colorScheme="primary" size="sm">
View Note

View File

@ -13,7 +13,8 @@ import { matchSorter } from "match-sorter";
import { Emoji, useContextEmojis } from "../providers/global/emoji-provider";
import { useUserSearchDirectoryContext } from "../providers/global/user-directory-provider";
import UserAvatar from "./user/user-avatar";
import { UserDnsIdentityIcon } from "./user/user-dns-identity-icon";
import UserDnsIdentity from "./user/user-dns-identity";
import { getWebOfTrust } from "../services/web-of-trust";
export type PeopleToken = { pubkey: string; names: string[] };
type Token = Emoji | PeopleToken;
@ -39,7 +40,7 @@ const Item = ({ entity }: ItemComponentProps<Token>) => {
return (
<span>
<UserAvatar pubkey={entity.pubkey} size="xs" /> {entity.names[0]}{" "}
<UserDnsIdentityIcon pubkey={entity.pubkey} onlyIcon />
<UserDnsIdentity pubkey={entity.pubkey} onlyIcon />
</span>
);
} else return null;
@ -73,7 +74,14 @@ function useAutocompleteTriggers() {
"@": {
dataProvider: async (token: string) => {
const dir = await getDirectory();
return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10);
return matchSorter(dir, token.trim(), {
keys: ["names"],
sorter: (items) =>
getWebOfTrust().sortByDistanceAndConnections(
items.sort((a, b) => b.rank - a.rank),
(i) => i.item.pubkey,
),
}).slice(0, 10);
},
component: Item,
output,

View File

@ -0,0 +1,18 @@
import { useMemo } from "react";
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { MediaServerIcon } from "../icons";
export type RelayFaviconProps = Omit<AvatarProps, "src"> & {
server: string;
};
export default function MediaServerFavicon({ server, ...props }: RelayFaviconProps) {
const url = useMemo(() => {
const url = new URL(server);
url.protocol = "https:";
url.pathname = "/favicon.ico";
return url.toString();
}, [server]);
return <Avatar src={url} icon={<MediaServerIcon />} overflow="hidden" {...props} />;
}

View File

@ -6,7 +6,7 @@ import { useRegisterIntersectionEntity } from "../../providers/local/intersectio
import { getEventUID } from "../../helpers/nostr/event";
import Timestamp from "../timestamp";
import UserLink from "../user/user-link";
import { UserDnsIdentityIcon } from "../user/user-dns-identity-icon";
import UserDnsIdentity from "../user/user-dns-identity";
import useEventReactions from "../../hooks/use-event-reactions";
import EventReactionButtons from "../event-reactions/event-reactions";
import { IconThreadButton } from "../../views/dms/components/thread-button";
@ -49,7 +49,7 @@ export default function MessageBubble({
{showHeader && (
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
<UserLink pubkey={message.pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={message.pubkey} onlyIcon />
<UserDnsIdentity pubkey={message.pubkey} onlyIcon />
{actionPosition === "header" && (
<ButtonGroup size="xs" variant="ghost" ml="auto">
{actions}

View File

@ -7,7 +7,6 @@ import { NostrEvent } from "../../types/nostr-event";
import { DotsMenuButton, MenuIconButtonProps } from "../dots-menu-button";
import NoteTranslationModal from "../../views/tools/transform-note/translation";
import Translate01 from "../icons/translate-01";
import InfoCircle from "../icons/info-circle";
import PinNoteMenuItem from "../common-menu-items/pin-note";
import CopyShareLinkMenuItem from "../common-menu-items/copy-share-link";
import OpenInAppMenuItem from "../common-menu-items/open-in-app";
@ -19,11 +18,7 @@ import Recording02 from "../icons/recording-02";
import { usePublishEvent } from "../../providers/global/publish-provider";
import DebugEventMenuItem from "../debug-modal/debug-event-menu-item";
export default function NoteMenu({
event,
detailsClick,
...props
}: { event: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const translationsModal = useDisclosure();
const publish = usePublishEvent();
@ -59,11 +54,6 @@ export default function NoteMenu({
Broadcast
</MenuItem>
<PinNoteMenuItem event={event} />
{detailsClick && (
<MenuItem onClick={detailsClick} icon={<InfoCircle />}>
Details
</MenuItem>
)}
<DebugEventMenuItem event={event} />
</DotsMenuButton>

View File

@ -1,20 +0,0 @@
import { IconButton, IconButtonProps } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import InfoCircle from "../../../icons/info-circle";
import useEventReactions from "../../../../hooks/use-event-reactions";
import { getEventUID } from "../../../../helpers/nostr/event";
import useEventZaps from "../../../../hooks/use-event-zaps";
export function NoteDetailsButton({
event,
...props
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "aria-label">) {
const uuid = getEventUID(event);
const reactions = useEventReactions(uuid) ?? [];
const zaps = useEventZaps(uuid);
if (reactions.length === 0 && zaps.length === 0) return null;
return <IconButton icon={<InfoCircle />} aria-label="Note Details" title="Note Details" {...props} />;
}

View File

@ -0,0 +1,58 @@
import { NostrEvent, nip19 } from "nostr-tools";
import { Flex, Link, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getThreadReferences, truncatedId } from "../../../../helpers/nostr/event";
import UserLink from "../../../user/user-link";
import useSingleEvent from "../../../../hooks/use-single-event";
import { CompactNoteContent } from "../../../compact-note-content";
import { ReplyIcon } from "../../../icons";
function ReplyToE({ pointer }: { pointer: nip19.EventPointer }) {
const event = useSingleEvent(pointer.id, pointer.relays);
if (!event) {
const nevent = nip19.neventEncode(pointer);
return (
<Text>
Replying to{" "}
<Link as={RouterLink} to={`/l/${nevent}`} color="blue.500">
{truncatedId(nevent)}
</Link>
</Text>
);
}
return (
<>
<Text>
Replying to <UserLink pubkey={event.pubkey} fontWeight="bold" />
</Text>
<CompactNoteContent event={event} maxLength={96} isTruncated textOnly />
</>
);
}
function ReplyToA({ pointer }: { pointer: nip19.AddressPointer }) {
const naddr = nip19.naddrEncode(pointer);
return (
<Text>
Replying to{" "}
<Link as={RouterLink} to={`/l/${naddr}`} color="blue.500">
{truncatedId(naddr)}
</Link>
</Text>
);
}
export default function ReplyContext({ event }: { event: NostrEvent }) {
const refs = getThreadReferences(event);
if (!refs.reply) return null;
return (
<Flex gap="2" fontStyle="italic" alignItems="center" whiteSpace="nowrap">
<ReplyIcon />
{refs.reply.e ? <ReplyToE pointer={refs.reply.e} /> : <ReplyToA pointer={refs.reply.a} />}
</Flex>
);
}

View File

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

View File

@ -11,7 +11,6 @@ import {
IconButton,
Link,
LinkBox,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
@ -20,7 +19,6 @@ import { Link as RouterLink } from "react-router-dom";
import NoteMenu from "../note-menu";
import UserLink from "../../user/user-link";
import { UserDnsIdentityIcon } from "../../user/user-dns-identity-icon";
import NoteZapButton from "../note-zap-button";
import { ExpandProvider } from "../../../providers/local/expanded";
import useSubject from "../../../hooks/use-subject";
@ -36,73 +34,20 @@ import BookmarkButton from "../bookmark-button";
import useCurrentAccount from "../../../hooks/use-current-account";
import NoteReactions from "./components/note-reactions";
import ReplyForm from "../../../views/thread/components/reply-form";
import { getThreadReferences, truncatedId } from "../../../helpers/nostr/event";
import { getThreadReferences } from "../../../helpers/nostr/event";
import Timestamp from "../../timestamp";
import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
import NoteCommunityMetadata from "./note-community-metadata";
import useSingleEvent from "../../../hooks/use-single-event";
import { CompactNoteContent } from "../../compact-note-content";
import NoteProxyLink from "./components/note-proxy-link";
import { NoteDetailsButton } from "./components/note-details-button";
import EventInteractionDetailsModal from "../../event-interactions-modal";
import singleEventService from "../../../services/single-event";
import { AddressPointer, EventPointer } from "nostr-tools/lib/types/nip19";
import { nip19 } from "nostr-tools";
import POWIcon from "../../pow/pow-icon";
import ReplyContext from "./components/reply-context";
import ZapBubbles from "./components/zap-bubbles";
function ReplyToE({ pointer }: { pointer: EventPointer }) {
const event = useSingleEvent(pointer.id, pointer.relays);
if (!event) {
const nevent = nip19.neventEncode(pointer);
return (
<Text>
Replying to{" "}
<Link as={RouterLink} to={`/l/${nevent}`} color="blue.500">
{truncatedId(nevent)}
</Link>
</Text>
);
}
return (
<>
<Text>
Replying to <UserLink pubkey={event.pubkey} fontWeight="bold" />
</Text>
<CompactNoteContent event={event} maxLength={96} isTruncated textOnly />
</>
);
}
function ReplyToA({ pointer }: { pointer: AddressPointer }) {
const naddr = nip19.naddrEncode(pointer);
return (
<Text>
Replying to{" "}
<Link as={RouterLink} to={`/l/${naddr}`} color="blue.500">
{truncatedId(naddr)}
</Link>
</Text>
);
}
function ReplyLine({ event }: { event: NostrEvent }) {
const refs = getThreadReferences(event);
if (!refs.reply) return null;
return (
<Flex gap="2" fontStyle="italic" alignItems="center" whiteSpace="nowrap">
<ReplyIcon />
{refs.reply.e ? <ReplyToE pointer={refs.reply.e} /> : <ReplyToA pointer={refs.reply.a} />}
</Flex>
);
}
export type NoteProps = Omit<CardProps, "children"> & {
export type TimelineNoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
variant?: CardProps["variant"];
showReplyButton?: boolean;
@ -120,11 +65,10 @@ export function TimelineNote({
registerIntersectionEntity = true,
clickable = true,
...props
}: NoteProps) {
}: TimelineNoteProps) {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const replyForm = useDisclosure();
const detailsModal = useDisclosure();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
@ -152,9 +96,11 @@ export function TimelineNote({
)}
<CardHeader p="2">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getSharableEventAddress(event)}`}>
<Timestamp timestamp={event.created_at} />
</Link>
<POWIcon event={event} boxSize={5} />
<Flex grow={1} />
{showSignatureVerification && <EventVerificationIcon event={event} />}
@ -166,17 +112,15 @@ export function TimelineNote({
onClick={() => singleEventService.handleEvent(event)}
/>
)}
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getSharableEventAddress(event)}`}>
<Timestamp timestamp={event.created_at} />
</Link>
</Flex>
<NoteCommunityMetadata event={event} />
{showReplyLine && <ReplyLine event={event} />}
{showReplyLine && <ReplyContext event={event} />}
</CardHeader>
<CardBody p="0">
<NoteContentWithWarning event={event} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
<ZapBubbles event={event} />
{showReactionsOnNewLine && reactionButtons}
<Flex gap="2" w="full" alignItems="center">
<ButtonGroup size="sm" variant="ghost" isDisabled={account?.readonly ?? true}>
@ -191,9 +135,8 @@ export function TimelineNote({
<Box flexGrow={1} />
<ButtonGroup size="sm" variant="ghost">
<NoteProxyLink event={event} />
<NoteDetailsButton event={event} onClick={detailsModal.onOpen} />
<BookmarkButton event={event} aria-label="Bookmark note" />
<NoteMenu event={event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
<NoteMenu event={event} aria-label="More Options" />
</ButtonGroup>
</Flex>
</CardFooter>
@ -206,7 +149,6 @@ export function TimelineNote({
onSubmitted={replyForm.onClose}
/>
)}
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={event} />}
</TrustProvider>
);
}

View File

@ -22,6 +22,7 @@ import {
SliderTrack,
SliderFilledTrack,
SliderThumb,
ModalCloseButton,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useForm } from "react-hook-form";
@ -214,7 +215,7 @@ export default function PostModal({
onChange={onFileInputChange}
/>
<IconButton
icon={<UploadImageIcon />}
icon={<UploadImageIcon boxSize={6} />}
aria-label="Upload Image"
title="Upload Image"
onClick={() => imageUploadRef.current?.click()}
@ -298,6 +299,7 @@ export default function PostModal({
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
{publishAction && <ModalCloseButton />}
<ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2">
{renderContent()}
</ModalBody>

View File

@ -17,8 +17,8 @@ export function PublishDetails({ pub }: PostResultsProps & Omit<FlexProps, "chil
<Flex direction="column" gap="2">
<EmbedEvent event={pub.event} />
<Progress value={(results.length / pub.relays.length) * 100} size="lg" hasStripe />
{results.map(({ result, relay }) => (
<Alert key={relay.url} status={result[2] ? "success" : "warning"}>
{results.map(({ success, message, relay }) => (
<Alert key={relay.url} status={success ? "success" : "warning"}>
<AlertIcon />
<Box>
<AlertTitle>
@ -27,7 +27,7 @@ export function PublishDetails({ pub }: PostResultsProps & Omit<FlexProps, "chil
</Link>
<RelayPaidTag url={relay.url} />
</AlertTitle>
{result[3] && <AlertDescription>{result[3]}</AlertDescription>}
{message && <AlertDescription>{message}</AlertDescription>}
</Box>
</Alert>
))}

View File

@ -1,3 +1,4 @@
import { useContext } from "react";
import {
Flex,
FlexProps,
@ -19,14 +20,13 @@ import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { CheckIcon, ErrorIcon } from "./icons";
import { PublishDetails } from "./publish-details";
import { useContext } from "react";
import { PublishContext } from "../providers/global/publish-provider";
export function PublishActionStatusTag({ pub, ...props }: { pub: NostrPublishAction } & Omit<TagProps, "children">) {
const results = useSubject(pub.results);
const successful = results.filter(({ result }) => result[2]);
const failedWithMessage = results.filter(({ result }) => !result[2] && result[3]);
const successful = results.filter(({ success }) => success);
const failedWithMessage = results.filter(({ success, message }) => !success && !!message);
let statusIcon = <Spinner size="xs" />;
let statusColor: TagProps["colorScheme"] = "blue";

View File

@ -56,6 +56,7 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
/>
{quickReactions.map((emoji) => (
<IconButton
key={emoji}
icon={<span>{emoji}</span>}
aria-label="Shaka"
variant="ghost"

View File

@ -9,7 +9,6 @@ import {
DrawerOverlay,
DrawerProps,
Flex,
Heading,
IconButton,
Link,
Select,
@ -36,21 +35,9 @@ import { SaveRelaySetForm } from "./save-relay-set-form";
function RelayControl({ url }: { url: string }) {
const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]);
const status = useSubject(relay.status);
const writeRelays = useSubject(clientRelaysService.writeRelays);
let color = "gray";
switch (status) {
case WebSocket.OPEN:
color = "green";
break;
case WebSocket.CONNECTING:
color = "yellow";
break;
case WebSocket.CLOSED:
color = "red";
break;
}
const color = relay.connected ? "green" : "red";
const onChange = () => {
if (writeRelays.has(url)) clientRelaysService.removeRelay(url, RelayMode.WRITE);
@ -117,7 +104,7 @@ export default function RelayManagementDrawer({ isOpen, onClose, ...props }: Omi
const sorted = useMemo(() => RelaySet.from(readRelays, writeRelays).urls.sort(), [readRelays, writeRelays]);
const others = Array.from(relayPoolService.relays.values())
.filter((r) => !r.closed && !sorted.includes(r.url))
.filter((r) => !r.connected && !sorted.includes(r.url))
.map((r) => r.url)
.sort();

View File

@ -1,22 +1,24 @@
import { Badge, useForceUpdate } from "@chakra-ui/react";
import { useInterval } from "react-use";
import Relay from "../classes/relay";
import relayPoolService from "../services/relay-pool";
import { Relay } from "nostr-tools";
const getStatusText = (relay: Relay) => {
if (relay.connecting) return "Connecting...";
// if (relay.connecting) return "Connecting...";
if (relay.connected) return "Connected";
if (relay.closing) return "Disconnecting...";
if (relay.closed) return "Disconnected";
return "Unused";
// if (relay.closing) return "Disconnecting...";
// if (relay.closed) return "Disconnected";
return "Disconnected";
// return "Unused";
};
const getStatusColor = (relay: Relay) => {
if (relay.connecting) return "yellow";
// if (relay.connecting) return "yellow";
if (relay.connected) return "green";
if (relay.closing) return "yellow";
if (relay.closed) return "red";
return "gray";
// if (relay.closing) return "yellow";
// if (relay.closed) return "red";
// return "gray";
return "red";
};
export const RelayStatus = ({ url }: { url: string }) => {

View File

@ -1,7 +1,8 @@
import { IconButton, IconButtonProps } from "@chakra-ui/react";
import { ChevronLeftIcon } from "../icons";
import { useNavigate } from "react-router-dom";
import { ChevronLeftIcon } from "../icons";
export default function BackButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) {
const navigate = useNavigate();
return (

View File

@ -6,7 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import TimelineNote from "../../note/timeline-note";
import UserAvatar from "../../user/user-avatar";
import { UserDnsIdentityIcon } from "../../user/user-dns-identity-icon";
import UserDnsIdentity from "../../user/user-dns-identity";
import UserLink from "../../user/user-link";
import { TrustProvider } from "../../../providers/local/trust";
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
@ -41,7 +41,7 @@ function RepostEvent({ event }: { event: NostrEvent }) {
<Heading size="sm" display="inline" isTruncated whiteSpace="pre">
<UserLink pubkey={event.pubkey} />
</Heading>
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<UserDnsIdentity pubkey={event.pubkey} onlyIcon />
<Text as="span" whiteSpace="pre">
{communityCoordinate ? `Shared to` : `Shared`}
</Text>

View File

@ -1,5 +1,5 @@
import { forwardRef, memo, useMemo } from "react";
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { Avatar, AvatarBadge, AvatarProps } from "@chakra-ui/react";
import { useAsync } from "react-use";
import useUserMetadata from "../../hooks/use-user-metadata";
@ -9,11 +9,19 @@ import { Kind0ParsedContent, getUserDisplayName } from "../../helpers/nostr/user
import useAppSettings from "../../hooks/use-app-settings";
import useCurrentAccount from "../../hooks/use-current-account";
import { buildImageProxyURL } from "../../helpers/image";
import UserDnsIdentityIcon, { useDnsIdentityColor } from "./user-dns-identity-icon";
import styled from "@emotion/styled";
export const UserIdenticon = memo(({ pubkey }: { pubkey: string }) => {
const { value: identicon } = useAsync(() => getIdenticon(pubkey), [pubkey]);
return identicon ? <img src={`data:image/svg+xml;base64,${identicon}`} width="100%" /> : null;
return identicon ? (
<img
src={`data:image/svg+xml;base64,${identicon}`}
width="100%"
style={{ borderRadius: "var(--chakra-radii-lg)" }}
/>
) : null;
});
const RESIZE_PROFILE_SIZE = 96;
@ -22,19 +30,55 @@ export type UserAvatarProps = Omit<MetadataAvatarProps, "pubkey" | "metadata"> &
pubkey: string;
relay?: string;
};
export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(({ pubkey, noProxy, relay, ...props }, ref) => {
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
return <MetadataAvatar pubkey={pubkey} metadata={metadata} noProxy={noProxy} ref={ref} {...props} />;
});
export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(
({ pubkey, noProxy, relay, size, ...props }, ref) => {
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
const color = useDnsIdentityColor(pubkey);
return (
<MetadataAvatar
pubkey={pubkey}
metadata={metadata}
noProxy={noProxy}
ref={ref}
borderColor={size !== "xs" ? color : undefined}
borderStyle="none"
size={size}
{...props}
>
{size !== "xs" && (
<UserDnsIdentityIcon
pubkey={pubkey}
position="absolute"
right={-1}
bottom={-1}
bgColor="white"
borderRadius="50%"
boxSize="1em"
/>
)}
</MetadataAvatar>
);
},
);
UserAvatar.displayName = "UserAvatar";
const SquareAvatar = styled(Avatar)`
img {
border-radius: var(--chakra-radii-lg);
border-width: 0.18rem;
border-color: inherit;
border-style: solid;
}
`;
export type MetadataAvatarProps = Omit<AvatarProps, "src"> & {
metadata?: Kind0ParsedContent;
pubkey?: string;
noProxy?: boolean;
square?: boolean;
};
export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
({ pubkey, metadata, noProxy, ...props }, ref) => {
({ pubkey, metadata, noProxy, children, square = true, ...props }, ref) => {
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
const account = useCurrentAccount();
const picture = useMemo(() => {
@ -52,18 +96,21 @@ export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
}
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
const AvatarComponent = square ? SquareAvatar : Avatar;
return (
<Avatar
<AvatarComponent
src={picture}
icon={pubkey ? <UserIdenticon pubkey={pubkey} /> : undefined}
overflow="hidden"
// overflow="hidden"
title={getUserDisplayName(metadata, pubkey ?? "")}
ref={ref}
{...props}
/>
>
{children}
</AvatarComponent>
);
},
);
UserAvatar.displayName = "UserAvatar";
export default memo(UserAvatar);

View File

@ -1,10 +1,31 @@
import { Text, Tooltip } from "@chakra-ui/react";
import { forwardRef } from "react";
import { IconProps, useColorMode } from "@chakra-ui/react";
import useDnsIdentity from "../../hooks/use-dns-identity";
import useUserMetadata from "../../hooks/use-user-metadata";
import { VerificationFailed, VerificationMissing, VerifiedIcon } from "../icons";
export function UserDnsIdentityIcon({ pubkey, onlyIcon }: { pubkey: string; onlyIcon?: boolean }) {
export function useDnsIdentityColor(pubkey: string) {
const metadata = useUserMetadata(pubkey);
const identity = useDnsIdentity(metadata?.nip05);
const { colorMode } = useColorMode();
if (!metadata?.nip05) {
return colorMode === "light" ? "gray.200" : "gray.800";
}
if (identity === undefined) {
return "yellow.500";
} else if (identity === null) {
return "red.500";
} else if (pubkey === identity.pubkey) {
return "purple.500";
} else {
return "red.500";
}
}
const UserDnsIdentityIcon = forwardRef<SVGSVGElement, { pubkey: string } & IconProps>(({ pubkey, ...props }, ref) => {
const metadata = useUserMetadata(pubkey);
const identity = useDnsIdentity(metadata?.nip05);
@ -12,26 +33,14 @@ export function UserDnsIdentityIcon({ pubkey, onlyIcon }: { pubkey: string; only
return null;
}
const renderIcon = () => {
if (identity === undefined) {
return <VerificationFailed color="yellow.500" />;
} else if (identity === null) {
return <VerificationMissing color="red.500" />;
} else if (pubkey === identity.pubkey) {
return <VerifiedIcon color="purple.500" />;
} else {
return <VerificationFailed color="red.500" />;
}
};
if (onlyIcon) {
return <Tooltip label={metadata.nip05}>{renderIcon()}</Tooltip>;
if (identity === undefined) {
return <VerificationFailed color="yellow.500" {...props} ref={ref} />;
} else if (identity === null) {
return <VerificationMissing color="red.500" {...props} ref={ref} />;
} else if (pubkey === identity.pubkey) {
return <VerifiedIcon color="purple.500" {...props} ref={ref} />;
} else {
return <VerificationFailed color="red.500" {...props} ref={ref} />;
}
return (
<Text as="span" whiteSpace="nowrap">
{metadata.nip05.startsWith("_@") ? metadata.nip05.substr(2) : metadata.nip05} {renderIcon()}
</Text>
);
}
});
export default UserDnsIdentityIcon;

View File

@ -0,0 +1,23 @@
import { Text, Tooltip } from "@chakra-ui/react";
import useUserMetadata from "../../hooks/use-user-metadata";
import UserDnsIdentityIcon from "./user-dns-identity-icon";
export default function UserDnsIdentity({ pubkey, onlyIcon }: { pubkey: string; onlyIcon?: boolean }) {
const metadata = useUserMetadata(pubkey);
if (!metadata?.nip05) return null;
if (onlyIcon) {
return (
<Tooltip label={metadata.nip05}>
<UserDnsIdentityIcon pubkey={pubkey} />
</Tooltip>
);
}
return (
<Text as="span" whiteSpace="nowrap">
{metadata.nip05.startsWith("_@") ? metadata.nip05.substr(2) : metadata.nip05}{" "}
<UserDnsIdentityIcon pubkey={pubkey} />
</Text>
);
}

View File

@ -0,0 +1,23 @@
import { NostrEvent } from "nostr-tools";
import { safeUrl } from "../parse";
import { BlobDescriptor, BlossomClient, Signer } from "blossom-client-sdk";
export function getServersFromEvent(event: NostrEvent) {
return event.tags
.filter((t) => t[0] === "r")
.map((t) => safeUrl(t[1]))
.filter(Boolean) as string[];
}
export async function uploadFileToServers(servers: string[], file: File, signer: Signer) {
const results: BlobDescriptor[] = [];
const auth = await BlossomClient.getUploadAuth(file, signer);
for (const server of servers) {
try {
results.push(await BlossomClient.uploadBlob(server, file, auth));
} catch (e) {}
}
return results[0];
}

View File

@ -1,5 +1,5 @@
import { nip98 } from "nostr-tools";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
type NostrBuildResponse = {
status: "success" | "error";

View File

@ -39,22 +39,22 @@ export function pointerMatchEvent(event: NostrEvent, pointer: AddressPointer | E
const isReplySymbol = Symbol("isReply");
export function isReply(event: NostrEvent | DraftNostrEvent) {
// @ts-ignore
// @ts-expect-error
if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false;
const isReply = !!getThreadReferences(event).reply;
// @ts-ignore
// @ts-expect-error
event[isReplySymbol] = isReply;
return isReply;
}
export function isMentionedInContent(event: NostrEvent | DraftNostrEvent, pubkey: string) {
export function isPTagMentionedInContent(event: NostrEvent | DraftNostrEvent, pubkey: string) {
return filterTagsByContentRefs(event.content, event.tags).some((t) => t[1] === pubkey);
}
const isRepostSymbol = Symbol("isRepost");
export function isRepost(event: NostrEvent | DraftNostrEvent) {
// @ts-ignore
// @ts-expect-error
if (event[isRepostSymbol] !== undefined) return event[isRepostSymbol] as boolean;
if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return true;
@ -62,7 +62,7 @@ export function isRepost(event: NostrEvent | DraftNostrEvent) {
const match = event.content.match(getMatchNostrLink());
const isRepost = !!match && match[0].length === event.content.length;
// @ts-ignore
// @ts-expect-error
event[isRepostSymbol] = isRepost;
return isRepost;
}
@ -165,11 +165,27 @@ export function interpretThreadTags(event: NostrEvent | DraftNostrEvent) {
};
}
export type EventReferences = ReturnType<typeof getThreadReferences>;
export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
const tags = interpretThreadTags(event);
export type ThreadReferences = {
root?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
reply?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
};
export const threadRefsSymbol = Symbol("threadRefs");
export type EventWithThread = (NostrEvent | DraftNostrEvent) & { [threadRefsSymbol]: ThreadReferences };
return {
export function getThreadReferences(event: NostrEvent | DraftNostrEvent): ThreadReferences {
// @ts-expect-error
if (Object.hasOwn(event, threadRefsSymbol)) return event[threadRefsSymbol];
const e = event as EventWithThread;
const tags = interpretThreadTags(e);
const threadRef = {
root: tags.root && {
e: tags.root.e && eTagToEventPointer(tags.root.e),
a: tags.root.a && aTagToAddressPointer(tags.root.a),
@ -178,16 +194,12 @@ export function getThreadReferences(event: NostrEvent | DraftNostrEvent) {
e: tags.reply.e && eTagToEventPointer(tags.reply.e),
a: tags.reply.a && aTagToAddressPointer(tags.reply.a),
},
} as {
root?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
reply?:
| { e: EventPointer; a: undefined }
| { e: undefined; a: AddressPointer }
| { e: EventPointer; a: AddressPointer };
};
} as ThreadReferences;
// @ts-expect-error
event[threadRefsSymbol] = threadRef;
return threadRef;
}
export function getEventCoordinate(event: NostrEvent) {
@ -289,11 +301,13 @@ export function addPubkeyRelayHints(draft: DraftNostrEvent) {
...draft,
tags: draft.tags.map((t) => {
if (isPTag(t) && !t[2]) {
const newTag = [...t];
const mailboxes = userMailboxesService.getMailboxes(t[1]).value;
// TODO: Pick the best mailbox for the user
if (mailboxes) newTag[2] = mailboxes.inbox.urls[0];
return newTag;
if (mailboxes && mailboxes.inbox.urls.length > 0) {
const newTag = [...t];
// TODO: Pick the best mailbox for the user
newTag[2] = mailboxes.inbox.urls[0];
return newTag;
} else return t;
}
return t;
}),

View File

@ -5,15 +5,15 @@ import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRT
import { parseCoordinate, replaceOrAddSimpleTag } from "./event";
import { getRelayVariations, safeRelayUrls } from "../relay";
export const MUTE_LIST_KIND = 10000;
export const PIN_LIST_KIND = 10001;
export const BOOKMARK_LIST_KIND = 10003;
export const COMMUNITIES_LIST_KIND = 10004;
export const CHANNELS_LIST_KIND = 10005;
export const MUTE_LIST_KIND = kinds.Mutelist;
export const PIN_LIST_KIND = kinds.Pinlist;
export const BOOKMARK_LIST_KIND = kinds.BookmarkList;
export const COMMUNITIES_LIST_KIND = kinds.CommunitiesList;
export const CHANNELS_LIST_KIND = kinds.PublicChatsList;
export const PEOPLE_LIST_KIND = 30000;
export const PEOPLE_LIST_KIND = kinds.Followsets;
export const NOTE_LIST_KIND = 30001;
export const BOOKMARK_LIST_SET_KIND = 30003;
export const BOOKMARK_LIST_SET_KIND = kinds.Bookmarksets;
export function getListName(event: NostrEvent) {
if (event.kind === kinds.Contacts) return "Following";

View File

@ -1,14 +1,12 @@
import { DraftNostrEvent, NostrEvent, Tag, isETag, isPTag } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event";
import { getMatchEmoji, getMatchHashtag, getMatchNostrLink } from "../regexp";
import { addPubkeyRelayHints, getThreadReferences } from "./event";
import { getPubkeyFromDecodeResult, safeDecode } from "../nip19";
import { safeDecode } from "../nip19";
import { Emoji } from "../../providers/global/emoji-provider";
import { EventSplit } from "./zaps";
import { unique } from "../array";
import relayHintService from "../../services/event-relay-hint";
import { EventTemplate } from "nostr-tools";
import RelaySet from "../../classes/relay-set";
import userMailboxesService from "../../services/user-mailboxes";
import { nip19 } from "nostr-tools";
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
@ -103,6 +101,38 @@ export function ensureNotifyContentMentions(draft: DraftNostrEvent) {
return mentions.length > 0 ? ensureNotifyPubkeys(draft, mentions) : draft;
}
export function getAllEventsMentionedInContent(content: string) {
const matched = content.matchAll(getMatchNostrLink());
const events: nip19.EventPointer[] = [];
for (const match of matched) {
const decode = safeDecode(match[2]);
if (!decode) continue;
switch (decode.type) {
case "note":
events.push({ id: decode.data });
break;
case "nevent":
events.push(decode.data);
break;
}
}
return events;
}
export function ensureTagContentMentions(draft: DraftNostrEvent) {
const mentions = getAllEventsMentionedInContent(draft.content);
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
for (const pointer of mentions) {
updated.tags = AddEtag(updated.tags, pointer.id, pointer.relays?.[0] ?? "", "mention", false);
}
return updated;
}
export function createHashtagTags(draft: DraftNostrEvent) {
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
@ -148,5 +178,6 @@ export function finalizeNote(draft: DraftNostrEvent) {
updated.content = correctContentMentions(updated.content);
updated = createHashtagTags(updated);
updated = addPubkeyRelayHints(updated);
updated = ensureTagContentMentions(updated);
return updated;
}

View File

@ -28,7 +28,7 @@ export function draftEventReaction(event: NostrEvent, emoji = "+", url?: string)
];
let content = emoji;
if (url && !content.startsWith(":") && content.endsWith(":")) content = ":" + content + ":";
if (url && !content.startsWith(":") && !content.endsWith(":")) content = ":" + content + ":";
const draft: DraftNostrEvent = {
kind: kinds.Reaction,
@ -37,7 +37,7 @@ export function draftEventReaction(event: NostrEvent, emoji = "+", url?: string)
created_at: dayjs().unix(),
};
if (url) draft.tags.push(["emoji", emoji, url]);
if (url) draft.tags.push(["emoji", emoji.replaceAll(/(^:|:$)/g, ""), url]);
return draft;
}

View File

@ -3,7 +3,7 @@ import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { ParsedInvoice, parsePaymentRequest } from "../bolt11";
import { Kind0ParsedContent } from "./user-metadata";
import { utils } from "nostr-tools";
import { nip57, utils, validateEvent } from "nostr-tools";
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
@ -52,19 +52,57 @@ export type ParsedZap = {
eventId?: string;
};
const parsedZapSymbol = Symbol("parsedZap");
type ParsedZapEvent = NostrEvent & { [parsedZapSymbol]: ParsedZap | Error };
export function getParsedZap(event: NostrEvent, quite: false, returnError?: boolean): ParsedZap;
export function getParsedZap(event: NostrEvent, quite: true, returnError: true): ParsedZap | Error;
export function getParsedZap(event: NostrEvent, quite: true, returnError: false): ParsedZap | undefined;
export function getParsedZap(event: NostrEvent, quite?: boolean, returnError?: boolean): ParsedZap | undefined;
export function getParsedZap(event: NostrEvent, quite: boolean = true, returnError?: boolean) {
const e = event as ParsedZapEvent;
if (Object.hasOwn(e, parsedZapSymbol)) {
const cached = e[parsedZapSymbol];
if (!returnError && cached instanceof Error) return undefined;
if (!quite && cached instanceof Error) throw cached;
return cached;
}
try {
return (e[parsedZapSymbol] = parseZapEvent(e));
} catch (error) {
if (error instanceof Error) {
e[parsedZapSymbol] = error;
if (quite) return returnError ? error : undefined;
else throw error;
} else throw error;
}
}
export function parseZapEvents(events: NostrEvent[]) {
const parsed: ParsedZap[] = [];
for (const event of events) {
const p = getParsedZap(event);
if (p) parsed.push(p);
}
return parsed;
}
/** @deprecated use getParsedZap instead */
export function parseZapEvent(event: NostrEvent): ParsedZap {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag");
if (!zapRequestStr) throw new Error("No description tag");
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
if (!bolt11) throw new Error("missing bolt11 invoice");
if (!bolt11) throw new Error("Missing bolt11 invoice");
// TODO: disabled until signature verification can be offloaded to a web worker
// const error = nip57.validateZapRequest(zapRequestStr);
// if (error) throw new Error(error);
const error = nip57.validateZapRequest(zapRequestStr);
if (error) throw new Error(error);
const request = JSON.parse(zapRequestStr) as NostrEvent;
if (!validateEvent(request)) throw new Error("Invalid zap request");
const payment = parsePaymentRequest(bolt11);
return {

View File

@ -1,8 +1,9 @@
import { SimpleRelay, SubscriptionOptions } from "nostr-idb";
import { Filter } from "nostr-tools";
import { AbstractRelay, Filter, SubCloser, SubscribeManyParams, Subscription } from "nostr-tools";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-relay";
import { NostrEvent } from "../types/nostr-event";
import relayPoolService from "../services/relay-pool";
// NOTE: only use this for equality checks and querying
export function getRelayVariations(relay: string) {
@ -110,3 +111,81 @@ export function relayRequest(relay: SimpleRelay, filters: Filter[], opts: Subscr
});
});
}
// copied from nostr-tools, SimplePool#subscribeMany
export function subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
const _knownIds = new Set<string>();
const subs: Subscription[] = [];
// batch all EOSEs into a single
const eosesReceived: boolean[] = [];
let handleEose = (i: number) => {
eosesReceived[i] = true;
if (eosesReceived.filter((a) => a).length === relays.length) {
params.oneose?.();
handleEose = () => {};
}
};
// batch all closes into a single
const closesReceived: string[] = [];
let handleClose = (i: number, reason: string) => {
handleEose(i);
closesReceived[i] = reason;
if (closesReceived.filter((a) => a).length === relays.length) {
params.onclose?.(closesReceived);
handleClose = () => {};
}
};
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true;
}
const have = _knownIds.has(id);
_knownIds.add(id);
return have;
};
// open a subscription in all given relays
const allOpened = Promise.all(
relays.map(validateRelayURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
// duplicate
handleClose(i, "duplicate url");
return;
}
let relay: AbstractRelay;
try {
relay = relayPoolService.requestRelay(url);
await relay.connect();
// changed from nostr-tools
// relay = await this.ensureRelay(url, {
// connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
// });
} catch (err) {
handleClose(i, (err as any)?.message || String(err));
return;
}
let subscription = relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: (reason) => handleClose(i, reason),
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
});
subs.push(subscription);
}),
);
return {
async close() {
await allOpened;
subs.forEach((sub) => {
sub.close();
});
},
};
}

View File

@ -19,7 +19,7 @@ export type SatelliteCDNFile = {
created: number;
magnet: string;
type: string;
name: string;
name?: string;
sha256: string;
size: number;
url: string;

View File

@ -1,5 +1,5 @@
import { NostrEvent } from "../types/nostr-event";
import { EventReferences, getThreadReferences } from "./nostr/event";
import { ThreadReferences, getThreadReferences } from "./nostr/event";
export function countReplies(replies: ThreadItem[]): number {
return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length;
@ -13,7 +13,7 @@ export type ThreadItem = {
/** the parent event this is replying to */
replyingTo?: ThreadItem;
/** refs from nostr event */
refs: EventReferences;
refs: ThreadReferences;
/** direct child replies */
replies: ThreadItem[];
};

View File

@ -3,7 +3,7 @@ import { useMemo } from "react";
import eventZapsService from "../services/event-zaps";
import { useReadRelays } from "./use-client-relays";
import useSubject from "./use-subject";
import { parseZapEvent } from "../helpers/nostr/zaps";
import { getParsedZap } from "../helpers/nostr/zaps";
export default function useEventZaps(eventUID: string, additionalRelays?: Iterable<string>, alwaysRequest = true) {
const readRelays = useReadRelays(additionalRelays);
@ -18,9 +18,8 @@ export default function useEventZaps(eventUID: string, additionalRelays?: Iterab
const zaps = useMemo(() => {
const parsed = [];
for (const zap of events) {
try {
parsed.push(parseZapEvent(zap));
} catch (e) {}
const p = getParsedZap(zap);
if (p) parsed.push(p);
}
return parsed;
}, [events]);

View File

@ -4,8 +4,9 @@ import { GOAL_KIND } from "../helpers/nostr/goal";
import { ParsedStream, getATag } from "../helpers/nostr/stream";
import { NostrEvent } from "../types/nostr-event";
import { useReadRelays } from "./use-client-relays";
import NostrRequest from "../classes/nostr-request";
import useSingleEvent from "./use-single-event";
import { subscribeMany } from "../helpers/relay";
import { Filter } from "nostr-tools";
export default function useStreamGoal(stream: ParsedStream) {
const [goal, setGoal] = useState<NostrEvent>();
@ -15,11 +16,11 @@ export default function useStreamGoal(stream: ParsedStream) {
useEffect(() => {
if (!stream.goal) {
const request = new NostrRequest(readRelays);
request.onEvent.subscribe((event) => {
setGoal(event);
const filter: Filter = { "#a": [getATag(stream)], kinds: [GOAL_KIND] };
const sub = subscribeMany(Array.from(readRelays), [filter], {
onevent: (event) => setGoal((c) => (!c || event.created_at > c.created_at ? event : c)),
oneose: () => sub.close(),
});
request.start({ "#a": [getATag(stream)], kinds: [GOAL_KIND] });
}
}, [stream.identifier, stream.goal, readRelays.urls.join("|")]);

View File

@ -1,10 +1,14 @@
import { ChangeEventHandler, ClipboardEventHandler, MutableRefObject, useCallback, useState } from "react";
import { useToast } from "@chakra-ui/react";
import { nostrBuildUploadImage } from "../helpers/nostr-build";
import { nostrBuildUploadImage } from "../helpers/media-upload/nostr-build";
import { RefType } from "../components/magic-textarea";
import { useSigningContext } from "../providers/global/signing-provider";
import { UseFormGetValues, UseFormSetValue } from "react-hook-form";
import useAppSettings from "./use-app-settings";
import useUsersMediaServers from "./use-user-media-servers";
import { getServersFromEvent, uploadFileToServers } from "../helpers/media-upload/blossom";
import useCurrentAccount from "./use-current-account";
export function useTextAreaUploadFileWithForm(
ref: MutableRefObject<RefType | null>,
@ -25,45 +29,55 @@ export default function useTextAreaUploadFile(
setText: (text: string) => void,
) {
const toast = useToast();
const account = useCurrentAccount();
const { mediaUploadService } = useAppSettings();
const mediaServers = useUsersMediaServers(account?.pubkey);
const { requestSignature } = useSigningContext();
const insertURL = useCallback(
(url: string) => {
const content = getText();
const position = ref.current?.getCaretPosition();
if (position !== undefined) {
let inject = url;
// add a space before
if (position >= 1 && content.slice(position - 1, position) !== " ") inject = " " + inject;
// add a space after
if (position < content.length && content.slice(position, position + 1) !== " ") inject = inject + " ";
setText(content.slice(0, position) + inject + content.slice(position));
} else {
let inject = url;
// add a space before if there isn't one
if (content.slice(content.length - 1) !== " ") inject = " " + inject;
setText(content + inject + " ");
}
},
[setText, getText],
);
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
setUploading(true);
try {
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
throw new Error("Unsupported file type");
setUploading(true);
const response = await nostrBuildUploadImage(file, requestSignature);
const imageUrl = response.url;
const content = getText();
const position = ref.current?.getCaretPosition();
if (position !== undefined) {
let inject = imageUrl;
// add a space before
if (position >= 1 && content.slice(position - 1, position) !== " ") inject = " " + inject;
// add a space after
if (position < content.length && content.slice(position, position + 1) !== " ") inject = inject + " ";
setText(content.slice(0, position) + inject + content.slice(position));
} else {
let inject = imageUrl;
// add a space before if there isn't one
if (content.slice(content.length - 1) !== " ") inject = " " + inject;
setText(content + inject + " ");
if (mediaUploadService === "nostr.build") {
const response = await nostrBuildUploadImage(file, requestSignature);
const imageUrl = response.url;
insertURL(imageUrl);
} else if (mediaUploadService === "blossom" && mediaServers) {
const blob = await uploadFileToServers(getServersFromEvent(mediaServers), file, requestSignature);
insertURL(blob.url);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
},
[setText, getText, toast, setUploading],
[insertURL, toast, setUploading, mediaServers, mediaUploadService],
);
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(

View File

@ -0,0 +1,10 @@
import replaceableEventsService, { RequestOptions } from "../services/replaceable-events";
import { useReadRelays } from "./use-client-relays";
import useSubject from "./use-subject";
export default function useUsersMediaServers(pubkey?: string, additionalRelays?: string[], opts?: RequestOptions) {
const readRelays = useReadRelays(additionalRelays);
const sub = pubkey ? replaceableEventsService.requestEvent(readRelays, 10063, pubkey, undefined, opts) : undefined;
const value = useSubject(sub);
return value;
}

View File

@ -4,6 +4,7 @@ import { App } from "./app";
import { GlobalProviders } from "./providers/global";
import "./services/user-event-sync";
import "./services/username-search";
import "./services/web-of-trust";
// setup bitcoin connect
import { init, onConnected } from "@getalby/bitcoin-connect-react";

View File

@ -4,7 +4,7 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { SigningProvider } from "./signing-provider";
import buildTheme from "../../theme";
import useAppSettings from "../../hooks/use-app-settings";
import NotificationTimelineProvider from "./notification-timeline";
import NotificationsProvider from "./notifications";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { AllUserSearchDirectoryProvider } from "./user-directory-provider";
import BreakpointProvider from "./breakpoint-provider";
@ -26,7 +26,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
<SigningProvider>
<PublishProvider>
<DecryptionProvider>
<NotificationTimelineProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
@ -34,7 +34,7 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationTimelineProvider>
</NotificationsProvider>
</DecryptionProvider>
</PublishProvider>
</SigningProvider>

View File

@ -1,4 +1,4 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { kinds } from "nostr-tools";
import { useReadRelays } from "../../hooks/use-client-relays";
@ -9,25 +9,27 @@ import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
import { useUserInbox } from "../../hooks/use-user-mailboxes";
import AccountNotifications from "../../classes/notifications";
type NotificationTimelineContextType = {
timeline?: TimelineLoader;
timeline: TimelineLoader;
notifications?: AccountNotifications;
};
const NotificationTimelineContext = createContext<NotificationTimelineContextType>({});
const NotificationTimelineContext = createContext<NotificationTimelineContextType | null>(null);
export function useNotificationTimeline() {
const context = useContext(NotificationTimelineContext);
if (!context?.timeline) throw new Error("No notification timeline");
return context.timeline;
export function useNotifications() {
const ctx = useContext(NotificationTimelineContext);
if (!ctx) throw new Error("Missing notifications provider");
return ctx;
}
export default function NotificationTimelineProvider({ children }: PropsWithChildren) {
export default function NotificationsProvider({ children }: PropsWithChildren) {
const account = useCurrentAccount();
const inbox = useUserInbox(account?.pubkey);
const readRelays = useReadRelays(inbox);
const [notifications, setNotifications] = useState<AccountNotifications>();
const userMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
@ -57,7 +59,22 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
{ eventFilter },
);
const context = useMemo(() => ({ timeline }), [timeline]);
useEffect(() => {
if (!account?.pubkey) return;
const n = new AccountNotifications(account.pubkey, timeline.events);
setNotifications(n);
if (import.meta.env.DEV) {
// @ts-expect-error
window.accountNotifications = n;
}
return () => {
n.destroy();
setNotifications(undefined);
};
}, [account?.pubkey, timeline.events]);
const context = useMemo(() => ({ timeline, notifications }), [timeline, notifications]);
return <NotificationTimelineContext.Provider value={context}>{children}</NotificationTimelineContext.Provider>;
}

View File

@ -22,12 +22,14 @@ type PublishContextType = {
event: EventTemplate | NostrEvent,
additionalRelays: Iterable<string> | undefined,
quite: false,
onlyAdditionalRelays: false
): Promise<NostrPublishAction>;
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays?: Iterable<string> | undefined,
quite?: boolean,
onlyAdditionalRelays?: boolean
): Promise<NostrPublishAction | undefined>;
};
export const PublishContext = createContext<PublishContextType>({
@ -47,14 +49,19 @@ export default function PublishProvider({ children }: PropsWithChildren) {
const { requestSignature } = useSigningContext();
const publishEvent = useCallback(
async (label: string, event: DraftNostrEvent | NostrEvent, additionalRelays?: Iterable<string>, quite = true) => {
async (label: string, event: DraftNostrEvent | NostrEvent, additionalRelays?: Iterable<string>, quite = true, onlyAdditionalRelays = false) => {
try {
const relays = RelaySet.from(
clientRelaysService.writeRelays.value,
clientRelaysService.outbox,
additionalRelays,
getAllRelayHints(event),
);
let relays;
if (onlyAdditionalRelays) {
relays = RelaySet.from(additionalRelays);
} else {
relays = RelaySet.from(
clientRelaysService.writeRelays.value,
clientRelaysService.outbox,
additionalRelays,
getAllRelayHints(event),
);
}
let signed: NostrEvent;
if (!Object.hasOwn(event, "sig")) {
@ -66,8 +73,8 @@ export default function PublishProvider({ children }: PropsWithChildren) {
const pub = new NostrPublishAction(label, relays, signed);
setLog((arr) => arr.concat(pub));
pub.onResult.subscribe(({ relay, result }) => {
if (result[2]) handleEventFromRelay(relay, signed);
pub.onResult.subscribe(({ relay, success }) => {
if (success) handleEventFromRelay(relay, signed);
});
// send it to the local relay

View File

@ -40,7 +40,7 @@ class DnsIdentityService {
async fetchIdentity(address: string) {
const { name, domain } = parseAddress(address);
if (!name || !domain) throw new Error("invalid address");
if (!name || !domain) throw new Error("invalid address " + address);
const json = await fetchWithCorsFallback(`https://${domain}/.well-known/nostr.json?name=${name}`)
.then((res) => res.json() as Promise<IdentityJson>)

View File

@ -3,37 +3,24 @@ import stringify from "json-stringify-deterministic";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrRequestFilter } from "../types/nostr-relay";
import NostrRequest from "../classes/nostr-request";
import relayPoolService from "./relay-pool";
/** @deprecated */
const COUNT_RELAY = "wss://relay.nostr.band";
const RATE_LIMIT = 1000;
import { localRelay } from "./local-relay";
class EventCountService {
subjects = new SuperMap<string, Subject<number>>(() => new Subject<number>());
constructor() {
window.setInterval(this.makeRequest.bind(this), RATE_LIMIT);
relayPoolService.addClaim(COUNT_RELAY, this);
}
stringifyFilter(filter: NostrRequestFilter) {
return stringify(filter);
}
private queue: NostrRequestFilter[] = [];
private queueKeys = new Set<string>();
requestCount(filter: NostrRequestFilter, alwaysRequest = false) {
const key = this.stringifyFilter(filter);
const sub = this.subjects.get(key);
if (sub.value === undefined || alwaysRequest) {
if (!this.queueKeys.has(key)) {
this.queue.push(filter);
this.queueKeys.add(key);
}
// try to get a count from the local relay
localRelay.count(Array.isArray(filter) ? filter : [filter], {}).then((count) => {
if (Number.isFinite(count)) sub.next(count);
});
}
return sub;
@ -44,19 +31,6 @@ class EventCountService {
const sub = this.subjects.get(key);
return sub;
}
makeRequest() {
const filter = this.queue.pop();
if (!filter) return;
const key = this.stringifyFilter(filter);
this.queueKeys.delete(key);
const sub = this.subjects.get(key);
const request = new NostrRequest([COUNT_RELAY]);
request.onCount.subscribe((c) => sub.next(c.count));
request.start(filter, "COUNT");
}
}
/** @deprecated */

View File

@ -3,13 +3,13 @@ import stringify from "json-stringify-deterministic";
import Subject from "../classes/subject";
import { NostrRequestFilter } from "../types/nostr-relay";
import SuperMap from "../classes/super-map";
import NostrRequest from "../classes/nostr-request";
import relayScoreboardService from "./relay-scoreboard";
import { logger } from "../helpers/debug";
import { matchFilter, matchFilters } from "nostr-tools";
import { NostrEvent } from "../types/nostr-event";
import { relayRequest } from "../helpers/relay";
import { localRelay } from "./local-relay";
import relayPoolService from "./relay-pool";
function hashFilter(filter: NostrRequestFilter) {
return stringify(filter);
@ -62,20 +62,23 @@ class EventExistsService {
relays.delete(nextRelay);
(async () => {
const sub = this.answers.get(key);
const request = new NostrRequest([nextRelay], 500);
const limitFilter = Array.isArray(filter) ? filter.map((f) => ({ ...f, limit: 1 })) : { ...filter, limit: 1 };
request.start(limitFilter);
request.onEvent.subscribe(() => {
this.log("Found event for", filter);
sub.next(true);
this.pending.delete(key);
const subject = this.answers.get(key);
const limitFilter = Array.isArray(filter) ? filter.map((f) => ({ ...f, limit: 1 })) : [{ ...filter, limit: 1 }];
const subscription = relayPoolService.requestRelay(nextRelay).subscribe(limitFilter, {
eoseTimeout: 500,
onevent: () => {
this.log("Found event for", filter);
subject.next(true);
this.pending.delete(key);
},
oneose: () => {
if (subject.value === undefined && this.asked.get(key).size > this.pending.get(key).size) {
this.log("Could not find event for", filter);
subject.next(false);
}
subscription.close();
},
});
await request.onComplete;
if (sub.value === undefined && this.asked.get(key).size > this.pending.get(key).size) {
this.log("Could not find event for", filter);
sub.next(false);
}
})();
}
}

View File

@ -1,12 +1,12 @@
import { Filter, kinds, nip25 } from "nostr-tools";
import _throttle from "lodash.throttle";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { localRelay } from "./local-relay";
import { relayRequest } from "../helpers/relay";
import relayPoolService from "./relay-pool";
type eventId = string;
type relay = string;
@ -73,9 +73,9 @@ class EventReactionsService {
if (coordinates.length > 0) filters.push({ "#a": coordinates, kinds: [kinds.Reaction] });
if (filters.length > 0) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe((e) => this.handleEvent(e));
request.start(filters);
const subscription = relayPoolService
.requestRelay(relay)
.subscribe(filters, { onevent: (event) => this.handleEvent(event), oneose: () => subscription.close() });
}
}
this.pending.clear();

View File

@ -1,8 +1,7 @@
import Relay from "../classes/relay";
import { Relay } from "nostr-tools";
import { PersistentSubject } from "../classes/subject";
import { getEventUID } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event";
import relayPoolService from "./relay-pool";
const eventRelays = new Map<string, PersistentSubject<string[]>>();
@ -30,11 +29,12 @@ export function handleEventFromRelay(relay: Relay, event: NostrEvent) {
if (event.id !== uid) addRelay(event.id, relay.url);
}
relayPoolService.onRelayCreated.subscribe((relay) => {
relay.onEvent.subscribe((message) => {
handleEventFromRelay(relay, message[2]);
});
});
// TODO: track events from relays
// relayPoolService.onRelayCreated.subscribe((relay) => {
// relay.onEvent.subscribe((message) => {
// handleEventFromRelay(relay, message[2]);
// });
// });
const eventRelaysService = {
getEventRelays,

View File

@ -1,12 +1,12 @@
import { Filter, kinds } from "nostr-tools";
import _throttle from "lodash.throttle";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
import { relayRequest } from "../helpers/relay";
import { localRelay } from "./local-relay";
import relayPoolService from "./relay-pool";
type eventUID = string;
type relay = string;
@ -73,9 +73,9 @@ class EventZapsService {
if (coordinates.length > 0) filter.push({ "#a": coordinates, kinds: [kinds.Zap] });
if (filter.length > 0) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe((e) => this.handleEvent(e));
request.start(filter);
const sub = relayPoolService
.requestRelay(relay)
.subscribe(filter, { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() });
}
}
this.pending.clear();

View File

@ -2,6 +2,7 @@ import { CacheRelay, openDB } from "nostr-idb";
import { Relay } from "nostr-tools";
import { logger } from "../helpers/debug";
import { safeRelayUrl } from "../helpers/relay";
import WasmRelay from "./wasm-relay";
// save the local relay from query params to localStorage
const params = new URLSearchParams(location.search);
@ -34,15 +35,19 @@ export const localDatabase = await openDB();
function createInternalRelay() {
return new CacheRelay(localDatabase, { maxEvents: 10000 });
}
function createRelay() {
async function createRelay() {
const localRelayURL = localStorage.getItem("localRelay");
if (localRelayURL) {
if (localRelayURL.startsWith("nostr-idb://")) {
if (localRelayURL === "nostr-idb://wasm-worker" && WasmRelay.SUPPORTED) {
return new WasmRelay();
} else if (localRelayURL.startsWith("nostr-idb://")) {
return createInternalRelay();
} else if (safeRelayUrl(localRelayURL)) {
return new Relay(safeRelayUrl(localRelayURL)!);
}
} else if (window.satellite) {
return new Relay(await window.satellite.getLocalRelay());
} else if (window.CACHE_RELAY_ENABLED) {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return new Relay(new URL(protocol + location.host + "/local-relay").toString());
@ -51,7 +56,7 @@ function createRelay() {
}
async function connectRelay() {
const relay = createRelay();
const relay = await createRelay();
try {
await relay.connect();
log("Connected");

View File

@ -27,8 +27,8 @@ export enum NostrConnectMethod {
Nip04Decrypt = "nip04_decrypt",
}
type RequestParams = {
[NostrConnectMethod.Connect]: [string] | [string, string];
[NostrConnectMethod.CreateAccount]: [string, string] | [string, string, string];
[NostrConnectMethod.Connect]: [string] | [string, string] | [string, string, string];
[NostrConnectMethod.CreateAccount]: [string, string] | [string, string, string] | [string, string, string, string];
[NostrConnectMethod.Disconnect]: [];
[NostrConnectMethod.GetPublicKey]: [];
[NostrConnectMethod.SignEvent]: [string];
@ -56,6 +56,10 @@ export type NostrConnectErrorResponse = {
error: string;
};
// FIXME list all requested perms
const Perms =
"nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:3,sign_event:4,sign_event:6,sign_event:7";
export class NostrConnectClient {
sub: NostrMultiSubscription;
log = logger.extend("NostrConnectClient");
@ -90,7 +94,7 @@ export class NostrConnectClient {
async open() {
this.sub.open();
await this.sub.waitForConnection();
await this.sub.waitForAllConnection();
this.log("Connected to relays", this.relays);
}
close() {
@ -106,6 +110,7 @@ export class NostrConnectClient {
}
private requests = new Map<string, Deferred<any>>();
private auths = new Set<string>();
async handleEvent(event: NostrEvent) {
if (this.provider && event.pubkey !== this.provider) return;
@ -121,10 +126,13 @@ export class NostrConnectClient {
if (response.error) {
this.log("Got Error", response.id, response.result, response.error);
if (response.result === "auth_url") {
try {
await this.handleAuthURL(response.error);
} catch (e) {
p.reject(e);
if (!this.auths.has(response.id)) {
this.auths.add(response.id);
try {
await this.handleAuthURL(response.error);
} catch (e) {
p.reject(e);
}
}
} else p.reject(response);
} else if (response.result) {
@ -156,7 +164,7 @@ export class NostrConnectClient {
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
const event = this.createEvent(encrypted, this.pubkey, kind);
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`, event);
this.sub.sendAll(event);
this.sub.publish(event);
const p = createDefer<ResponseResults[T]>();
this.requests.set(id, p);
@ -173,7 +181,7 @@ export class NostrConnectClient {
const encrypted = await nip04.encrypt(this.secretKey, this.provider, JSON.stringify(request));
const event = this.createEvent(encrypted, this.provider, kind);
this.log(`Sending admin request ${id} (${method}) ${JSON.stringify(params)}`, event);
this.sub.sendAll(event);
this.sub.publish(event);
const p = createDefer<ResponseResults[T]>();
this.requests.set(id, p);
@ -183,10 +191,7 @@ export class NostrConnectClient {
async connect(token?: string) {
await this.open();
try {
const result = await this.makeRequest(
NostrConnectMethod.Connect,
token ? [this.publicKey, token] : [this.publicKey],
);
const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]);
this.isConnected = true;
return result;
} catch (e) {
@ -200,10 +205,12 @@ export class NostrConnectClient {
await this.open();
try {
const newPubkey = await this.makeAdminRequest(
NostrConnectMethod.CreateAccount,
email ? [name, domain, email] : [name, domain],
);
const newPubkey = await this.makeAdminRequest(NostrConnectMethod.CreateAccount, [
name,
domain,
email || "",
Perms,
]);
this.pubkey = newPubkey;
this.isConnected = true;
return newPubkey;

View File

@ -1,12 +1,13 @@
import _throttle from "lodash.throttle";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import relayInfoService from "./relay-info";
import { localRelay } from "./local-relay";
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
import relayPoolService from "./relay-pool";
import { Filter } from "nostr-tools";
const MONITOR_PUBKEY = "151c17c9d234320cf0f189af7b761f63419fd6c38c6041587a008b7682e4640f";
const MONITOR_RELAY = "wss://history.nostr.watch";
@ -50,9 +51,10 @@ class RelayStatsService {
relayInfoService.getInfo(relay).then((info) => {
if (!info.pubkey) return sub.next(null);
const request = new NostrRequest([relay, MONITOR_RELAY]);
request.onEvent.subscribe((e) => this.handleEvent(e));
request.start({ kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] });
const filter: Filter = { kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] };
const subscription = relayPoolService
.requestRelay(MONITOR_RELAY)
.subscribe([filter], { onevent: (event) => this.handleEvent(event), oneose: () => subscription.close() });
});
}
@ -74,9 +76,10 @@ class RelayStatsService {
private batchRequestMonitorStats() {
const relays = Array.from(this.pendingMonitorStats);
const request = new NostrRequest([MONITOR_RELAY]);
request.onEvent.subscribe((e) => this.handleEvent(e));
request.start({ since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] });
const filter: Filter = { since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] };
const sub = relayPoolService
.requestRelay(MONITOR_RELAY)
.subscribe([filter], { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() });
this.pendingMonitorStats.clear();
}

View File

@ -11,6 +11,7 @@ import { relayRequest } from "../helpers/relay";
import EventStore from "../classes/event-store";
import Subject from "../classes/subject";
import BatchKindLoader, { createCoordinate } from "../classes/batch-kind-loader";
import relayPoolService from "./relay-pool";
export type RequestOptions = {
/** Always request the event from the relays */
@ -31,7 +32,7 @@ const WRITE_CACHE_BATCH_TIME = 250;
class ReplaceableEventsService {
private subjects = new SuperMap<string, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
private loaders = new SuperMap<string, BatchKindLoader>((relay) => {
const loader = new BatchKindLoader(relay, this.log.extend(relay));
const loader = new BatchKindLoader(relayPoolService.requestRelay(relay), this.log.extend(relay));
loader.events.onEvent.subscribe((e) => this.handleEvent(e));
return loader;
});

View File

@ -34,8 +34,12 @@ export type AppSettingsV4 = Omit<AppSettingsV3, "version"> & { version: 4; loadO
export type AppSettingsV5 = Omit<AppSettingsV4, "version"> & { version: 5; hideUsernames: boolean };
export type AppSettingsV6 = Omit<AppSettingsV5, "version"> & { version: 6; noteDifficulty: number | null };
export type AppSettingsV7 = Omit<AppSettingsV6, "version"> & { version: 7; autoDecryptDMs: boolean };
export type AppSettingsV8 = Omit<AppSettingsV7, "version"> & {
version: 7;
mediaUploadService: "nostr.build" | "blossom";
};
export type AppSettings = AppSettingsV7;
export type AppSettings = AppSettingsV8;
export const defaultSettings: AppSettings = {
version: 7,
@ -55,13 +59,14 @@ export const defaultSettings: AppSettings = {
autoDecryptDMs: false,
quickReactions: ["🤙", "❤️", "🤣", "😍", "🔥"],
mediaUploadService: "nostr.build",
autoPayWithWebLN: true,
customZapAmounts: "50,200,500,1000,2000,5000",
primaryColor: "#8DB600",
imageProxy: "",
corsProxy: "https://corsproxy.io/?<encoded_url>",
corsProxy: "", //"https://corsproxy.io/?<encoded_url>",
showContentWarning: true,
twitterRedirect: undefined,
redditRedirect: undefined,

View File

@ -1,12 +1,12 @@
import _throttle from "lodash.throttle";
import NostrRequest from "../classes/nostr-request";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { localRelay } from "./local-relay";
import { relayRequest, safeRelayUrls } from "../helpers/relay";
import { logger } from "../helpers/debug";
import Subject from "../classes/subject";
import relayPoolService from "./relay-pool";
const RELAY_REQUEST_BATCH_TIME = 500;
@ -15,6 +15,10 @@ class SingleEventService {
pending = new Map<string, string[]>();
log = logger.extend("SingleEvent");
getSubject(id: string) {
return this.subjects.get(id);
}
requestEvent(id: string, relays: Iterable<string>) {
const subject = this.subjects.get(id);
if (subject.value) return subject;
@ -60,9 +64,9 @@ class SingleEventService {
}
for (const [relay, ids] of Object.entries(idsFromRelays)) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe((event) => this.handleEvent(event));
request.start({ ids });
const sub = relayPoolService
.requestRelay(relay)
.subscribe([{ ids }], { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() });
}
this.pending.clear();
}

View File

@ -1,4 +1,6 @@
import { kinds } from "nostr-tools";
import _throttle from "lodash.throttle";
import { COMMON_CONTACT_RELAY } from "../const";
import { logger } from "../helpers/debug";
import accountService from "./account";
@ -11,8 +13,14 @@ import userMetadataService from "./user-metadata";
const log = logger.extend("user-event-sync");
function loadContactsList() {
function downloadEvents() {
const account = accountService.current.value!;
const relays = clientRelaysService.readRelays.value;
log("Loading user information");
userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
log("Loading contacts list");
replaceableEventsService.requestEvent(
@ -26,18 +34,6 @@ function loadContactsList() {
);
}
function downloadEvents() {
const account = accountService.current.value!;
const relays = clientRelaysService.readRelays.value;
log("Loading user information");
userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
loadContactsList();
}
accountService.current.subscribe((account) => {
if (!account) return;
downloadEvents();

View File

@ -0,0 +1,96 @@
import { type WorkerRelayInterface } from "@snort/worker-relay";
import { nanoid } from "nanoid";
import { SimpleRelay, Subscription, SubscriptionOptions } from "nostr-idb";
import { Filter, NostrEvent } from "nostr-tools";
import { logger } from "../../helpers/debug";
import { WASM_RELAY_SUPPORTED } from "./supported";
export default class WasmRelay implements SimpleRelay {
log = logger.extend("WasmRelay");
url = "nostr-idb://wasm-worker";
connected = false;
worker?: WorkerRelayInterface;
static SUPPORTED = WASM_RELAY_SUPPORTED;
private subscriptions: Map<
string,
SubscriptionOptions & {
filters: Filter[];
}
> = new Map();
async connect() {
if (this.connected || this.worker) return;
console.time("Starting Wasm Worker");
const { default: worker } = await import("./worker");
this.worker = worker;
this.connected = true;
console.timeEnd("Starting Wasm Worker");
}
async close() {
console.error("Cant stop wasm worker");
}
async publish(event: NostrEvent) {
if (!this.worker) throw new Error("Worker not setup");
const res = await this.worker.event(event);
if (res.message) return res.message;
return res.ok ? "success" : "failed";
}
async count(filters: Filter[], params: { id?: string | null }) {
if (!this.worker) throw new Error("Worker not setup");
return await this.worker.count(["REQ", params.id || nanoid(), ...filters]);
}
private async executeSubscription(sub: Subscription) {
if (!this.worker) throw new Error("Worker not setup");
const start = new Date().valueOf();
this.log(`Running ${sub.id}`, sub.filters);
// get events
await this.worker.query(["REQ", sub.id, ...sub.filters]).then((events) => {
const delta = new Date().valueOf() - start;
this.log(`Finished ${sub.id} took ${delta}ms and got ${events.length} events`);
if (sub.onevent) {
for (const event of events) sub.onevent(event);
}
if (sub.oneose) sub.oneose();
});
}
subscribe(filters: Filter[], options: Partial<SubscriptionOptions>): Subscription {
// remove any duplicate subscriptions
if (options.id && this.subscriptions.has(options.id)) {
this.subscriptions.delete(options.id);
}
const id = options.id || nanoid();
const sub = {
id,
filters,
close: () => this.subscriptions.delete(id),
fire: () => this.executeSubscription(sub),
...options,
};
this.subscriptions.set(id, sub);
this.executeSubscription(sub);
return sub;
}
unsubscribe(id: string) {
const sub = this.subscriptions.get(id);
if (sub) {
sub.onclose?.("unsubscribe");
this.subscriptions.delete(id);
}
}
}

View File

@ -0,0 +1 @@
export const WASM_RELAY_SUPPORTED = "WebAssembly" in self && "Worker" in self && "storage" in navigator;

View File

@ -0,0 +1,16 @@
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
const workerScript = import.meta.env.DEV
? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url)
: new WorkerVite();
const workerRelay = new WorkerRelayInterface(workerScript);
await workerRelay.init({ databasePath: "nostrudel.db", insertBatchSize: 100 });
if (import.meta.env.DEV) {
// @ts-expect-error
window.workerRelay = workerRelay;
}
export default workerRelay;

View File

@ -0,0 +1,73 @@
import { NostrEvent, kinds } from "nostr-tools";
import _throttle from "lodash.throttle";
import { PubkeyGraph } from "../classes/pubkey-graph";
import { COMMON_CONTACT_RELAY } from "../const";
import { logger } from "../helpers/debug";
import accountService from "./account";
import replaceableEventsService from "./replaceable-events";
import { getPubkeysFromList } from "../helpers/nostr/lists";
const log = logger.extend("web-of-trust");
let webOfTrust: PubkeyGraph;
let newEvents = 0;
const throttleUpdateWebOfTrust = _throttle(() => {
log("Computing web-of-trust with", newEvents, "new events");
webOfTrust.compute();
newEvents = 0;
}, 5_000);
export function loadSocialGraph(
web: PubkeyGraph,
kind: number,
pubkey: string,
relay?: string,
maxLvl = 0,
walked: Set<string> = new Set(),
) {
const contacts = replaceableEventsService.requestEvent(
relay ? [relay, COMMON_CONTACT_RELAY] : [COMMON_CONTACT_RELAY],
kind,
pubkey,
);
walked.add(pubkey);
const handleEvent = (event: NostrEvent) => {
web.handleEvent(event);
newEvents++;
throttleUpdateWebOfTrust();
if (maxLvl > 0) {
for (const person of getPubkeysFromList(event)) {
if (walked.has(person.pubkey)) continue;
loadSocialGraph(web, kind, person.pubkey, person.relay, maxLvl - 1, walked);
}
}
};
if (contacts.value) {
handleEvent(contacts.value);
} else {
contacts.once((event) => handleEvent(event));
}
}
accountService.current.subscribe((account) => {
if (!account) return;
webOfTrust = new PubkeyGraph(account.pubkey);
if (import.meta.env.DEV) {
//@ts-expect-error
window.webOfTrust = webOfTrust;
}
loadSocialGraph(webOfTrust, kinds.Contacts, account.pubkey, undefined, 1);
});
export function getWebOfTrust() {
return webOfTrust;
}

6
src/types/satellite.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
interface Window {
satellite?: {
getLocalRelay: () => Promise<string>;
getAdminAuth: () => Promise<string>;
};
}

View File

@ -25,7 +25,7 @@ import IntersectionObserverProvider from "../../../providers/local/intersection-
import UserLink from "../../../components/user/user-link";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import UserAvatar from "../../../components/user/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user/user-dns-identity-icon";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import ChannelJoinButton from "./channel-join-button";
import { ExternalLinkIcon } from "../../../components/icons";
import { CHANNELS_LIST_KIND } from "../../../helpers/nostr/lists";
@ -37,7 +37,7 @@ function UserCard({ pubkey }: { pubkey: string }) {
<Card as={LinkBox} direction="row" alignItems="center" gap="2" p="2">
<UserAvatar pubkey={pubkey} size="sm" />
<HoverLinkOverlay as={UserLink} pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
<UserDnsIdentity pubkey={pubkey} onlyIcon />
</Card>
);
}

View File

@ -30,7 +30,7 @@ import UserAvatar from "../../../components/user/user-avatar";
import UserLink from "../../../components/user/user-link";
import { TrashIcon } from "../../../components/icons";
import Upload01 from "../../../components/icons/upload-01";
import { nostrBuildUploadImage } from "../../../helpers/nostr-build";
import { nostrBuildUploadImage } from "../../../helpers/media-upload/nostr-build";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { RelayFavicon } from "../../../components/relay-favicon";

View File

@ -23,7 +23,7 @@ import useSubject from "../../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
import UserLink from "../../../components/user/user-link";
import { UserDnsIdentityIcon } from "../../../components/user/user-dns-identity-icon";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import UserAvatarLink from "../../../components/user/user-avatar-link";
function UserCard({ pubkey }: { pubkey: string }) {
@ -32,7 +32,7 @@ function UserCard({ pubkey }: { pubkey: string }) {
<UserAvatarLink pubkey={pubkey} />
<Flex direction="column" flex={1} overflow="hidden">
<UserLink pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={pubkey} />
<UserDnsIdentity pubkey={pubkey} />
</Flex>
</Flex>
);

View File

@ -13,7 +13,7 @@ import useCurrentAccount from "../../hooks/use-current-account";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { UserDnsIdentityIcon } from "../../components/user/user-dns-identity-icon";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import { useDecryptionContext } from "../../providers/global/dycryption-provider";
import SendMessageForm from "./components/send-message-form";
import { groupMessages } from "../../helpers/nostr/dms";
@ -81,13 +81,8 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
[
{
kinds: [kinds.EncryptedDirectMessage],
"#p": [account.pubkey],
authors: [pubkey],
},
{
kinds: [kinds.EncryptedDirectMessage],
"#p": [pubkey],
authors: [account.pubkey],
"#p": [account.pubkey, pubkey],
authors: [pubkey, account.pubkey],
},
],
);
@ -123,7 +118,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
/>
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
<UserDnsIdentity pubkey={pubkey} onlyIcon />
</Flex>
<ButtonGroup ml="auto">
{!autoDecryptDMs && (

View File

@ -7,11 +7,9 @@ import { Button, Flex, FlexProps, Heading } from "@chakra-ui/react";
import { useSigningContext } from "../../../providers/global/signing-provider";
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
import clientRelaysService from "../../../services/client-relays";
import { DraftNostrEvent } from "../../../types/nostr-event";
import { useDecryptionContext } from "../../../providers/global/dycryption-provider";
import useUserMailboxes from "../../../hooks/use-user-mailboxes";
import RelaySet from "../../../classes/relay-set";
import { usePublishEvent } from "../../../providers/global/publish-provider";
export default function SendMessageForm({

View File

@ -21,7 +21,7 @@ import UserName from "../../components/user/user-name";
import { useDecryptionContainer } from "../../providers/global/dycryption-provider";
import { NostrEvent } from "../../types/nostr-event";
import { CheckIcon } from "../../components/icons";
import { UserDnsIdentityIcon } from "../../components/user/user-dns-identity-icon";
import UserDnsIdentity from "../../components/user/user-dns-identity";
function MessagePreview({ message, pubkey }: { message: NostrEvent; pubkey: string }) {
const ref = useRef<HTMLParagraphElement | null>(null);
@ -50,7 +50,7 @@ function ConversationCard({ conversation }: { conversation: KnownConversation })
<Flex direction="column" gap="1" overflow="hidden" flex={1}>
<Flex gap="2" alignItems="center" overflow="hidden">
<UserName pubkey={conversation.correspondent} isTruncated />
<UserDnsIdentityIcon onlyIcon pubkey={conversation.correspondent} />
<UserDnsIdentity onlyIcon pubkey={conversation.correspondent} />
<Timestamp flexShrink={0} timestamp={lastMessage.created_at} ml="auto" />
{hasResponded(conversation) && <CheckIcon boxSize={4} color="green.500" />}
</Flex>

View File

@ -49,11 +49,10 @@ function DVMFeedPage({ pointer }: { pointer: AddressPointer }) {
const dvmRelays = useUserMailboxes(pointer.pubkey)?.relays;
const readRelays = useReadRelays(dvmRelays);
const timeline = useTimelineLoader(`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}-jobs`, readRelays, [
{ authors: [account.pubkey], "#p": [pointer.pubkey], kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND], since },
{
authors: [pointer.pubkey],
"#p": [account.pubkey],
kinds: [DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND],
authors: [account.pubkey, pointer.pubkey],
"#p": [account.pubkey, pointer.pubkey],
kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND, DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND],
since,
},
]);

Some files were not shown because too many files have changed in this diff Show More