workspace with decoupled nostr package

This commit is contained in:
ennmichael
2023-02-11 21:05:46 +01:00
parent 52e0809622
commit 2a211b78a1
260 changed files with 2363 additions and 714 deletions

View File

@ -0,0 +1,10 @@
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { HexKey, Lists } from "Nostr";
import useNotelistSubscription from "Feed/useNotelistSubscription";
export default function useBookmarkFeed(pubkey: HexKey) {
const { bookmarked } = useSelector((s: RootState) => s.login);
return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked);
}

View File

@ -0,0 +1,406 @@
import { useSelector } from "react-redux";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind, Tag, Event as NEvent, System, RelaySettings } from "@snort/nostr";
import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
import { bech32ToHex, unwrap } from "Util";
import { DefaultRelays, HashtagRegex } from "Const";
declare global {
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>;
signEvent: (event: RawEvent) => Promise<RawEvent>;
getRelays: () => Promise<Record<string, { read: boolean; write: boolean }>>;
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>;
decrypt: (pubkey: HexKey, content: string) => Promise<string>;
};
};
}
}
export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const relays = useSelector((s: RootState) => s.login.relays);
const hasNip07 = "nostr" in window;
async function signEvent(ev: NEvent): Promise<NEvent> {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev.ToObject()))) as RawEvent;
return new NEvent(tmpEv as TaggedRawEvent);
} else if (privKey) {
await ev.Sign(privKey);
} else {
console.warn("Count not sign event, no private keys available");
}
return ev;
}
function processContent(ev: NEvent, msg: string) {
const replaceNpub = (match: string) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceNoteId = (match: string) => {
try {
const hex = bech32ToHex(match);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return match;
};
const content = msg
.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
}
return {
nip42Auth: async (challenge: string, relay: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth;
ev.Content = "";
ev.Tags.push(new Tag(["relay", relay], 0));
ev.Tags.push(new Tag(["challenge", challenge], 1));
return await signEvent(ev);
}
},
broadcast: (ev: NEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
}
},
/**
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap: (ev: NEvent | undefined) => {
if (ev) {
for (const [k] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
}
},
/**
* Write event to all given relays.
*/
broadcastAll: (ev: NEvent | undefined, relays: string[]) => {
if (ev) {
for (const k of relays) {
System.WriteOnceToRelay(k, ev);
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.PubkeyLists;
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
});
let content = "";
if (priv.length > 0) {
const ps = priv.map(p => ["p", p]);
const plaintext = JSON.stringify(ps);
if (hasNip07 && !privKey) {
content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
} else if (privKey) {
content = await ev.EncryptData(plaintext, pubKey, privKey);
}
}
ev.Content = content;
return await signEvent(ev);
}
},
pinned: async (notes: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.NoteLists;
ev.Tags.push(new Tag(["d", Lists.Pinned], ev.Tags.length));
notes.forEach(n => {
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
});
ev.Content = "";
return await signEvent(ev);
}
},
bookmarked: async (notes: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.NoteLists;
ev.Tags.push(new Tag(["d", Lists.Bookmarked], ev.Tags.length));
notes.forEach(n => {
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
});
ev.Content = "";
return await signEvent(ev);
}
},
tags: async (tags: string[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TagLists;
ev.Tags.push(new Tag(["d", Lists.Followed], ev.Tags.length));
tags.forEach(t => {
ev.Tags.push(new Tag(["t", t], ev.Tags.length));
});
ev.Content = "";
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processContent(ev, msg);
return await signEvent(ev);
}
},
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ZapRequest;
if (note) {
ev.Tags.push(new Tag(["e", note], ev.Tags.length));
}
ev.Tags.push(new Tag(["p", author], ev.Tags.length));
const relayTag = ["relays", ...Object.keys(relays).slice(0, 10)];
ev.Tags.push(new Tag(relayTag, ev.Tags.length));
processContent(ev, msg || "");
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
const thread = replyTo.Thread;
if (thread) {
if (thread.Root || thread.ReplyTo) {
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event ?? "", "", "root"], ev.Tags.length));
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
for (const pk of thread.PubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
}
processContent(ev, msg);
return await signEvent(ev);
}
},
react: async (evRef: NEvent, content = "+") => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (const pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
saveRelaysSettings: async () => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Relays;
ev.Content = "";
for (const [url, settings] of Object.entries(relays)) {
const rTag = ["r", url];
if (settings.read && !settings.write) {
rTag.push("read");
}
if (settings.write && !settings.read) {
rTag.push("write");
}
ev.Tags.push(new Tag(rTag, ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(newRelays ?? relays);
const temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (const pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (const pk of follows) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
/**
* Delete an event (NIP-09)
*/
delete: async (id: u256) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
return await signEvent(ev);
}
},
/**
* Repost a note (NIP-18)
*/
repost: async (note: NEvent) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id], 0));
ev.Tags.push(new Tag(["p", note.PubKey], 1));
return await signEvent(ev);
}
},
decryptDm: async (note: NEvent): Promise<string | undefined> => {
if (pubKey) {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
try {
const otherPubKey =
note.PubKey === pubKey ? unwrap(note.Tags.filter(a => a.Key === "p")[0].PubKey) : note.PubKey;
if (hasNip07 && !privKey) {
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decryption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
try {
if (hasNip07 && !privKey) {
const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
return await signEvent(ev);
} else if (privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
},
};
}
let isNip07Busy = false;
const delay = (t: number) => {
return new Promise(resolve => {
setTimeout(resolve, t);
});
};
export const barrierNip07 = async <T>(then: () => Promise<T>): Promise<T> => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

@ -0,0 +1,17 @@
import { useMemo } from "react";
import { HexKey } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
export default function useFollowersFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const x = new Subscriptions();
x.Id = `followers:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.PTags = new Set([pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub);
}

View File

@ -0,0 +1,23 @@
import { useMemo } from "react";
import { HexKey } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useFollowsFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const x = new Subscriptions();
x.Id = `follows:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub);
}
export function getFollowers(feed: NoteStore, pubkey: HexKey) {
const contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
const pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
return [...new Set(pTags?.flat())];
}

View File

@ -0,0 +1,41 @@
import * as secp from "@noble/secp256k1";
import * as base64 from "@protobufjs/base64";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { unwrap } from "Util";
export interface ImgProxySettings {
url: string;
key: string;
salt: string;
}
export default function useImgProxy() {
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig);
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
async function signUrl(u: string) {
const result = await secp.utils.hmacSha256(
secp.utils.hexToBytes(unwrap(settings).key),
secp.utils.hexToBytes(unwrap(settings).salt),
te.encode(u)
);
return urlSafe(base64.encode(result, 0, result.byteLength));
}
return {
proxy: async (url: string, resize?: number) => {
if (!settings) return url;
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
const path = `/${opt}/${urlEncoded}`;
const sig = await signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
},
};
}

View File

@ -0,0 +1,278 @@
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getNewest } from "Util";
import { makeNotification } from "Notifications";
import { TaggedRawEvent, HexKey, Lists } from "@snort/nostr";
import { Event, EventKind, Subscriptions } from "@snort/nostr";
import {
addDirectMessage,
setFollows,
setRelays,
setMuted,
setTags,
setPinned,
setBookmarked,
setBlocked,
sendNotification,
setLatestNotifications,
} from "State/Login";
import { RootState } from "State/Store";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { useDb } from "State/Users/Db";
import useSubscription from "Feed/Subscription";
import { barrierNip07 } from "Feed/EventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import { unwrap } from "Util";
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
const {
publicKey: pubKey,
privateKey: privKey,
latestMuted,
readNotifications,
} = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const db = useDb();
const subMetadata = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
sub.Limit = 2;
return sub;
}, [pubKey]);
const subNotification = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:notifications";
// todo: add zaps
sub.Kinds = new Set([EventKind.TextNote]);
sub.PTags = new Set([pubKey]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subMuted = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:muted";
sub.Kinds = new Set([EventKind.PubkeyLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subTags = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:tags";
sub.Kinds = new Set([EventKind.TagLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Followed]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subPinned = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:pinned";
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Pinned]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subBookmarks = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:bookmarks";
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Bookmarked]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subDms = useMemo(() => {
if (!pubKey) return null;
const dms = new Subscriptions();
dms.Id = "login:dms";
dms.Kinds = new Set([EventKind.DirectMessage]);
dms.PTags = new Set([pubKey]);
const dmsFromME = new Subscriptions();
dmsFromME.Authors = new Set([pubKey]);
dmsFromME.Kinds = new Set([EventKind.DirectMessage]);
dms.AddSubscription(dmsFromME);
return dms;
}, [pubKey]);
const metadataFeed = useSubscription(subMetadata, {
leaveOpen: true,
cache: true,
});
const notificationFeed = useSubscription(subNotification, {
leaveOpen: true,
cache: true,
});
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
const pinnedFeed = useSubscription(subPinned, { leaveOpen: true, cache: true });
const tagsFeed = useSubscription(subTags, { leaveOpen: true, cache: true });
const bookmarkFeed = useSubscription(subBookmarks, { leaveOpen: true, cache: true });
useEffect(() => {
const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
const metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata);
const profiles = metadata
.map(a => mapEventToProfile(a))
.filter(a => a !== undefined)
.map(a => unwrap(a));
for (const cl of contactList) {
if (cl.content !== "" && cl.content !== "{}") {
const relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at }));
}
const pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
}
(async () => {
const maxProfile = profiles.reduce(
(acc, v) => {
if (v.created > acc.created) {
acc.profile = v;
acc.created = v.created;
}
return acc;
},
{ created: 0, profile: null as MetadataCache | null }
);
if (maxProfile.profile) {
const existing = await db.find(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) {
await db.put(maxProfile.profile);
}
}
})().catch(console.warn);
}, [dispatch, metadataFeed.store, db]);
useEffect(() => {
const replies = notificationFeed.store.notes.filter(
a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications
);
replies.forEach(nx => {
dispatch(setLatestNotifications(nx.created_at));
makeNotification(db, nx).then(notification => {
if (notification) {
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
}
});
});
}, [dispatch, notificationFeed.store, db, readNotifications]);
useEffect(() => {
const muted = getMutedKeys(mutedFeed.store.notes);
dispatch(setMuted(muted));
const newest = getNewest(mutedFeed.store.notes);
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
decryptBlocked(newest, pubKey, privKey)
.then(plaintext => {
try {
const blocked = JSON.parse(plaintext);
const keys = blocked.filter((p: string) => p && p.length === 2 && p[0] === "p").map((p: string) => p[1]);
dispatch(
setBlocked({
keys,
createdAt: newest.created_at,
})
);
} catch (error) {
console.debug("Couldn't parse JSON");
}
})
.catch(error => console.warn(error));
}
}, [dispatch, mutedFeed.store]);
useEffect(() => {
const newest = getNewest(pinnedFeed.store.notes);
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
dispatch(
setPinned({
keys,
createdAt: newest.created_at,
})
);
}
}, [dispatch, pinnedFeed.store]);
useEffect(() => {
const newest = getNewest(tagsFeed.store.notes);
if (newest) {
const tags = newest.tags.filter(p => p && p.length === 2 && p[0] === "t").map(p => p[1]);
dispatch(
setTags({
tags,
createdAt: newest.created_at,
})
);
}
}, [dispatch, tagsFeed.store]);
useEffect(() => {
const newest = getNewest(bookmarkFeed.store.notes);
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
dispatch(
setBookmarked({
keys,
createdAt: newest.created_at,
})
);
}
}, [dispatch, bookmarkFeed.store]);
useEffect(() => {
const dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms));
}, [dispatch, dmsFeed.store]);
}
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
const ev = new Event(raw);
if (pubKey && privKey) {
return await ev.DecryptData(raw.content, privKey, pubKey);
} else {
return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
}
}

View File

@ -0,0 +1,41 @@
import { useMemo } from "react";
import { getNewest } from "Util";
import { HexKey, TaggedRawEvent, Lists } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useMutedFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.PubkeyLists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
}, [pubkey]);
return useSubscription(sub);
}
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
createdAt: number;
keys: HexKey[];
} {
const newest = getNewest(rawNotes);
if (newest) {
const { created_at, tags } = newest;
const keys = tags.filter(t => t[0] === "p").map(t => t[1]);
return {
keys,
createdAt: created_at,
};
}
return { createdAt: 0, keys: [] };
}
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
const lists = feed?.notes.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
return getMutedKeys(lists).keys;
}

View File

@ -0,0 +1,10 @@
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { HexKey, Lists } from "Nostr";
import useNotelistSubscription from "Feed/useNotelistSubscription";
export default function usePinnedFeed(pubkey: HexKey) {
const { pinned } = useSelector((s: RootState) => s.login);
return useNotelistSubscription(pubkey, Lists.Pinned, pinned);
}

View File

@ -0,0 +1,30 @@
import { useEffect } from "react";
import { MetadataCache } from "State/Users";
import { useKey, useKeys } from "State/Users/Hooks";
import { System, HexKey } from "@snort/nostr";
export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
const users = useKey(pubKey);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
return users;
}
export function useUserProfiles(pubKeys?: Array<HexKey>): Map<HexKey, MetadataCache> | undefined {
const users = useKeys(pubKeys);
useEffect(() => {
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
return users;
}

View File

@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
import { System, StateSnapshot } from "@snort/nostr";
const noop = () => {
return () => undefined;
};
const noopState = (): StateSnapshot | undefined => {
return undefined;
};
export default function useRelayState(addr: string) {
const c = System.Sockets.get(addr);
return useSyncExternalStore<StateSnapshot | undefined>(
c?.StatusHook.bind(c) ?? noop,
c?.GetState.bind(c) ?? noopState
);
}

View File

@ -0,0 +1,35 @@
import { useMemo } from "react";
import { HexKey, FullRelaySettings } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "./Subscription";
export default function useRelaysFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const x = new Subscriptions();
x.Id = `relays:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.Relays]);
x.Authors = new Set([pubkey]);
x.Limit = 1;
return x;
}, [pubkey]);
const relays = useSubscription(sub, { leaveOpen: false, cache: true });
const notes = relays.store.notes;
const tags = notes.slice(-1)[0]?.tags || [];
return tags.reduce((rs, tag) => {
const [t, url, ...settings] = tag;
if (t === "r") {
return [
...rs,
{
url,
settings: {
read: settings.length === 0 || settings.includes("read"),
write: settings.length === 0 || settings.includes("write"),
},
},
];
}
return rs;
}, [] as FullRelaySettings[]);
}

View File

@ -0,0 +1,195 @@
import { useEffect, useMemo, useReducer, useState } from "react";
import { TaggedRawEvent } from "@snort/nostr";
import { System, Subscriptions } from "@snort/nostr";
import { debounce, unwrap } from "Util";
import { db } from "Db";
export type NoteStore = {
notes: Array<TaggedRawEvent>;
end: boolean;
};
export type UseSubscriptionOptions = {
leaveOpen: boolean;
cache: boolean;
relay?: string;
};
interface ReducerArg {
type: "END" | "EVENT" | "CLEAR";
ev?: TaggedRawEvent | TaggedRawEvent[];
end?: boolean;
}
function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") {
return {
notes: state.notes,
end: arg.end ?? true,
} as NoteStore;
}
if (arg.type === "CLEAR") {
return {
notes: [],
end: state.end,
} as NoteStore;
}
let evs = arg.ev;
if (!(evs instanceof Array)) {
evs = evs === undefined ? [] : [evs];
}
const existingIds = new Set(state.notes.map(a => a.id));
evs = evs.filter(a => !existingIds.has(a.id));
if (evs.length === 0) {
return state;
}
return {
notes: [...state.notes, ...evs],
} as NoteStore;
}
const initStore: NoteStore = {
notes: [],
end: false,
};
export interface UseSubscriptionState {
store: NoteStore;
clear: () => void;
append: (notes: TaggedRawEvent[]) => void;
}
/**
* Wait time before returning changed state
*/
const DebounceMs = 200;
/**
*
* @param {Subscriptions} sub
* @param {any} opt
* @returns
*/
export default function useSubscription(
sub: Subscriptions | null,
options?: UseSubscriptionOptions
): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounceOutput, setDebounceOutput] = useState<number>(0);
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
const useCache = useMemo(() => options?.cache === true, [options]);
useEffect(() => {
if (sub) {
return debounce(DebounceMs, () => {
setSubDebounced(sub);
});
}
}, [sub, options]);
useEffect(() => {
if (subDebounce) {
dispatch({
type: "END",
end: false,
});
if (useCache) {
// preload notes from db
PreloadNotes(subDebounce.Id)
.then(ev => {
dispatch({
type: "EVENT",
ev: ev,
});
})
.catch(console.warn);
}
subDebounce.OnEvent = e => {
dispatch({
type: "EVENT",
ev: e,
});
if (useCache) {
db.events.put(e);
}
};
subDebounce.OnEnd = c => {
if (!(options?.leaveOpen ?? false)) {
c.RemoveSubscription(subDebounce.Id);
if (subDebounce.IsFinished()) {
System.RemoveSubscription(subDebounce.Id);
}
}
dispatch({
type: "END",
end: true,
});
};
const subObj = subDebounce.ToObject();
console.debug("Adding sub: ", subObj);
if (options?.relay) {
System.AddSubscriptionToRelay(subDebounce, options.relay);
} else {
System.AddSubscription(subDebounce);
}
return () => {
console.debug("Removing sub: ", subObj);
subDebounce.OnEvent = () => undefined;
System.RemoveSubscription(subDebounce.Id);
};
}
}, [subDebounce, useCache]);
useEffect(() => {
if (subDebounce && useCache) {
return debounce(500, () => {
TrackNotesInFeed(subDebounce.Id, state.notes).catch(console.warn);
});
}
}, [state, useCache]);
useEffect(() => {
return debounce(DebounceMs, () => {
setDebounceOutput(s => (s += 1));
});
}, [state]);
const stateDebounced = useMemo(() => state, [debounceOutput]);
return {
store: stateDebounced,
clear: () => {
dispatch({ type: "CLEAR" });
},
append: (n: TaggedRawEvent[]) => {
dispatch({
type: "EVENT",
ev: n,
});
},
};
}
/**
* Lookup cached copy of feed
*/
const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => {
const feed = await db.feeds.get(id);
if (feed) {
const events = await db.events.bulkGet(feed.ids);
return events.filter(a => a !== undefined).map(a => unwrap(a));
}
return [];
};
const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => {
const existing = await db.feeds.get(id);
const ids = Array.from(new Set([...(existing?.ids || []), ...notes.map(a => a.id)]));
const since = notes.reduce((acc, v) => (acc > v.created_at ? v.created_at : acc), +Infinity);
const until = notes.reduce((acc, v) => (acc < v.created_at ? v.created_at : acc), -Infinity);
await db.feeds.put({ id, ids, since, until });
};

View File

@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from "react";
import { u256 } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
import { debounce } from "Util";
export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function addId(id: u256[]) {
setTrackingEvent(s => {
const orig = new Set(s);
if (id.some(a => !orig.has(a))) {
const tmp = new Set([...s, ...id]);
return Array.from(tmp);
} else {
return s;
}
});
}
const sub = useMemo(() => {
const thisSub = new Subscriptions();
thisSub.Id = `thread:${id.substring(0, 8)}`;
thisSub.Ids = new Set(trackingEvents);
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set(
pref.enableReactions
? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.TextNote]
);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
}, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
useEffect(() => {
if (main.store) {
return debounce(200, () => {
const mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
const eTags = mainNotes
.filter(a => a.kind === EventKind.TextNote)
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1]))
.flat();
const ids = mainNotes.map(a => a.id);
const allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
});
}
}, [main.store]);
return main.store;
}

View File

@ -0,0 +1,187 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { u256 } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import { unixNow, unwrap } from "Util";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL";
window?: number;
relay?: string;
}
export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword";
discriminator: string;
items: string[];
}
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window] = useState<number>(options.window ?? 60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {
return null;
}
const sub = new Subscriptions();
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
break;
}
case "hashtag": {
sub.HashTags = new Set(subject.items);
break;
}
case "ptag": {
sub.PTags = new Set(subject.items);
break;
}
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
break;
}
}
return sub;
}, [subject.type, subject.items, subject.discriminator, options.relay]);
const sub = useMemo(() => {
const sub = createSub();
if (sub) {
if (options.method === "LIMIT_UNTIL") {
sub.Until = until;
sub.Limit = 10;
} else {
sub.Since = since;
sub.Until = until;
if (since === undefined) {
sub.Limit = 50;
}
}
if (pref.autoShowLatest) {
// copy properties of main sub but with limit 0
// this will put latest directly into main feed
const latestSub = new Subscriptions();
latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags;
latestSub.PTags = sub.PTags;
latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub);
}
}
return sub;
}, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true, cache: subject.type !== "global", relay: options.relay });
const subRealtime = useMemo(() => {
const subLatest = createSub();
if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 1;
subLatest.Since = Math.floor(new Date().getTime() / 1000);
}
return subLatest;
}, [pref, createSub]);
const latest = useSubscription(subRealtime, {
leaveOpen: true,
cache: false,
relay: options.relay,
});
useEffect(() => {
// clear store if chaning relays
main.clear();
latest.clear();
}, [options.relay]);
const subNext = useMemo(() => {
let sub: Subscriptions | undefined;
if (trackingEvents.length > 0 && pref.enableReactions) {
sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]);
sub.ETags = new Set(trackingEvents);
}
return sub ?? null;
}, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true, cache: subject.type !== "global", relay: options.relay });
const subParents = useMemo(() => {
if (trackingParentEvents.length > 0) {
const parents = new Subscriptions();
parents.Id = `timeline-parent:${subject.type}`;
parents.Ids = new Set(trackingParentEvents);
return parents;
}
return null;
}, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents, { leaveOpen: false, cache: false, relay: options.relay });
useEffect(() => {
if (main.store.notes.length > 0) {
setTrackingEvent(s => {
const ids = main.store.notes.map(a => a.id);
if (ids.some(a => !s.includes(a))) {
return Array.from(new Set([...s, ...ids]));
}
return s;
});
const reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "")
.map(a => a.tags.find(b => b[0] === "e"))
.filter(a => a)
.map(a => unwrap(a)[1]);
if (reposts.length > 0) {
setTrackingParentEvents(s => {
if (reposts.some(a => !s.includes(a))) {
const temp = new Set([...s, ...reposts]);
return Array.from(temp);
}
return s;
});
}
}
}, [main.store]);
return {
main: main.store,
related: others.store,
latest: latest.store,
parent: parent.store,
loadMore: () => {
console.debug("Timeline load more!");
if (options.method === "LIMIT_UNTIL") {
const oldest = main.store.notes.reduce((acc, v) => (acc = v.created_at < acc ? v.created_at : acc), unixNow());
setUntil(oldest);
} else {
setUntil(s => s - window);
setSince(s => s - window);
}
},
showLatest: () => {
main.append(latest.store.notes);
latest.clear();
},
};
}

View File

@ -0,0 +1,16 @@
import { useMemo } from "react";
import { HexKey } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "./Subscription";
export default function useZapsFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const x = new Subscriptions();
x.Id = `zaps:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ZapReceipt]);
x.PTags = new Set([pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub, { leaveOpen: true, cache: true });
}

View File

@ -0,0 +1,63 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { getNewest } from "Util";
import { HexKey, Lists } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "Feed/Subscription";
import { RootState } from "State/Store";
export default function useNotelistSubscription(pubkey: HexKey, l: Lists, defaultIds: HexKey[]) {
const { preferences, publicKey } = useSelector((s: RootState) => s.login);
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
if (isMe) return null;
const sub = new Subscriptions();
sub.Id = `note-list-${l}:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([l]);
sub.Limit = 1;
return sub;
}, [pubkey]);
const { store } = useSubscription(sub, { leaveOpen: true, cache: true });
const etags = useMemo(() => {
if (isMe) return defaultIds;
const newest = getNewest(store.notes);
if (newest) {
const { tags } = newest;
return tags.filter(t => t[0] === "e").map(t => t[1]);
}
return [];
}, [store.notes, isMe, defaultIds]);
const esub = useMemo(() => {
const s = new Subscriptions();
s.Id = `${l}-notes:${pubkey.slice(0, 12)}`;
s.Kinds = new Set([EventKind.TextNote]);
s.Ids = new Set(etags);
return s;
}, [etags]);
const subRelated = useMemo(() => {
let sub: Subscriptions | undefined;
if (etags.length > 0 && preferences.enableReactions) {
sub = new Subscriptions();
sub.Id = `${l}-related`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]);
sub.ETags = new Set(etags);
}
return sub ?? null;
}, [etags, preferences]);
const mainSub = useSubscription(esub, { leaveOpen: true, cache: true });
const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true });
const notes = mainSub.store.notes.filter(e => etags.includes(e.id));
const related = relatedSub.store.notes;
return { notes, related };
}