chore: rename Dirs
This commit is contained in:
269
src/Feed/EventPublisher.ts
Normal file
269
src/Feed/EventPublisher.ts
Normal 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
18
src/Feed/FollowersFeed.ts
Normal 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
24
src/Feed/FollowsFeed.ts
Normal 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
138
src/Feed/LoginFeed.ts
Normal 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
34
src/Feed/ProfileFeed.ts
Normal 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
13
src/Feed/RelayState.ts
Normal 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
95
src/Feed/Subscription.ts
Normal 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
45
src/Feed/ThreadFeed.ts
Normal 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
94
src/Feed/TimelineFeed.ts
Normal 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
55
src/Feed/VoidUpload.ts
Normal 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,
|
||||
}
|
Reference in New Issue
Block a user