chore: rename Dirs

This commit is contained in:
2023-01-20 11:30:04 +00:00
parent ab1efc2e2e
commit 3533f26e4e
90 changed files with 0 additions and 0 deletions

269
src/Feed/EventPublisher.ts Normal file
View File

@ -0,0 +1,269 @@
import { useSelector } from "react-redux";
import { System } from "Nostr/System";
import { default as NEvent } from "Nostr/Event";
import EventKind from "Nostr/EventKind";
import Tag from "Nostr/Tag";
import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata } from "Nostr";
import { bech32ToHex } from "Util"
import { HashtagRegex } from "Const";
declare global {
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>,
signEvent: (event: RawEvent) => Promise<RawEvent>,
getRelays: () => Promise<[[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<RootState>(s => s.login.relays);
const hasNip07 = 'nostr' in window;
async function signEvent(ev: NEvent): Promise<NEvent> {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
return new NEvent(tmpEv);
} 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 replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return `#[${idx}]`;
}
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub);
content = content.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
}
return {
broadcast: (ev: NEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processContent(ev, msg);
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
let 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 (let 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) {
let 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) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
let temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (let pk of temp) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove) {
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) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
return await signEvent(ev);
}
},
/**
* Respot a note (NIP-18)
*/
repost: async (note: NEvent) => {
if (pubKey) {
let 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 {
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey! : note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
try {
if (hasNip07 && !privKey) {
let cx: string = await barierNip07(() => 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, reject) => {
setTimeout(resolve, t);
});
}
const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

18
src/Feed/FollowersFeed.ts Normal file
View File

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

24
src/Feed/FollowsFeed.ts Normal file
View File

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

138
src/Feed/LoginFeed.ts Normal file
View File

@ -0,0 +1,138 @@
import Nostrich from "nostrich.jpg";
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { HexKey, TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login";
import { RootState } from "State/Store";
import { db } from "Db";
import useSubscription from "Feed/Subscription";
import { mapEventToProfile, MetadataCache } from "Db/User";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
const sub = useMemo(() => {
if (!pubKey) {
return null;
}
let sub = new Subscriptions();
sub.Id = `login:${sub.Id}`;
sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]);
let notifications = new Subscriptions();
notifications.Kinds = new Set([EventKind.TextNote]);
notifications.PTags = new Set([pubKey]);
notifications.Limit = 100;
sub.AddSubscription(notifications);
let dms = new Subscriptions();
dms.Kinds = new Set([EventKind.DirectMessage]);
dms.PTags = new Set([pubKey]);
sub.AddSubscription(dms);
return sub;
}, [pubKey]);
const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => {
let contactList = main.notes.filter(a => a.kind === EventKind.ContactList);
let notifications = main.notes.filter(a => a.kind === EventKind.TextNote);
let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata);
let profiles = metadata.map(a => mapEventToProfile(a))
.filter(a => a !== undefined)
.map(a => a!);
let dms = main.notes.filter(a => a.kind === EventKind.DirectMessage);
for (let cl of contactList) {
if (cl.content !== "") {
let relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at }));
}
let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
dispatch(setFollows(pTags));
}
if ("Notification" in window && Notification.permission === "granted") {
for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) {
sendNotification(nx)
.catch(console.warn);
}
}
dispatch(addNotifications(notifications));
dispatch(addDirectMessage(dms));
(async () => {
let maxProfile = profiles.reduce((acc, v) => {
if (v.created > acc.created) {
acc.profile = v;
acc.created = v.created;
}
return acc;
}, { created: 0, profile: <MetadataCache | null>null });
if (maxProfile.profile) {
let existing = await db.users.get(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) {
await db.users.put(maxProfile.profile);
}
}
})().catch(console.warn);
}, [main]);
}
async function makeNotification(ev: TaggedRawEvent) {
switch (ev.kind) {
case EventKind.TextNote: {
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
const fromUser = users.find(a => a?.pubkey === ev.pubkey);
const name = getDisplayName(fromUser, ev.pubkey);
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;
return {
title: `Reply from ${name}`,
body: replaceTagsWithUser(ev, users).substring(0, 50),
icon: avatarUrl
}
}
}
return null;
}
function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
return ev.content.split(MentionRegex).map(match => {
let matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
let idx = parseInt(matchTag[1]);
let ref = ev.tags[idx];
if (ref && ref[0] === "p" && ref.length > 1) {
let u = users.find(a => a.pubkey === ref[1]);
return `@${getDisplayName(u, ref[1])}`;
}
}
return match;
}).join();
}
async function sendNotification(ev: TaggedRawEvent) {
let n = await makeNotification(ev);
if (n != null && Notification.permission === "granted") {
let worker = await navigator.serviceWorker.ready;
worker.showNotification(n.title, {
body: n.body,
icon: n.icon,
tag: "notification",
timestamp: ev.created_at * 1000,
vibrate: [500]
});
}
}

34
src/Feed/ProfileFeed.ts Normal file
View File

@ -0,0 +1,34 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useMemo } from "react";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { HexKey } from "Nostr";
import { System } from "Nostr/System";
export default function useProfile(pubKey: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined {
const user = useLiveQuery(async () => {
let userList = new Map<HexKey, MetadataCache>();
if (pubKey) {
if (Array.isArray(pubKey)) {
let ret = await db.users.bulkGet(pubKey);
let filtered = ret.filter(a => a !== undefined).map(a => a!);
return new Map(filtered.map(a => [a.pubkey, a]))
} else {
let ret = await db.users.get(pubKey);
if (ret) {
userList.set(ret.pubkey, ret);
}
}
}
return userList;
}, [pubKey]);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
return user;
}

13
src/Feed/RelayState.ts Normal file
View File

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

95
src/Feed/Subscription.ts Normal file
View File

@ -0,0 +1,95 @@
import { useEffect, useMemo, useReducer, useState } from "react";
import { System } from "Nostr/System";
import { TaggedRawEvent } from "Nostr";
import { Subscriptions } from "Nostr/Subscriptions";
export type NoteStore = {
notes: Array<TaggedRawEvent>,
end: boolean
};
export type UseSubscriptionOptions = {
leaveOpen: boolean
}
interface ReducerArg {
type: "END" | "EVENT"
ev?: TaggedRawEvent,
end?: boolean
}
function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") {
state.end = arg.end!;
return state;
}
let ev = arg.ev!;
if (state.notes.some(a => a.id === ev.id)) {
//state.notes.find(a => a.id == ev.id)?.relays?.push(ev.relays[0]);
return state;
}
return {
notes: [
...state.notes,
ev
]
} as NoteStore;
}
const initStore: NoteStore = {
notes: [],
end: false
};
/**
*
* @param {Subscriptions} sub
* @param {any} opt
* @returns
*/
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions) {
const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounce, setDebounce] = useState<number>(0);
useEffect(() => {
if (sub) {
sub.OnEvent = (e) => {
dispatch({
type: "EVENT",
ev: e
});
};
sub.OnEnd = (c) => {
if (!(options?.leaveOpen ?? false)) {
c.RemoveSubscription(sub.Id);
if (sub.IsFinished()) {
System.RemoveSubscription(sub.Id);
}
}
dispatch({
type: "END",
end: true
});
};
console.debug("Adding sub: ", sub.ToObject());
System.AddSubscription(sub);
return () => {
console.debug("Removing sub: ", sub.ToObject());
System.RemoveSubscription(sub.Id);
};
}
}, [sub]);
useEffect(() => {
let t = setTimeout(() => {
setDebounce(s => s += 1);
}, 100);
return () => clearTimeout(t);
}, [state]);
return useMemo(() => state, [debounce]);
}

45
src/Feed/ThreadFeed.ts Normal file
View File

@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from "react";
import { u256 } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "Feed/Subscription";
export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
function addId(id: u256[]) {
setTrackingEvent((s) => {
let tmp = new Set([...s, ...id]);
return Array.from(tmp);
})
}
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([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost]);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
}, [trackingEvents]);
const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => {
// debounce
let t = setTimeout(() => {
let eTags = main.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
let ids = main.notes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
}, 200);
return () => clearTimeout(t);
}, [main.notes]);
return main;
}

94
src/Feed/TimelineFeed.ts Normal file
View File

@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from "react";
import { u256 } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import { unixNow } from "Util";
import useSubscription from "Feed/Subscription";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL"
}
export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global",
items: string[]
}
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const sub = useMemo(() => {
if (subject.type !== "global" && subject.items.length == 0) {
return null;
}
let sub = new Subscriptions();
sub.Id = `timeline:${subject.type}`;
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;
}
}
if (options.method === "LIMIT_UNTIL") {
sub.Until = until;
sub.Limit = 10;
} else {
sub.Since = since;
sub.Until = until;
if (since === undefined) {
sub.Limit = 50;
}
}
return sub;
}, [subject.type, subject.items, until, since, window]);
const main = useSubscription(sub, { leaveOpen: true });
const subNext = useMemo(() => {
if (trackingEvents.length > 0) {
let sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]);
sub.ETags = new Set(trackingEvents);
return sub;
}
return null;
}, [trackingEvents]);
const others = useSubscription(subNext, { leaveOpen: true });
useEffect(() => {
if (main.notes.length > 0) {
setTrackingEvent(s => {
let ids = main.notes.map(a => a.id);
let temp = new Set([...s, ...ids]);
return Array.from(temp);
});
}
}, [main.notes]);
return {
main: main.notes,
others: others.notes,
loadMore: () => {
if (options.method === "LIMIT_UNTIL") {
let oldest = main.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);
}
}
};
}

55
src/Feed/VoidUpload.ts Normal file
View File

@ -0,0 +1,55 @@
import * as secp from "@noble/secp256k1";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
*/
export default async function VoidUpload(file: File | Blob, filename: string) {
const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buf);
let req = await fetch(`https://void.cat/upload`, {
mode: "cors",
method: "POST",
body: buf,
headers: {
"Content-Type": "application/octet-stream",
"V-Content-Type": file.type,
"V-Filename": filename,
"V-Full-Digest": secp.utils.bytesToHex(new Uint8Array(digest)),
"V-Description": "Upload from https://snort.social"
}
});
if (req.ok) {
let rsp: VoidUploadResponse = await req.json();
return rsp;
}
return null;
}
export type VoidUploadResponse = {
ok: boolean,
file?: VoidFile,
errorMessage?: string
}
export type VoidFile = {
id: string,
meta?: VoidFileMeta
}
export type VoidFileMeta = {
version: number,
id: string,
name?: string,
size: number,
uploaded: Date,
description?: string,
mimeType?: string,
digest?: string,
url?: string,
expires?: Date,
storage?: string,
encryptionParams?: string,
}