refactor: TS #69
@ -15,6 +15,7 @@
|
|||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"dexie": "^3.2.2",
|
"dexie": "^3.2.2",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { RelaySettings } from "./nostr/Connection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Websocket re-connect timeout
|
* Websocket re-connect timeout
|
||||||
@ -12,11 +13,11 @@ export const ProfileCacheExpire = (1_000 * 60 * 5);
|
|||||||
/**
|
/**
|
||||||
* Default bootstrap relays
|
* Default bootstrap relays
|
||||||
*/
|
*/
|
||||||
export const DefaultRelays = {
|
export const DefaultRelays = new Map<string, RelaySettings>([
|
||||||
"wss://relay.snort.social": { read: true, write: true },
|
["wss://relay.snort.social", { read: true, write: true }],
|
||||||
"wss://relay.damus.io": { read: true, write: true },
|
["wss://relay.damus.io", { read: true, write: true }],
|
||||||
"wss://nostr-pub.wellorder.net": { read: true, write: true }
|
["wss://nostr-pub.wellorder.net", { read: true, write: true }],
|
||||||
};
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of recommended follows for new users
|
* List of recommended follows for new users
|
@ -1,12 +1,16 @@
|
|||||||
import * as secp from "@noble/secp256k1";
|
import * as secp from "@noble/secp256k1";
|
||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
|
import { HexKey, u256 } from "./nostr";
|
||||||
|
|
||||||
export async function openFile() {
|
export async function openFile() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let elm = document.createElement("input");
|
let elm = document.createElement("input");
|
||||||
elm.type = "file";
|
elm.type = "file";
|
||||||
elm.onchange = (e) => {
|
elm.onchange = (e: Event) => {
|
||||||
resolve(e.target.files[0]);
|
let elm = e.target as HTMLInputElement;
|
||||||
|
if (elm.files) {
|
||||||
|
resolve(elm.files[0]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
elm.click();
|
elm.click();
|
||||||
});
|
});
|
||||||
@ -15,9 +19,9 @@ export async function openFile() {
|
|||||||
/**
|
/**
|
||||||
* Parse bech32 ids
|
* Parse bech32 ids
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/19.md
|
* https://github.com/nostr-protocol/nips/blob/master/19.md
|
||||||
* @param {string} id bech32 id
|
* @param id bech32 id
|
||||||
*/
|
*/
|
||||||
export function parseId(id) {
|
export function parseId(id: string) {
|
||||||
const hrp = ["note", "npub", "nsec"];
|
const hrp = ["note", "npub", "nsec"];
|
||||||
try {
|
try {
|
||||||
if (hrp.some(a => id.startsWith(a))) {
|
if (hrp.some(a => id.startsWith(a))) {
|
||||||
@ -27,7 +31,7 @@ export function parseId(id) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bech32ToHex(str) {
|
export function bech32ToHex(str: string) {
|
||||||
let nKey = bech32.decode(str);
|
let nKey = bech32.decode(str);
|
||||||
let buff = bech32.fromWords(nKey.words);
|
let buff = bech32.fromWords(nKey.words);
|
||||||
return secp.utils.bytesToHex(Uint8Array.from(buff));
|
return secp.utils.bytesToHex(Uint8Array.from(buff));
|
||||||
@ -35,10 +39,10 @@ export function bech32ToHex(str) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode bech32 to string UTF-8
|
* Decode bech32 to string UTF-8
|
||||||
* @param {string} str bech32 encoded string
|
* @param str bech32 encoded string
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function bech32ToText(str) {
|
export function bech32ToText(str: string) {
|
||||||
let decoded = bech32.decode(str, 1000);
|
let decoded = bech32.decode(str, 1000);
|
||||||
let buf = bech32.fromWords(decoded.words);
|
let buf = bech32.fromWords(decoded.words);
|
||||||
return new TextDecoder().decode(Uint8Array.from(buf));
|
return new TextDecoder().decode(Uint8Array.from(buf));
|
||||||
@ -46,10 +50,10 @@ export function bech32ToText(str) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert hex note id to bech32 link url
|
* Convert hex note id to bech32 link url
|
||||||
* @param {string} hex
|
* @param hex
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function eventLink(hex) {
|
export function eventLink(hex: u256) {
|
||||||
return `/e/${hexToBech32("note", hex)}`;
|
return `/e/${hexToBech32("note", hex)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +61,7 @@ export function eventLink(hex) {
|
|||||||
* Convert hex to bech32
|
* Convert hex to bech32
|
||||||
* @param {string} hex
|
* @param {string} hex
|
||||||
*/
|
*/
|
||||||
export function hexToBech32(hrp, hex) {
|
export function hexToBech32(hrp: string, hex: string) {
|
||||||
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) {
|
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@ -76,7 +80,7 @@ export function hexToBech32(hrp, hex) {
|
|||||||
* @param {string} hex
|
* @param {string} hex
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function profileLink(hex) {
|
export function profileLink(hex: HexKey) {
|
||||||
return `/p/${hexToBech32("npub", hex)}`;
|
return `/p/${hexToBech32("npub", hex)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +97,7 @@ export const Reaction = {
|
|||||||
* @param {string} content
|
* @param {string} content
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function normalizeReaction(content) {
|
export function normalizeReaction(content: string) {
|
||||||
switch (content) {
|
switch (content) {
|
||||||
case "": return Reaction.Positive;
|
case "": return Reaction.Positive;
|
||||||
case "🤙": return Reaction.Positive;
|
case "🤙": return Reaction.Positive;
|
||||||
@ -109,10 +113,10 @@ export function normalizeReaction(content) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts LNURL service to LN Address
|
* Converts LNURL service to LN Address
|
||||||
* @param {string} lnurl
|
* @param lnurl
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function extractLnAddress(lnurl) {
|
export function extractLnAddress(lnurl: string) {
|
||||||
// some clients incorrectly set this to LNURL service, patch this
|
// some clients incorrectly set this to LNURL service, patch this
|
||||||
if (lnurl.toLowerCase().startsWith("lnurl")) {
|
if (lnurl.toLowerCase().startsWith("lnurl")) {
|
||||||
let url = bech32ToText(lnurl);
|
let url = bech32ToText(lnurl);
|
32
src/db/User.ts
Normal file
32
src/db/User.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { HexKey, TaggedRawEvent, UserMetadata } from "../nostr";
|
||||||
|
|
||||||
|
export interface MetadataCache extends UserMetadata {
|
||||||
|
/**
|
||||||
|
* When the object was saved in cache
|
||||||
|
*/
|
||||||
|
loaded: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the source metadata event was created
|
||||||
|
*/
|
||||||
|
created: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pubkey of the owner of this metadata
|
||||||
|
*/
|
||||||
|
pubkey: HexKey
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapEventToProfile(ev: TaggedRawEvent) {
|
||||||
|
try {
|
||||||
|
let data: UserMetadata = JSON.parse(ev.content);
|
||||||
|
return {
|
||||||
|
pubkey: ev.pubkey,
|
||||||
|
created: ev.created_at,
|
||||||
|
loaded: new Date().getTime(),
|
||||||
|
...data
|
||||||
|
} as MetadataCache;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse JSON", ev, e);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import Dexie, { Table } from 'dexie';
|
import Dexie, { Table } from 'dexie';
|
||||||
|
import { MetadataCache } from './User';
|
||||||
|
|
||||||
import type { User } from './nostr/types';
|
|
||||||
|
|
||||||
export class SnortDB extends Dexie {
|
export class SnortDB extends Dexie {
|
||||||
users!: Table<User>;
|
users!: Table<MetadataCache>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('snortDB');
|
super('snortDB');
|
@ -24,9 +24,9 @@ export default function DM(props: DMProps) {
|
|||||||
const { ref, inView, entry } = useInView();
|
const { ref, inView, entry } = useInView();
|
||||||
|
|
||||||
async function decrypt() {
|
async function decrypt() {
|
||||||
let e = Event.FromObject(props.data);
|
let e = new Event(props.data);
|
||||||
let decrypted = await publisher.decryptDm(e);
|
let decrypted = await publisher.decryptDm(e);
|
||||||
setContent(decrypted);
|
setContent(decrypted || "<ERROR>");
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
@ -15,14 +15,10 @@ import AsyncButton from "./AsyncButton";
|
|||||||
import LNURLTip from "./LNURLTip";
|
import LNURLTip from "./LNURLTip";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Copy from "./Copy";
|
import Copy from "./Copy";
|
||||||
// @ts-ignore
|
|
||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
// @ts-ignore
|
|
||||||
import useEventPublisher from "../feed/EventPublisher";
|
import useEventPublisher from "../feed/EventPublisher";
|
||||||
// @ts-ignore
|
|
||||||
import { resetProfile } from "../state/Users";
|
|
||||||
// @ts-ignore
|
|
||||||
import { hexToBech32 } from "../Util";
|
import { hexToBech32 } from "../Util";
|
||||||
|
import { UserMetadata } from "../nostr";
|
||||||
|
|
||||||
type Nip05ServiceProps = {
|
type Nip05ServiceProps = {
|
||||||
name: string,
|
name: string,
|
||||||
@ -35,10 +31,9 @@ type Nip05ServiceProps = {
|
|||||||
type ReduxStore = any;
|
type ReduxStore = any;
|
||||||
|
|
||||||
export default function Nip5Service(props: Nip05ServiceProps) {
|
export default function Nip5Service(props: Nip05ServiceProps) {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
|
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
|
||||||
const user: any = useProfile(pubkey);
|
const user = useProfile(pubkey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const svc = new ServiceProvider(props.service);
|
const svc = new ServiceProvider(props.service);
|
||||||
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
||||||
@ -71,11 +66,11 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
|||||||
setError(undefined);
|
setError(undefined);
|
||||||
setAvailabilityResponse(undefined);
|
setAvailabilityResponse(undefined);
|
||||||
if (handle && domain) {
|
if (handle && domain) {
|
||||||
if(handle.length < (domainConfig?.length[0] ?? 2)) {
|
if (handle.length < (domainConfig?.length[0] ?? 2)) {
|
||||||
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
|
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(handle.length > (domainConfig?.length[1] ?? 20)) {
|
if (handle.length > (domainConfig?.length[1] ?? 20)) {
|
||||||
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -149,17 +144,15 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(handle: string, domain: string) {
|
async function updateProfile(handle: string, domain: string) {
|
||||||
let newProfile = {
|
if (user) {
|
||||||
...user,
|
let newProfile = {
|
||||||
nip05: `${handle}@${domain}`
|
...user,
|
||||||
};
|
nip05: `${handle}@${domain}`
|
||||||
delete newProfile["loaded"];
|
} as UserMetadata;
|
||||||
delete newProfile["fromEvent"];
|
let ev = await publisher.metadata(newProfile);
|
||||||
delete newProfile["pubkey"];
|
publisher.broadcast(ev);
|
||||||
let ev = await publisher.metadata(newProfile);
|
navigate("/settings");
|
||||||
dispatch(resetProfile(pubkey));
|
}
|
||||||
publisher.broadcast(ev);
|
|
||||||
navigate("/settings");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import "./Note.css";
|
import "./Note.css";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import Event from "../nostr/Event";
|
import Event from "../nostr/Event";
|
||||||
@ -9,15 +8,16 @@ import Text from "./Text";
|
|||||||
import { eventLink, hexToBech32 } from "../Util";
|
import { eventLink, hexToBech32 } from "../Util";
|
||||||
import NoteFooter from "./NoteFooter";
|
import NoteFooter from "./NoteFooter";
|
||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
|
import EventKind from "../nostr/EventKind";
|
||||||
|
import useProfile from "../feed/ProfileFeed";
|
||||||
|
|
||||||
export default function Note(props) {
|
export default function Note(props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const opt = props.options;
|
const { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props
|
||||||
const dataEvent = props["data-ev"];
|
const ev = useMemo(() => parsedEvent ?? new Event(data), [data]);
|
||||||
const { data, isThread, reactions, deletion, hightlight } = props
|
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||||
|
|
||||||
const users = useSelector(s => s.users?.users);
|
const users = useProfile(pubKeys);
|
||||||
const ev = dataEvent ?? Event.FromObject(data);
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
@ -31,8 +31,8 @@ export default function Note(props) {
|
|||||||
if (deletion?.length > 0) {
|
if (deletion?.length > 0) {
|
||||||
return (<b className="error">Deleted</b>);
|
return (<b className="error">Deleted</b>);
|
||||||
}
|
}
|
||||||
return <Text content={body} tags={ev.Tags} users={users} />;
|
return <Text content={body} tags={ev.Tags} users={users || []} />;
|
||||||
}, [data, dataEvent, reactions, deletion]);
|
}, [props]);
|
||||||
|
|
||||||
function goToEvent(e, id) {
|
function goToEvent(e, id) {
|
||||||
if (!window.location.pathname.startsWith("/e/")) {
|
if (!window.location.pathname.startsWith("/e/")) {
|
||||||
@ -48,7 +48,7 @@ export default function Note(props) {
|
|||||||
|
|
||||||
const maxMentions = 2;
|
const maxMentions = 2;
|
||||||
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
let mentions = ev.Thread?.PubKeys?.map(a => [a, users[a]])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12))
|
let mentions = ev.Thread?.PubKeys?.map(a => [a, users ? users[a] : null])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12))
|
||||||
.sort((a, b) => a.startsWith("npub") ? 1 : -1);
|
.sort((a, b) => a.startsWith("npub") ? 1 : -1);
|
||||||
let othersLength = mentions.length - maxMentions
|
let othersLength = mentions.length - maxMentions
|
||||||
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
|
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
|
||||||
@ -59,7 +59,7 @@ export default function Note(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ev.IsContent()) {
|
if (ev.Kind !== EventKind.TextNote) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>Unknown event kind: {ev.Kind}</h4>
|
<h4>Unknown event kind: {ev.Kind}</h4>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useState, Component } from "react";
|
import { useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
|
import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ export function NoteCreator(props) {
|
|||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const users = useSelector((state) => state.users.users)
|
|
||||||
|
|
||||||
async function sendNote() {
|
async function sendNote() {
|
||||||
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
|
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
|
||||||
@ -71,7 +69,6 @@ export function NoteCreator(props) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
className={`textarea ${active ? "textarea--focused" : ""}`}
|
className={`textarea ${active ? "textarea--focused" : ""}`}
|
||||||
users={users}
|
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={note}
|
value={note}
|
||||||
onFocus={() => setActive(true)}
|
onFocus={() => setActive(true)}
|
||||||
|
@ -7,13 +7,14 @@ import useEventPublisher from "../feed/EventPublisher";
|
|||||||
import { normalizeReaction, Reaction } from "../Util";
|
import { normalizeReaction, Reaction } from "../Util";
|
||||||
import { NoteCreator } from "./NoteCreator";
|
import { NoteCreator } from "./NoteCreator";
|
||||||
import LNURLTip from "./LNURLTip";
|
import LNURLTip from "./LNURLTip";
|
||||||
|
import useProfile from "../feed/ProfileFeed";
|
||||||
|
|
||||||
export default function NoteFooter(props) {
|
export default function NoteFooter(props) {
|
||||||
const reactions = props.reactions;
|
const reactions = props.reactions;
|
||||||
const ev = props.ev;
|
const ev = props.ev;
|
||||||
|
|
||||||
const login = useSelector(s => s.login.publicKey);
|
const login = useSelector(s => s.login.publicKey);
|
||||||
const author = useSelector(s => s.users.users[ev.RootPubKey]);
|
const author = useProfile(ev.RootPubKey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [reply, setReply] = useState(false);
|
const [reply, setReply] = useState(false);
|
||||||
const [tip, setTip] = useState(false);
|
const [tip, setTip] = useState(false);
|
||||||
@ -105,10 +106,10 @@ export default function NoteFooter(props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<NoteCreator
|
<NoteCreator
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
replyTo={ev}
|
replyTo={ev}
|
||||||
onSend={(e) => setReply(false)}
|
onSend={(e) => setReply(false)}
|
||||||
show={reply}
|
show={reply}
|
||||||
/>
|
/>
|
||||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
|
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
|
||||||
</>
|
</>
|
||||||
|
@ -9,7 +9,7 @@ import { useMemo } from "react";
|
|||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
|
|
||||||
export default function NoteReaction(props) {
|
export default function NoteReaction(props) {
|
||||||
const ev = props["data-ev"] || Event.FromObject(props.data);
|
const ev = props["data-ev"] || new Event(props.data);
|
||||||
|
|
||||||
const refEvent = useMemo(() => {
|
const refEvent = useMemo(() => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
|
@ -38,11 +38,11 @@ function transformHttpLink(a) {
|
|||||||
return <a key={url} href={url} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
|
return <a key={url} href={url} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
|
||||||
}
|
}
|
||||||
} else if (tweetId) {
|
} else if (tweetId) {
|
||||||
return (
|
return (
|
||||||
<div className="tweet" key={tweetId}>
|
<div className="tweet" key={tweetId}>
|
||||||
<TwitterTweetEmbed tweetId={tweetId} />
|
<TwitterTweetEmbed tweetId={tweetId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (youtubeId) {
|
} else if (youtubeId) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { useSelector } from "react-redux";
|
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
|
||||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||||
@ -10,14 +9,13 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
|
|||||||
import "./Textarea.css";
|
import "./Textarea.css";
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
// @ts-expect-error
|
|
||||||
import { hexToBech32 } from "../Util";
|
import { hexToBech32 } from "../Util";
|
||||||
import type { User } from "../nostr/types";
|
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
import { MetadataCache } from "../db/User";
|
||||||
|
|
||||||
function searchUsers(query: string, users: User[]) {
|
function searchUsers(query: string, users: MetadataCache[]) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
return users.filter(({ name, display_name, about, nip05 }: User) => {
|
return users.filter(({ name, display_name, about, nip05 }: MetadataCache) => {
|
||||||
return name?.toLowerCase().includes(q)
|
return name?.toLowerCase().includes(q)
|
||||||
|| display_name?.toLowerCase().includes(q)
|
|| display_name?.toLowerCase().includes(q)
|
||||||
|| about?.toLowerCase().includes(q)
|
|| about?.toLowerCase().includes(q)
|
||||||
@ -25,7 +23,7 @@ function searchUsers(query: string, users: User[]) {
|
|||||||
}).slice(0, 3)
|
}).slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: User) => {
|
const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: MetadataCache) => {
|
||||||
return (
|
return (
|
||||||
<div key={pubkey} className="user-item">
|
<div key={pubkey} className="user-item">
|
||||||
<div className="user-picture">
|
<div className="user-picture">
|
||||||
@ -39,40 +37,28 @@ const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: User) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUser({ pubkey, picture, nip05, name, display_name }: User) {
|
|
||||||
return { pubkey, nip05, name, picture, display_name }
|
|
||||||
}
|
|
||||||
|
|
||||||
const Textarea = ({ users, onChange, ...rest }: any) => {
|
const Textarea = ({ users, onChange, ...rest }: any) => {
|
||||||
const normalizedUsers = Object.keys(users).reduce((acc, pk) => {
|
const allUsers = useLiveQuery(
|
||||||
return {...acc, [pk]: normalizeUser(users[pk]) }
|
() => db.users.toArray()
|
||||||
}, {})
|
);
|
||||||
const dbUsers = useLiveQuery(
|
|
||||||
() => db.users.toArray().then(usrs => {
|
|
||||||
return usrs.reduce((acc, usr) => {
|
|
||||||
return { ...acc, [usr.pubkey]: normalizeUser(usr)}
|
|
||||||
}, {})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const allUsers: User[] = Object.values({...normalizedUsers, ...dbUsers})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactTextareaAutocomplete
|
<ReactTextareaAutocomplete
|
||||||
{...rest}
|
{...rest}
|
||||||
loadingComponent={() => <span>Loading....</span>}
|
loadingComponent={() => <span>Loading....</span>}
|
||||||
placeholder="Say something!"
|
placeholder="Say something!"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
textAreaComponent={TextareaAutosize}
|
textAreaComponent={TextareaAutosize}
|
||||||
trigger={{
|
trigger={{
|
||||||
"@": {
|
"@": {
|
||||||
afterWhitespace: true,
|
afterWhitespace: true,
|
||||||
dataProvider: token => dbUsers ? searchUsers(token, allUsers) : [],
|
dataProvider: token => allUsers ? searchUsers(token, allUsers) : [],
|
||||||
component: (props: any) => <UserItem {...props.entity} />,
|
component: (props: any) => <UserItem {...props.entity} />,
|
||||||
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
|
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Textarea
|
export default Textarea
|
||||||
|
@ -10,7 +10,7 @@ export default function Thread(props) {
|
|||||||
const thisEvent = props.this;
|
const thisEvent = props.this;
|
||||||
|
|
||||||
/** @type {Array<Event>} */
|
/** @type {Array<Event>} */
|
||||||
const notes = props.notes?.map(a => Event.FromObject(a));
|
const notes = props.notes?.map(a => new Event(a));
|
||||||
|
|
||||||
// root note has no thread info
|
// root note has no thread info
|
||||||
const root = useMemo(() => notes.find(a => a.Thread === null), [notes]);
|
const root = useMemo(() => notes.find(a => a.Thread === null), [notes]);
|
||||||
|
@ -25,7 +25,7 @@ export default function Timeline({ global, pubkeys }) {
|
|||||||
}
|
}
|
||||||
case EventKind.Reaction:
|
case EventKind.Reaction:
|
||||||
case EventKind.Repost: {
|
case EventKind.Repost: {
|
||||||
return <NoteReaction data={e} key={e.id}/>
|
return <NoteReaction data={e} key={e.id} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,233 +0,0 @@
|
|||||||
import { useSelector } from "react-redux";
|
|
||||||
|
|
||||||
import { System } from "..";
|
|
||||||
import Event from "../nostr/Event";
|
|
||||||
import EventKind from "../nostr/EventKind";
|
|
||||||
import Tag from "../nostr/Tag";
|
|
||||||
import { bech32ToHex } from "../Util"
|
|
||||||
|
|
||||||
export default function useEventPublisher() {
|
|
||||||
const pubKey = useSelector(s => s.login.publicKey);
|
|
||||||
const privKey = useSelector(s => s.login.privateKey);
|
|
||||||
const follows = useSelector(s => s.login.follows);
|
|
||||||
const relays = useSelector(s => s.login.relays);
|
|
||||||
const hasNip07 = 'nostr' in window;
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {Event} ev
|
|
||||||
* @param {*} privKey
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
async function signEvent(ev) {
|
|
||||||
if (hasNip07 && !privKey) {
|
|
||||||
ev.Id = await ev.CreateId();
|
|
||||||
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
|
|
||||||
return Event.FromObject(tmpEv);
|
|
||||||
} else {
|
|
||||||
await ev.Sign(privKey);
|
|
||||||
}
|
|
||||||
return ev;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function processMentions(ev, msg) {
|
|
||||||
const replaceNpub = (match) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
|
||||||
ev.Content = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
broadcast: (ev) => {
|
|
||||||
console.debug("Sending event: ", ev);
|
|
||||||
System.BroadcastEvent(ev);
|
|
||||||
},
|
|
||||||
metadata: async (obj) => {
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.SetMetadata;
|
|
||||||
ev.Content = JSON.stringify(obj);
|
|
||||||
return await signEvent(ev, privKey);
|
|
||||||
},
|
|
||||||
note: async (msg) => {
|
|
||||||
if (typeof msg !== "string") {
|
|
||||||
throw "Must be text!";
|
|
||||||
}
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
processMentions(ev, msg);
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Reply to a note
|
|
||||||
* @param {Event} replyTo
|
|
||||||
* @param {String} msg
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
reply: async (replyTo, msg) => {
|
|
||||||
if (typeof msg !== "string") {
|
|
||||||
throw "Must be text!";
|
|
||||||
}
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
|
|
||||||
let thread = replyTo.Thread;
|
|
||||||
if (thread) {
|
|
||||||
if (thread.Root) {
|
|
||||||
ev.Tags.push(new Tag(["e", thread.Root.Event, "", "root"], ev.Tags.length));
|
|
||||||
} else {
|
|
||||||
ev.Tags.push(new Tag(["e", thread.ReplyTo.Event, "", "root"], ev.Tags.length));
|
|
||||||
}
|
|
||||||
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
|
|
||||||
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));
|
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], 1));
|
|
||||||
}
|
|
||||||
processMentions(ev, msg);
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
react: async (evRef, content = "+") => {
|
|
||||||
let ev = Event.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 () => {
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.ContactList;
|
|
||||||
ev.Content = JSON.stringify(relays);
|
|
||||||
for (let pk of follows) {
|
|
||||||
ev.Tags.push(new Tag(["p", pk]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
addFollow: async (pkAdd) => {
|
|
||||||
let ev = Event.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]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
removeFollow: async (pkRemove) => {
|
|
||||||
let ev = Event.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]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
delete: async (id) => {
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Deletion;
|
|
||||||
ev.Content = "";
|
|
||||||
ev.Tags.push(new Tag(["e", id]));
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Respot a note
|
|
||||||
* @param {Event} note
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
repost: async (note) => {
|
|
||||||
if (typeof note.Id !== "string") {
|
|
||||||
throw "Must be parsed note in Event class";
|
|
||||||
}
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Repost;
|
|
||||||
ev.Content = JSON.stringify(note.Original);
|
|
||||||
ev.Tags.push(new Tag(["e", note.Id]));
|
|
||||||
ev.Tags.push(new Tag(["p", note.PubKey]));
|
|
||||||
return await signEvent(ev);
|
|
||||||
},
|
|
||||||
decryptDm: async (note) => {
|
|
||||||
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>";
|
|
||||||
}
|
|
||||||
return "test";
|
|
||||||
},
|
|
||||||
sendDm: async (content, to) => {
|
|
||||||
let ev = Event.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.DirectMessage;
|
|
||||||
ev.Content = content;
|
|
||||||
ev.Tags.push(new Tag(["p", to]));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (hasNip07 && !privKey) {
|
|
||||||
let cx = 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) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
setTimeout(resolve, t);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const barierNip07 = async (then) => {
|
|
||||||
while (isNip07Busy) {
|
|
||||||
await delay(10);
|
|
||||||
}
|
|
||||||
isNip07Busy = true;
|
|
||||||
try {
|
|
||||||
return await then();
|
|
||||||
} finally {
|
|
||||||
isNip07Busy = false;
|
|
||||||
}
|
|
||||||
};
|
|
261
src/feed/EventPublisher.ts
Normal file
261
src/feed/EventPublisher.ts
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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 processMentions(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
||||||
|
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;
|
||||||
|
processMentions(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processMentions(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) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
@ -1,14 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
|
|
||||||
export default function useFollowersFeed(pubkey) {
|
export default function useFollowersFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let x = new Subscriptions();
|
let x = new Subscriptions();
|
||||||
x.Id = "followers";
|
x.Id = "followers";
|
||||||
x.Kinds.add(EventKind.ContactList);
|
x.Kinds = new Set([EventKind.ContactList]);
|
||||||
x.PTags.add(pubkey);
|
x.PTags = new Set([pubkey]);
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
@ -1,14 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
|
|
||||||
export default function useFollowsFeed(pubkey) {
|
export default function useFollowsFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let x = new Subscriptions();
|
let x = new Subscriptions();
|
||||||
x.Id = "follows";
|
x.Id = "follows";
|
||||||
x.Kinds.add(EventKind.ContactList);
|
x.Kinds = new Set([EventKind.ContactList]);
|
||||||
x.Authors.add(pubkey);
|
x.Authors = new Set([pubkey]);
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
@ -1,19 +1,20 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login";
|
import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login";
|
||||||
import { setUserData } from "../state/Users";
|
import { RootState } from "../state/Store";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
import { mapEventToProfile } from "./UsersFeed";
|
import { mapEventToProfile } from "../db/User";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Managed loading data for the current logged in user
|
* Managed loading data for the current logged in user
|
||||||
*/
|
*/
|
||||||
export default function useLoginFeed() {
|
export default function useLoginFeed() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]);
|
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!pubKey) {
|
if (!pubKey) {
|
||||||
@ -22,29 +23,28 @@ export default function useLoginFeed() {
|
|||||||
|
|
||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `login:${sub.Id}`;
|
sub.Id = `login:${sub.Id}`;
|
||||||
sub.Authors.add(pubKey);
|
sub.Authors = new Set([pubKey]);
|
||||||
sub.Kinds.add(EventKind.ContactList);
|
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]);
|
||||||
sub.Kinds.add(EventKind.SetMetadata);
|
|
||||||
sub.Kinds.add(EventKind.DirectMessage);
|
|
||||||
|
|
||||||
let notifications = new Subscriptions();
|
let notifications = new Subscriptions();
|
||||||
notifications.Kinds.add(EventKind.TextNote);
|
notifications.Kinds = new Set([EventKind.TextNote, EventKind.DirectMessage]);
|
||||||
notifications.Kinds.add(EventKind.DirectMessage);
|
notifications.PTags = new Set([pubKey]);
|
||||||
notifications.PTags.add(pubKey);
|
|
||||||
notifications.Limit = 100;
|
notifications.Limit = 100;
|
||||||
sub.AddSubscription(notifications);
|
sub.AddSubscription(notifications);
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
|
|
||||||
const { notes } = useSubscription(sub, { leaveOpen: true });
|
const main = useSubscription(sub, { leaveOpen: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let contactList = notes.filter(a => a.kind === EventKind.ContactList);
|
let contactList = main.notes.filter(a => a.kind === EventKind.ContactList);
|
||||||
let notifications = notes.filter(a => a.kind === EventKind.TextNote);
|
let notifications = main.notes.filter(a => a.kind === EventKind.TextNote);
|
||||||
let metadata = notes.filter(a => a.kind === EventKind.SetMetadata)
|
let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata);
|
||||||
let profiles = metadata.map(a => mapEventToProfile(a));
|
let profiles = metadata.map(a => mapEventToProfile(a))
|
||||||
let dms = notes.filter(a => a.kind === EventKind.DirectMessage);
|
.filter(a => a !== undefined)
|
||||||
|
.map(a => a!);
|
||||||
|
let dms = main.notes.filter(a => a.kind === EventKind.DirectMessage);
|
||||||
|
|
||||||
for (let cl of contactList) {
|
for (let cl of contactList) {
|
||||||
if (cl.content !== "") {
|
if (cl.content !== "") {
|
||||||
@ -62,11 +62,7 @@ export default function useLoginFeed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dispatch(addNotifications(notifications));
|
dispatch(addNotifications(notifications));
|
||||||
dispatch(setUserData(profiles));
|
db.users.bulkPut(profiles);
|
||||||
const userMetadata = metadata.map(ev => {
|
|
||||||
return {...JSON.parse(ev.content), pubkey: ev.pubkey }
|
|
||||||
})
|
|
||||||
db.users.bulkPut(metadata);
|
|
||||||
dispatch(addDirectMessage(dms));
|
dispatch(addDirectMessage(dms));
|
||||||
}, [notes]);
|
}, [main]);
|
||||||
}
|
}
|
@ -1,16 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { addPubKey } from "../state/Users";
|
|
||||||
|
|
||||||
export default function useProfile(pubKey) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const user = useSelector(s => s.users.users[pubKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pubKey) {
|
|
||||||
dispatch(addPubKey(pubKey));
|
|
||||||
}
|
|
||||||
}, [pubKey]);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
27
src/feed/ProfileFeed.ts
Normal file
27
src/feed/ProfileFeed.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
import { System } from "../nostr/System";
|
||||||
|
|
||||||
|
export default function useProfile(pubKey: HexKey | Array<HexKey>) {
|
||||||
|
const user = useLiveQuery(async () => {
|
||||||
|
if (pubKey) {
|
||||||
|
if (Array.isArray(pubKey)) {
|
||||||
|
let ret = await db.users.bulkGet(pubKey);
|
||||||
|
return ret.filter(a => a !== undefined).map(a => a!);
|
||||||
|
} else {
|
||||||
|
return await db.users.get(pubKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pubKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pubKey) {
|
||||||
|
System.TrackMetadata(pubKey);
|
||||||
|
return () => System.UntrackMetadata(pubKey);
|
||||||
|
}
|
||||||
|
}, [pubKey]);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
|
||||||
import { System } from "..";
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
export default function useRelayState(addr) {
|
|
||||||
let c = System.Sockets[addr];
|
|
||||||
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noop);
|
|
||||||
}
|
|
11
src/feed/RelayState.ts
Normal file
11
src/feed/RelayState.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { System } from "../nostr/System";
|
||||||
|
import { CustomHook } from "../nostr/Connection";
|
||||||
|
|
||||||
|
const noop = (f: CustomHook) => { return () => { }; };
|
||||||
|
const noopState = () => { };
|
||||||
|
|
||||||
|
export default function useRelayState(addr: string) {
|
||||||
|
let c = System.Sockets.get(addr);
|
||||||
|
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
|
||||||
|
}
|
@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useReducer } from "react";
|
import { useEffect, useReducer } from "react";
|
||||||
import { System } from "..";
|
import { System } from "../nostr/System";
|
||||||
|
import { TaggedRawEvent } from "../nostr";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
|
|
||||||
function notesReducer(state, ev) {
|
export type NoteStore = {
|
||||||
|
notes: Array<TaggedRawEvent>
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseSubscriptionOptions = {
|
||||||
|
leaveOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesReducer(state: NoteStore, ev: TaggedRawEvent) {
|
||||||
if (state.notes.some(a => a.id === ev.id)) {
|
if (state.notes.some(a => a.id === ev.id)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@ -21,13 +30,8 @@ function notesReducer(state, ev) {
|
|||||||
* @param {any} opt
|
* @param {any} opt
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export default function useSubscription(sub, opt) {
|
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions) {
|
||||||
const [state, dispatch] = useReducer(notesReducer, { notes: [] });
|
const [state, dispatch] = useReducer(notesReducer, <NoteStore>{ notes: [] });
|
||||||
|
|
||||||
const options = {
|
|
||||||
leaveOpen: false,
|
|
||||||
...opt
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@ -35,7 +39,7 @@ export default function useSubscription(sub, opt) {
|
|||||||
dispatch(e);
|
dispatch(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options.leaveOpen) {
|
if (!(options?.leaveOpen ?? false)) {
|
||||||
sub.OnEnd = (c) => {
|
sub.OnEnd = (c) => {
|
||||||
c.RemoveSubscription(sub.Id);
|
c.RemoveSubscription(sub.Id);
|
||||||
if (sub.IsFinished()) {
|
if (sub.IsFinished()) {
|
@ -1,19 +1,18 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { u256 } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
|
|
||||||
export default function useThreadFeed(id) {
|
export default function useThreadFeed(id: u256) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const thisSub = new Subscriptions();
|
const thisSub = new Subscriptions();
|
||||||
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
||||||
thisSub.Ids.add(id);
|
thisSub.Ids = new Set([id]);
|
||||||
|
|
||||||
// get replies to this event
|
// get replies to this event
|
||||||
const subRelated = new Subscriptions();
|
const subRelated = new Subscriptions();
|
||||||
subRelated.Kinds.add(EventKind.Reaction);
|
subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
|
||||||
subRelated.Kinds.add(EventKind.TextNote);
|
|
||||||
subRelated.Kinds.add(EventKind.Deletion);
|
|
||||||
subRelated.ETags = thisSub.Ids;
|
subRelated.ETags = thisSub.Ids;
|
||||||
thisSub.AddSubscription(subRelated);
|
thisSub.AddSubscription(subRelated);
|
||||||
|
|
||||||
@ -28,6 +27,7 @@ export default function useThreadFeed(id) {
|
|||||||
if (thisNote) {
|
if (thisNote) {
|
||||||
let otherSubs = new Subscriptions();
|
let otherSubs = new Subscriptions();
|
||||||
otherSubs.Id = `thread-related:${id.substring(0, 8)}`;
|
otherSubs.Id = `thread-related:${id.substring(0, 8)}`;
|
||||||
|
otherSubs.Ids = new Set();
|
||||||
for (let e of thisNote.tags.filter(a => a[0] === "e")) {
|
for (let e of thisNote.tags.filter(a => a[0] === "e")) {
|
||||||
otherSubs.Ids.add(e[1]);
|
otherSubs.Ids.add(e[1]);
|
||||||
}
|
}
|
||||||
@ -37,15 +37,14 @@ export default function useThreadFeed(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relatedSubs = new Subscriptions();
|
let relatedSubs = new Subscriptions();
|
||||||
relatedSubs.Kinds.add(EventKind.Reaction);
|
relatedSubs.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
|
||||||
relatedSubs.Kinds.add(EventKind.TextNote);
|
|
||||||
relatedSubs.Kinds.add(EventKind.Deletion);
|
|
||||||
relatedSubs.ETags = otherSubs.Ids;
|
relatedSubs.ETags = otherSubs.Ids;
|
||||||
|
|
||||||
otherSubs.AddSubscription(relatedSubs);
|
otherSubs.AddSubscription(relatedSubs);
|
||||||
return otherSubs;
|
return otherSubs;
|
||||||
}
|
}
|
||||||
}, [main.notes]);
|
return null;
|
||||||
|
}, [main]);
|
||||||
|
|
||||||
const others = useSubscription(relatedThisSub, { leaveOpen: true });
|
const others = useSubscription(relatedThisSub, { leaveOpen: true });
|
||||||
|
|
@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
|
|
||||||
export default function useTimelineFeed(pubKeys, global = false) {
|
export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global: boolean = false) {
|
||||||
const subTab = global ? "global" : "follows";
|
const subTab = global ? "global" : "follows";
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!Array.isArray(pubKeys)) {
|
if (!Array.isArray(pubKeys)) {
|
||||||
@ -17,8 +18,7 @@ export default function useTimelineFeed(pubKeys, global = false) {
|
|||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `timeline:${subTab}`;
|
sub.Id = `timeline:${subTab}`;
|
||||||
sub.Authors = new Set(global ? [] : pubKeys);
|
sub.Authors = new Set(global ? [] : pubKeys);
|
||||||
sub.Kinds.add(EventKind.TextNote);
|
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
||||||
sub.Kinds.add(EventKind.Repost);
|
|
||||||
sub.Limit = 20;
|
sub.Limit = 20;
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
@ -31,8 +31,7 @@ export default function useTimelineFeed(pubKeys, global = false) {
|
|||||||
if (main.notes.length > 0) {
|
if (main.notes.length > 0) {
|
||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `timeline-related:${subTab}`;
|
sub.Id = `timeline-related:${subTab}`;
|
||||||
sub.Kinds.add(EventKind.Reaction);
|
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]);
|
||||||
sub.Kinds.add(EventKind.Deletion);
|
|
||||||
sub.ETags = new Set(main.notes.map(a => a.id));
|
sub.ETags = new Set(main.notes.map(a => a.id));
|
||||||
|
|
||||||
return sub;
|
return sub;
|
@ -1,61 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { ProfileCacheExpire } from "../Const";
|
|
||||||
import EventKind from "../nostr/EventKind";
|
|
||||||
import { db } from "../db";
|
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
|
||||||
import { setUserData } from "../state/Users";
|
|
||||||
import useSubscription from "./Subscription";
|
|
||||||
|
|
||||||
export default function useUsersCache() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const pKeys = useSelector(s => s.users.pubKeys);
|
|
||||||
const users = useSelector(s => s.users.users);
|
|
||||||
|
|
||||||
function isUserCached(id) {
|
|
||||||
let expire = new Date().getTime() - ProfileCacheExpire;
|
|
||||||
let u = users[id];
|
|
||||||
return u && u.loaded > expire;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
|
||||||
let needProfiles = pKeys.filter(a => !isUserCached(a));
|
|
||||||
if (needProfiles.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sub = new Subscriptions();
|
|
||||||
sub.Id = `profiles:${sub.Id}`;
|
|
||||||
sub.Authors = new Set(needProfiles.slice(0, 20));
|
|
||||||
sub.Kinds.add(EventKind.SetMetadata);
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
}, [pKeys]);
|
|
||||||
|
|
||||||
const results = useSubscription(sub);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const userData = results.notes.map(a => mapEventToProfile(a));
|
|
||||||
dispatch(setUserData(userData));
|
|
||||||
const profiles = results.notes.map(ev => {
|
|
||||||
return {...JSON.parse(ev.content), pubkey: ev.pubkey }
|
|
||||||
});
|
|
||||||
db.users.bulkPut(profiles);
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapEventToProfile(ev) {
|
|
||||||
try {
|
|
||||||
let data = JSON.parse(ev.content);
|
|
||||||
return {
|
|
||||||
pubkey: ev.pubkey,
|
|
||||||
fromEvent: ev,
|
|
||||||
loaded: new Date().getTime(),
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse JSON", ev, e);
|
|
||||||
}
|
|
||||||
}
|
|
@ -43,6 +43,7 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@ -213,13 +214,6 @@ span.pill:hover {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 720px) {
|
|
||||||
.page {
|
|
||||||
width: calc(100vw - 20px);
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.form-group {
|
div.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -349,3 +343,14 @@ body.scroll-lock {
|
|||||||
.tweet div .twitter-tweet > iframe {
|
.tweet div .twitter-tweet > iframe {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media(max-width: 720px) {
|
||||||
|
.page {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
div.form-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
@ -24,11 +24,6 @@ import VerificationPage from './pages/Verification';
|
|||||||
import MessagesPage from './pages/MessagesPage';
|
import MessagesPage from './pages/MessagesPage';
|
||||||
import ChatPage from './pages/ChatPage';
|
import ChatPage from './pages/ChatPage';
|
||||||
|
|
||||||
/**
|
|
||||||
* Nostr websocket managment system
|
|
||||||
*/
|
|
||||||
export const System = new NostrSystem();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP query provider
|
* HTTP query provider
|
||||||
*/
|
*/
|
||||||
|
@ -2,33 +2,60 @@ import * as secp from "@noble/secp256k1";
|
|||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { Subscriptions } from "./Subscriptions";
|
import { Subscriptions } from "./Subscriptions";
|
||||||
import Event from "./Event";
|
import { default as NEvent } from "./Event";
|
||||||
import { DefaultConnectTimeout } from "../Const";
|
import { DefaultConnectTimeout } from "../Const";
|
||||||
|
import { ConnectionStats } from "./ConnectionStats";
|
||||||
|
import { RawEvent, TaggedRawEvent } from ".";
|
||||||
|
|
||||||
export class ConnectionStats {
|
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
||||||
constructor() {
|
|
||||||
this.Latency = [];
|
/**
|
||||||
this.Subs = 0;
|
* Relay settings
|
||||||
this.SubsTimeout = 0;
|
*/
|
||||||
this.EventsReceived = 0;
|
export type RelaySettings = {
|
||||||
this.EventsSent = 0;
|
read: boolean,
|
||||||
this.Disconnects = 0;
|
write: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of connection stats
|
||||||
|
*/
|
||||||
|
export type StateSnapshot = {
|
||||||
|
connected: boolean,
|
||||||
|
disconnects: number,
|
||||||
|
avgLatency: number,
|
||||||
|
events: {
|
||||||
|
received: number,
|
||||||
|
send: number
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default class Connection {
|
export default class Connection {
|
||||||
constructor(addr, options) {
|
Address: string;
|
||||||
|
Socket: WebSocket | null;
|
||||||
|
Pending: Subscriptions[];
|
||||||
|
Subscriptions: Map<string, Subscriptions>;
|
||||||
|
Settings: RelaySettings;
|
||||||
|
ConnectTimeout: number;
|
||||||
|
Stats: ConnectionStats;
|
||||||
|
StateHooks: Map<string, CustomHook>;
|
||||||
|
HasStateChange: boolean;
|
||||||
|
CurrentState: StateSnapshot;
|
||||||
|
LastState: Readonly<StateSnapshot>;
|
||||||
|
IsClosed: boolean;
|
||||||
|
ReconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
|
||||||
|
constructor(addr: string, options: RelaySettings) {
|
||||||
this.Address = addr;
|
this.Address = addr;
|
||||||
this.Socket = null;
|
this.Socket = null;
|
||||||
this.Pending = [];
|
this.Pending = [];
|
||||||
this.Subscriptions = {};
|
this.Subscriptions = new Map();
|
||||||
this.Read = options?.read || true;
|
this.Settings = options;
|
||||||
this.Write = options?.write || true;
|
|
||||||
this.ConnectTimeout = DefaultConnectTimeout;
|
this.ConnectTimeout = DefaultConnectTimeout;
|
||||||
this.Stats = new ConnectionStats();
|
this.Stats = new ConnectionStats();
|
||||||
this.StateHooks = {};
|
this.StateHooks = new Map();
|
||||||
this.HasStateChange = true;
|
this.HasStateChange = true;
|
||||||
this.CurrentState = {
|
this.CurrentState = <StateSnapshot>{
|
||||||
connected: false,
|
connected: false,
|
||||||
disconnects: 0,
|
disconnects: 0,
|
||||||
avgLatency: 0,
|
avgLatency: 0,
|
||||||
@ -58,11 +85,11 @@ export default class Connection {
|
|||||||
clearTimeout(this.ReconnectTimer);
|
clearTimeout(this.ReconnectTimer);
|
||||||
this.ReconnectTimer = null;
|
this.ReconnectTimer = null;
|
||||||
}
|
}
|
||||||
this.Socket.close();
|
this.Socket?.close();
|
||||||
this._UpdateState();
|
this._UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnOpen(e) {
|
OnOpen(e: Event) {
|
||||||
this.ConnectTimeout = DefaultConnectTimeout;
|
this.ConnectTimeout = DefaultConnectTimeout;
|
||||||
console.log(`[${this.Address}] Open!`);
|
console.log(`[${this.Address}] Open!`);
|
||||||
|
|
||||||
@ -72,13 +99,13 @@ export default class Connection {
|
|||||||
}
|
}
|
||||||
this.Pending = [];
|
this.Pending = [];
|
||||||
|
|
||||||
for (let s of Object.values(this.Subscriptions)) {
|
for (let [_, s] of this.Subscriptions) {
|
||||||
this._SendSubscription(s, s.ToObject());
|
this._SendSubscription(s);
|
||||||
}
|
}
|
||||||
this._UpdateState();
|
this._UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnClose(e) {
|
OnClose(e: CloseEvent) {
|
||||||
if (!this.IsClosed) {
|
if (!this.IsClosed) {
|
||||||
this.ConnectTimeout = this.ConnectTimeout * 2;
|
this.ConnectTimeout = this.ConnectTimeout * 2;
|
||||||
console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`);
|
console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`);
|
||||||
@ -93,7 +120,7 @@ export default class Connection {
|
|||||||
this._UpdateState();
|
this._UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnMessage(e) {
|
OnMessage(e: MessageEvent<any>) {
|
||||||
if (e.data.length > 0) {
|
if (e.data.length > 0) {
|
||||||
let msg = JSON.parse(e.data);
|
let msg = JSON.parse(e.data);
|
||||||
let tag = msg[0];
|
let tag = msg[0];
|
||||||
@ -125,17 +152,16 @@ export default class Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OnError(e) {
|
OnError(e: Event) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this._UpdateState();
|
this._UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send event on this connection
|
* Send event on this connection
|
||||||
* @param {Event} e
|
|
||||||
*/
|
*/
|
||||||
SendEvent(e) {
|
SendEvent(e: NEvent) {
|
||||||
if (!this.Write) {
|
if (!this.Settings.write) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let req = ["EVENT", e.ToObject()];
|
let req = ["EVENT", e.ToObject()];
|
||||||
@ -146,36 +172,28 @@ export default class Connection {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to data from this connection
|
* Subscribe to data from this connection
|
||||||
* @param {Subscriptions | Array<Subscriptions>} sub Subscriptions object
|
|
||||||
*/
|
*/
|
||||||
AddSubscription(sub) {
|
AddSubscription(sub: Subscriptions) {
|
||||||
if (!this.Read) {
|
if (!this.Settings.read) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subObj = sub.ToObject();
|
if (this.Subscriptions.has(sub.Id)) {
|
||||||
if (Object.keys(subObj).length === 0) {
|
|
||||||
debugger;
|
|
||||||
throw "CANNOT SEND EMPTY SUB - FIX ME";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.Subscriptions[sub.Id]) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._SendSubscription(sub, subObj);
|
this._SendSubscription(sub);
|
||||||
this.Subscriptions[sub.Id] = sub;
|
this.Subscriptions.set(sub.Id, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a subscription
|
* Remove a subscription
|
||||||
* @param {any} subId Subscription id to remove
|
|
||||||
*/
|
*/
|
||||||
RemoveSubscription(subId) {
|
RemoveSubscription(subId: string) {
|
||||||
if (this.Subscriptions[subId]) {
|
if (this.Subscriptions.has(subId)) {
|
||||||
let req = ["CLOSE", subId];
|
let req = ["CLOSE", subId];
|
||||||
this._SendJson(req);
|
this._SendJson(req);
|
||||||
delete this.Subscriptions[subId];
|
this.Subscriptions.delete(subId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -183,19 +201,17 @@ export default class Connection {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook status for connection
|
* Hook status for connection
|
||||||
* @param {function} fnHook Subscription hook
|
|
||||||
*/
|
*/
|
||||||
StatusHook(fnHook) {
|
StatusHook(fnHook: CustomHook) {
|
||||||
let id = uuid();
|
let id = uuid();
|
||||||
this.StateHooks[id] = fnHook;
|
this.StateHooks.set(id, fnHook);
|
||||||
return () => {
|
return () => {
|
||||||
delete this.StateHooks[id];
|
this.StateHooks.delete(id);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the current state of this connection
|
* Returns the current state of this connection
|
||||||
* @returns {any}
|
|
||||||
*/
|
*/
|
||||||
GetState() {
|
GetState() {
|
||||||
if (this.HasStateChange) {
|
if (this.HasStateChange) {
|
||||||
@ -218,24 +234,24 @@ export default class Connection {
|
|||||||
|
|
||||||
_NotifyState() {
|
_NotifyState() {
|
||||||
let state = this.GetState();
|
let state = this.GetState();
|
||||||
for (let h of Object.values(this.StateHooks)) {
|
for (let [_, h] of this.StateHooks) {
|
||||||
h(state);
|
h(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_SendSubscription(sub, subObj) {
|
_SendSubscription(sub: Subscriptions) {
|
||||||
let req = ["REQ", sub.Id, subObj];
|
let req = ["REQ", sub.Id, sub.ToObject()];
|
||||||
if (sub.OrSubs.length > 0) {
|
if (sub.OrSubs.length > 0) {
|
||||||
req = [
|
req = [
|
||||||
...req,
|
...req,
|
||||||
...sub.OrSubs.map(o => o.ToObject())
|
...sub.OrSubs.map(o => o.ToObject())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
sub.Started[this.Address] = new Date().getTime();
|
sub.Started.set(this.Address, new Date().getTime());
|
||||||
this._SendJson(req);
|
this._SendJson(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
_SendJson(obj) {
|
_SendJson(obj: any) {
|
||||||
if (this.Socket?.readyState !== WebSocket.OPEN) {
|
if (this.Socket?.readyState !== WebSocket.OPEN) {
|
||||||
this.Pending.push(obj);
|
this.Pending.push(obj);
|
||||||
return;
|
return;
|
||||||
@ -244,34 +260,43 @@ export default class Connection {
|
|||||||
this.Socket.send(json);
|
this.Socket.send(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
_OnEvent(subId, ev) {
|
_OnEvent(subId: string, ev: RawEvent) {
|
||||||
if (this.Subscriptions[subId]) {
|
if (this.Subscriptions.has(subId)) {
|
||||||
//this._VerifySig(ev);
|
//this._VerifySig(ev);
|
||||||
ev.relay = this.Address; // tag event with relay
|
let tagged: TaggedRawEvent = {
|
||||||
this.Subscriptions[subId].OnEvent(ev);
|
...ev,
|
||||||
|
relays: [this.Address]
|
||||||
|
};
|
||||||
|
this.Subscriptions.get(subId)?.OnEvent(tagged);
|
||||||
} else {
|
} else {
|
||||||
// console.warn(`No subscription for event! ${subId}`);
|
// console.warn(`No subscription for event! ${subId}`);
|
||||||
// ignored for now, track as "dropped event" with connection stats
|
// ignored for now, track as "dropped event" with connection stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_OnEnd(subId) {
|
_OnEnd(subId: string) {
|
||||||
let sub = this.Subscriptions[subId];
|
let sub = this.Subscriptions.get(subId);
|
||||||
if (sub) {
|
if (sub) {
|
||||||
sub.Finished[this.Address] = new Date().getTime();
|
let now = new Date().getTime();
|
||||||
let responseTime = sub.Finished[this.Address] - sub.Started[this.Address];
|
let started = sub.Started.get(this.Address);
|
||||||
if (responseTime > 10_000) {
|
sub.Finished.set(this.Address, now);
|
||||||
console.warn(`[${this.Address}][${subId}] Slow response time ${(responseTime / 1000).toFixed(1)} seconds`);
|
if (started) {
|
||||||
|
let responseTime = now - started;
|
||||||
|
if (responseTime > 10_000) {
|
||||||
|
console.warn(`[${this.Address}][${subId}] Slow response time ${(responseTime / 1000).toFixed(1)} seconds`);
|
||||||
|
}
|
||||||
|
this.Stats.Latency.push(responseTime);
|
||||||
|
} else {
|
||||||
|
console.warn("No started timestamp!");
|
||||||
}
|
}
|
||||||
sub.OnEnd(this);
|
sub.OnEnd(this);
|
||||||
this.Stats.Latency.push(responseTime);
|
|
||||||
this._UpdateState();
|
this._UpdateState();
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No subscription for end! ${subId}`);
|
console.warn(`No subscription for end! ${subId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_VerifySig(ev) {
|
_VerifySig(ev: RawEvent) {
|
||||||
let payload = [
|
let payload = [
|
||||||
0,
|
0,
|
||||||
ev.pubkey,
|
ev.pubkey,
|
||||||
@ -282,6 +307,9 @@ export default class Connection {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
if (secp.utils.sha256Sync === undefined) {
|
||||||
|
throw "Cannot verify event, no sync sha256 method";
|
||||||
|
}
|
||||||
let data = secp.utils.sha256Sync(payloadData);
|
let data = secp.utils.sha256Sync(payloadData);
|
||||||
let hash = secp.utils.bytesToHex(data);
|
let hash = secp.utils.bytesToHex(data);
|
||||||
if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) {
|
if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) {
|
44
src/nostr/ConnectionStats.ts
Normal file
44
src/nostr/ConnectionStats.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Stats class for tracking metrics per connection
|
||||||
|
*/
|
||||||
|
export class ConnectionStats {
|
||||||
|
/**
|
||||||
|
* Last n records of how long between REQ->EOSE
|
||||||
|
*/
|
||||||
|
Latency: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of REQ's sent on this connection
|
||||||
|
*/
|
||||||
|
Subs: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of REQ which took too long and where abandoned
|
||||||
|
*/
|
||||||
|
SubsTimeout: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of EVENT messages received
|
||||||
|
*/
|
||||||
|
EventsReceived: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of EVENT messages sent
|
||||||
|
*/
|
||||||
|
EventsSent: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of times this connection was lost
|
||||||
|
*/
|
||||||
|
Disconnects: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.Latency = [];
|
||||||
|
this.Subs = 0;
|
||||||
|
this.SubsTimeout = 0;
|
||||||
|
this.EventsReceived = 0;
|
||||||
|
this.EventsSent = 0;
|
||||||
|
this.Disconnects = 0;
|
||||||
|
}
|
||||||
|
}
|
@ -1,63 +1,66 @@
|
|||||||
import * as secp from '@noble/secp256k1';
|
import * as secp from '@noble/secp256k1';
|
||||||
import base64 from "@protobufjs/base64"
|
import * as base64 from "@protobufjs/base64"
|
||||||
|
import { HexKey, RawEvent, TaggedRawEvent } from '.';
|
||||||
import EventKind from "./EventKind";
|
import EventKind from "./EventKind";
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
import Thread from './Thread';
|
import Thread from './Thread';
|
||||||
|
|
||||||
export default class Event {
|
export default class Event {
|
||||||
constructor() {
|
/**
|
||||||
/**
|
* The original event
|
||||||
* The original event
|
*/
|
||||||
*/
|
Original: TaggedRawEvent | null;
|
||||||
this.Original = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of the event
|
* Id of the event
|
||||||
* @type {string}
|
*/
|
||||||
*/
|
Id: string
|
||||||
this.Id = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pub key of the creator
|
* Pub key of the creator
|
||||||
* @type {string}
|
*/
|
||||||
*/
|
PubKey: string;
|
||||||
this.PubKey = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp when the event was created
|
* Timestamp when the event was created
|
||||||
* @type {number}
|
*/
|
||||||
*/
|
CreatedAt: number;
|
||||||
this.CreatedAt = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of event
|
* The type of event
|
||||||
* @type {EventKind}
|
*/
|
||||||
*/
|
Kind: EventKind;
|
||||||
this.Kind = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of metadata tags
|
* A list of metadata tags
|
||||||
* @type {Array<Tag>}
|
*/
|
||||||
*/
|
Tags: Array<Tag>;
|
||||||
this.Tags = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content of the event
|
* Content of the event
|
||||||
* @type {string}
|
*/
|
||||||
*/
|
Content: string;
|
||||||
this.Content = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signature of this event from the creator
|
* Signature of this event from the creator
|
||||||
* @type {string}
|
*/
|
||||||
*/
|
Signature: string;
|
||||||
this.Signature = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread information for this event
|
* Thread information for this event
|
||||||
* @type {Thread}
|
*/
|
||||||
*/
|
Thread: Thread | null;
|
||||||
this.Thread = null;
|
|
||||||
|
constructor(e?: TaggedRawEvent) {
|
||||||
|
this.Original = e ?? null;
|
||||||
|
this.Id = e?.id ?? "";
|
||||||
|
this.PubKey = e?.pubkey ?? "";
|
||||||
|
this.CreatedAt = e?.created_at ?? Math.floor(new Date().getTime() / 1000);
|
||||||
|
this.Kind = e?.kind ?? EventKind.Unknown;
|
||||||
|
this.Tags = e?.tags.map((a, i) => new Tag(a, i)) ?? [];
|
||||||
|
this.Content = e?.content ?? "";
|
||||||
|
this.Signature = e?.sig ?? "";
|
||||||
|
this.Thread = Thread.ExtractThread(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -73,9 +76,8 @@ export default class Event {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign this message with a private key
|
* Sign this message with a private key
|
||||||
* @param {string} key Key to sign message with
|
|
||||||
*/
|
*/
|
||||||
async Sign(key) {
|
async Sign(key: HexKey) {
|
||||||
this.Id = await this.CreateId();
|
this.Id = await this.CreateId();
|
||||||
|
|
||||||
let sig = await secp.schnorr.sign(this.Id, key);
|
let sig = await secp.schnorr.sign(this.Id, key);
|
||||||
@ -108,49 +110,20 @@ export default class Event {
|
|||||||
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
let data = await secp.utils.sha256(payloadData);
|
let data = await secp.utils.sha256(payloadData);
|
||||||
let hash = secp.utils.bytesToHex(data);
|
let hash = secp.utils.bytesToHex(data);
|
||||||
if (this.Id !== null && hash !== this.Id) {
|
if (this.Id !== "" && hash !== this.Id) {
|
||||||
console.debug(payload);
|
console.debug(payload);
|
||||||
throw "ID doesnt match!";
|
throw "ID doesnt match!";
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
ToObject(): RawEvent {
|
||||||
* Does this event have content
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
IsContent() {
|
|
||||||
const ContentKinds = [
|
|
||||||
EventKind.TextNote
|
|
||||||
];
|
|
||||||
return ContentKinds.includes(this.Kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
static FromObject(obj) {
|
|
||||||
if (typeof obj !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ret = new Event();
|
|
||||||
ret.Original = obj;
|
|
||||||
ret.Id = obj.id;
|
|
||||||
ret.PubKey = obj.pubkey;
|
|
||||||
ret.CreatedAt = obj.created_at;
|
|
||||||
ret.Kind = obj.kind;
|
|
||||||
ret.Tags = obj.tags.map((e, i) => new Tag(e, i)).filter(a => !a.Invalid);
|
|
||||||
ret.Content = obj.content;
|
|
||||||
ret.Signature = obj.sig;
|
|
||||||
ret.Thread = Thread.ExtractThread(ret);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
ToObject() {
|
|
||||||
return {
|
return {
|
||||||
id: this.Id,
|
id: this.Id,
|
||||||
pubkey: this.PubKey,
|
pubkey: this.PubKey,
|
||||||
created_at: this.CreatedAt,
|
created_at: this.CreatedAt,
|
||||||
kind: this.Kind,
|
kind: this.Kind,
|
||||||
tags: this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null),
|
tags: <string[][]>this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null),
|
||||||
content: this.Content,
|
content: this.Content,
|
||||||
sig: this.Signature
|
sig: this.Signature
|
||||||
};
|
};
|
||||||
@ -158,21 +131,17 @@ export default class Event {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new event for a specific pubkey
|
* Create a new event for a specific pubkey
|
||||||
* @param {String} pubKey
|
|
||||||
*/
|
*/
|
||||||
static ForPubKey(pubKey) {
|
static ForPubKey(pubKey: HexKey) {
|
||||||
let ev = new Event();
|
let ev = new Event();
|
||||||
ev.CreatedAt = parseInt(new Date().getTime() / 1000);
|
|
||||||
ev.PubKey = pubKey;
|
ev.PubKey = pubKey;
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt the message content in place
|
* Encrypt the message content in place
|
||||||
* @param {string} pubkey
|
|
||||||
* @param {string} privkey
|
|
||||||
*/
|
*/
|
||||||
async EncryptDmForPubkey(pubkey, privkey) {
|
async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
|
||||||
let key = await this._GetDmSharedKey(pubkey, privkey);
|
let key = await this._GetDmSharedKey(pubkey, privkey);
|
||||||
let iv = window.crypto.getRandomValues(new Uint8Array(16));
|
let iv = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
let data = new TextEncoder().encode(this.Content);
|
let data = new TextEncoder().encode(this.Content);
|
||||||
@ -186,10 +155,8 @@ export default class Event {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt the content of this message in place
|
* Decrypt the content of this message in place
|
||||||
* @param {string} privkey
|
|
||||||
* @param {string} pubkey
|
|
||||||
*/
|
*/
|
||||||
async DecryptDm(privkey, pubkey) {
|
async DecryptDm(privkey: HexKey, pubkey: HexKey) {
|
||||||
let key = await this._GetDmSharedKey(pubkey, privkey);
|
let key = await this._GetDmSharedKey(pubkey, privkey);
|
||||||
let cSplit = this.Content.split("?iv=");
|
let cSplit = this.Content.split("?iv=");
|
||||||
let data = new Uint8Array(base64.length(cSplit[0]));
|
let data = new Uint8Array(base64.length(cSplit[0]));
|
||||||
@ -205,7 +172,7 @@ export default class Event {
|
|||||||
this.Content = new TextDecoder().decode(result);
|
this.Content = new TextDecoder().decode(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _GetDmSharedKey(pubkey, privkey) {
|
async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
|
||||||
let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey);
|
let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey);
|
||||||
let sharedX = sharedPoint.slice(1, 33);
|
let sharedX = sharedPoint.slice(1, 33);
|
||||||
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
|
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
|
@ -1,13 +0,0 @@
|
|||||||
const EventKind = {
|
|
||||||
Unknown: -1,
|
|
||||||
SetMetadata: 0,
|
|
||||||
TextNote: 1,
|
|
||||||
RecommendServer: 2,
|
|
||||||
ContactList: 3, // NIP-02
|
|
||||||
DirectMessage: 4, // NIP-04
|
|
||||||
Deletion: 5, // NIP-09
|
|
||||||
Repost: 6, // NIP-18
|
|
||||||
Reaction: 7 // NIP-25
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventKind;
|
|
13
src/nostr/EventKind.ts
Normal file
13
src/nostr/EventKind.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const enum EventKind {
|
||||||
|
Unknown = -1,
|
||||||
|
SetMetadata = 0,
|
||||||
|
TextNote = 1,
|
||||||
|
RecommendServer = 2,
|
||||||
|
ContactList = 3, // NIP-02
|
||||||
|
DirectMessage = 4, // NIP-04
|
||||||
|
Deletion = 5, // NIP-09
|
||||||
|
Repost = 6, // NIP-18
|
||||||
|
Reaction = 7 // NIP-25
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventKind;
|
@ -1,143 +0,0 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
|
||||||
import Connection from "./Connection";
|
|
||||||
|
|
||||||
export class Subscriptions {
|
|
||||||
constructor() {
|
|
||||||
/**
|
|
||||||
* A unique id for this subscription filter
|
|
||||||
*/
|
|
||||||
this.Id = uuid();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of event ids or prefixes
|
|
||||||
*/
|
|
||||||
this.Ids = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
|
|
||||||
*/
|
|
||||||
this.Authors = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of a kind numbers
|
|
||||||
*/
|
|
||||||
this.Kinds = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of event ids that are referenced in an "e" tag
|
|
||||||
*/
|
|
||||||
this.ETags = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of pubkeys that are referenced in a "p" tag
|
|
||||||
*/
|
|
||||||
this.PTags = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a timestamp, events must be newer than this to pass
|
|
||||||
*/
|
|
||||||
this.Since = NaN;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a timestamp, events must be older than this to pass
|
|
||||||
*/
|
|
||||||
this.Until = NaN;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* maximum number of events to be returned in the initial query
|
|
||||||
*/
|
|
||||||
this.Limit = NaN;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler function for this event
|
|
||||||
*/
|
|
||||||
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End of data event
|
|
||||||
* @param {Connection} c
|
|
||||||
*/
|
|
||||||
this.OnEnd = (c) => {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection of OR sub scriptions linked to this
|
|
||||||
*/
|
|
||||||
this.OrSubs = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start time for this subscription
|
|
||||||
*/
|
|
||||||
this.Started = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End time for this subscription
|
|
||||||
*/
|
|
||||||
this.Finished = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds OR filter subscriptions
|
|
||||||
* @param {Subscriptions} sub Extra filters
|
|
||||||
*/
|
|
||||||
AddSubscription(sub) {
|
|
||||||
this.OrSubs.push(sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If all relays have responded with EOSE
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
IsFinished() {
|
|
||||||
return Object.keys(this.Started).length === Object.keys(this.Finished).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
static FromObject(obj) {
|
|
||||||
let ret = new Subscriptions();
|
|
||||||
ret.Ids = new Set(obj.ids);
|
|
||||||
ret.Authors = new Set(obj.authors);
|
|
||||||
ret.Kinds = new Set(obj.kinds);
|
|
||||||
ret.ETags = new Set(obj["#e"]);
|
|
||||||
ret.PTags = new Set(obj["#p"]);
|
|
||||||
ret.Since = parseInt(obj.since);
|
|
||||||
ret.Until = parseInt(obj.until);
|
|
||||||
ret.Limit = parseInt(obj.limit);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
ToObject() {
|
|
||||||
let ret = {};
|
|
||||||
if (this.Ids.size > 0) {
|
|
||||||
ret.ids = Array.from(this.Ids);
|
|
||||||
}
|
|
||||||
if (this.Authors.size > 0) {
|
|
||||||
ret.authors = Array.from(this.Authors);
|
|
||||||
}
|
|
||||||
if (this.Kinds.size > 0) {
|
|
||||||
ret.kinds = Array.from(this.Kinds);
|
|
||||||
}
|
|
||||||
if (this.ETags.size > 0) {
|
|
||||||
ret["#e"] = Array.from(this.ETags);
|
|
||||||
}
|
|
||||||
if (this.PTags.size > 0) {
|
|
||||||
ret["#p"] = Array.from(this.PTags);
|
|
||||||
}
|
|
||||||
if (!isNaN(this.Since)) {
|
|
||||||
ret.since = this.Since;
|
|
||||||
}
|
|
||||||
if (!isNaN(this.Until)) {
|
|
||||||
ret.until = this.Until;
|
|
||||||
}
|
|
||||||
if (!isNaN(this.Limit)) {
|
|
||||||
ret.limit = this.Limit;
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split subscription by ids
|
|
||||||
* @param {number} n How many segments to create
|
|
||||||
*/
|
|
||||||
Split(n) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
139
src/nostr/Subscriptions.ts
Normal file
139
src/nostr/Subscriptions.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
import { TaggedRawEvent, RawReqFilter, u256 } from ".";
|
||||||
|
import Connection from "./Connection";
|
||||||
|
import EventKind from "./EventKind";
|
||||||
|
|
||||||
|
export type NEventHandler = (e: TaggedRawEvent) => void;
|
||||||
|
export type OnEndHandler = (c: Connection) => void;
|
||||||
|
|
||||||
|
export class Subscriptions {
|
||||||
|
/**
|
||||||
|
* A unique id for this subscription filter
|
||||||
|
*/
|
||||||
|
Id: u256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of event ids or prefixes
|
||||||
|
*/
|
||||||
|
Ids: Set<u256> | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
|
||||||
|
*/
|
||||||
|
Authors: Set<u256> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of a kind numbers
|
||||||
|
*/
|
||||||
|
Kinds: Set<EventKind> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of event ids that are referenced in an "e" tag
|
||||||
|
*/
|
||||||
|
ETags: Set<u256> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of pubkeys that are referenced in a "p" tag
|
||||||
|
*/
|
||||||
|
PTags: Set<u256> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a timestamp, events must be newer than this to pass
|
||||||
|
*/
|
||||||
|
Since: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a timestamp, events must be older than this to pass
|
||||||
|
*/
|
||||||
|
Until: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maximum number of events to be returned in the initial query
|
||||||
|
*/
|
||||||
|
Limit: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler function for this event
|
||||||
|
*/
|
||||||
|
OnEvent: NEventHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End of data event
|
||||||
|
*/
|
||||||
|
OnEnd: OnEndHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of OR sub scriptions linked to this
|
||||||
|
*/
|
||||||
|
OrSubs: Array<Subscriptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start time for this subscription
|
||||||
|
*/
|
||||||
|
Started: Map<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End time for this subscription
|
||||||
|
*/
|
||||||
|
Finished: Map<string, number>;
|
||||||
|
|
||||||
|
constructor(sub?: RawReqFilter) {
|
||||||
|
this.Id = uuid();
|
||||||
|
this.Ids = sub?.ids ? new Set(sub.ids) : null;
|
||||||
|
this.Authors = sub?.authors ? new Set(sub.authors) : null;
|
||||||
|
this.Kinds = sub?.kinds ? new Set(sub.kinds) : null;
|
||||||
|
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : null;
|
||||||
|
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : null;
|
||||||
|
this.Since = sub?.since ?? null;
|
||||||
|
this.Until = sub?.until ?? null;
|
||||||
|
this.Limit = sub?.limit ?? null;
|
||||||
|
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
||||||
|
this.OnEnd = (c) => { };
|
||||||
|
this.OrSubs = [];
|
||||||
|
this.Started = new Map<string, number>();
|
||||||
|
this.Finished = new Map<string, number>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR filter subscriptions
|
||||||
|
*/
|
||||||
|
AddSubscription(sub: Subscriptions) {
|
||||||
|
this.OrSubs.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If all relays have responded with EOSE
|
||||||
|
*/
|
||||||
|
IsFinished() {
|
||||||
|
return this.Started.size === this.Finished.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
ToObject(): RawReqFilter {
|
||||||
|
let ret: RawReqFilter = {};
|
||||||
|
if (this.Ids) {
|
||||||
|
ret.ids = Array.from(this.Ids);
|
||||||
|
}
|
||||||
|
if (this.Authors) {
|
||||||
|
ret.authors = Array.from(this.Authors);
|
||||||
|
}
|
||||||
|
if (this.Kinds) {
|
||||||
|
ret.kinds = Array.from(this.Kinds);
|
||||||
|
}
|
||||||
|
if (this.ETags) {
|
||||||
|
ret["#e"] = Array.from(this.ETags);
|
||||||
|
}
|
||||||
|
if (this.PTags) {
|
||||||
|
ret["#p"] = Array.from(this.PTags);
|
||||||
|
}
|
||||||
|
if (this.Since !== null) {
|
||||||
|
ret.since = this.Since;
|
||||||
|
}
|
||||||
|
if (this.Until !== null) {
|
||||||
|
ret.until = this.Until;
|
||||||
|
}
|
||||||
|
if (this.Limit !== null) {
|
||||||
|
ret.limit = this.Limit;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
@ -1,98 +0,0 @@
|
|||||||
import Connection from "./Connection";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages nostr content retrival system
|
|
||||||
*/
|
|
||||||
export class NostrSystem {
|
|
||||||
constructor() {
|
|
||||||
this.Sockets = {};
|
|
||||||
this.Subscriptions = {};
|
|
||||||
this.PendingSubscriptions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a NOSTR relay if not already connected
|
|
||||||
* @param {string} address
|
|
||||||
*/
|
|
||||||
ConnectToRelay(address, options) {
|
|
||||||
try {
|
|
||||||
if (typeof this.Sockets[address] === "undefined") {
|
|
||||||
let c = new Connection(address, options);
|
|
||||||
for (let s of Object.values(this.Subscriptions)) {
|
|
||||||
c.AddSubscription(s);
|
|
||||||
}
|
|
||||||
this.Sockets[address] = c;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisconnectRelay(address) {
|
|
||||||
let c = this.Sockets[address];
|
|
||||||
delete this.Sockets[address];
|
|
||||||
if (c) {
|
|
||||||
c.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AddSubscription(sub) {
|
|
||||||
for (let s of Object.values(this.Sockets)) {
|
|
||||||
s.AddSubscription(sub);
|
|
||||||
}
|
|
||||||
this.Subscriptions[sub.Id] = sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoveSubscription(subId) {
|
|
||||||
for (let s of Object.values(this.Sockets)) {
|
|
||||||
s.RemoveSubscription(subId);
|
|
||||||
}
|
|
||||||
delete this.Subscriptions[subId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send events to writable relays
|
|
||||||
* @param {Event} ev
|
|
||||||
*/
|
|
||||||
BroadcastEvent(ev) {
|
|
||||||
for (let s of Object.values(this.Sockets)) {
|
|
||||||
s.SendEvent(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request/Response pattern
|
|
||||||
* @param {Subscriptions} sub
|
|
||||||
* @returns {Array<any>}
|
|
||||||
*/
|
|
||||||
RequestSubscription(sub) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let events = [];
|
|
||||||
|
|
||||||
// force timeout returning current results
|
|
||||||
let timeout = setTimeout(() => {
|
|
||||||
this.RemoveSubscription(sub.Id);
|
|
||||||
resolve(events);
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
let onEventPassthrough = sub.OnEvent;
|
|
||||||
sub.OnEvent = (ev) => {
|
|
||||||
if (typeof onEventPassthrough === "function") {
|
|
||||||
onEventPassthrough(ev);
|
|
||||||
}
|
|
||||||
if (!events.some(a => a.id === ev.id)) {
|
|
||||||
events.push(ev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
sub.OnEnd = (c) => {
|
|
||||||
c.RemoveSubscription(sub.Id);
|
|
||||||
if (sub.IsFinished()) {
|
|
||||||
clearInterval(timeout);
|
|
||||||
console.debug(`[${sub.Id}] Finished`);
|
|
||||||
resolve(events);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.AddSubscription(sub);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
199
src/nostr/System.ts
Normal file
199
src/nostr/System.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { HexKey, TaggedRawEvent } from ".";
|
||||||
|
import { ProfileCacheExpire } from "../Const";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { mapEventToProfile, MetadataCache } from "../db/User";
|
||||||
|
import Connection, { RelaySettings } from "./Connection";
|
||||||
|
import Event from "./Event";
|
||||||
|
import EventKind from "./EventKind";
|
||||||
|
import { Subscriptions } from "./Subscriptions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages nostr content retrival system
|
||||||
|
*/
|
||||||
|
export class NostrSystem {
|
||||||
|
/**
|
||||||
|
* All currently connected websockets
|
||||||
|
*/
|
||||||
|
Sockets: Map<string, Connection>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All active subscriptions
|
||||||
|
*/
|
||||||
|
Subscriptions: Map<string, Subscriptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending subscriptions to send when sockets become open
|
||||||
|
*/
|
||||||
|
PendingSubscriptions: Subscriptions[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of pubkeys to fetch metadata for
|
||||||
|
*/
|
||||||
|
WantsMetadata: Set<HexKey>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.Sockets = new Map();
|
||||||
|
this.Subscriptions = new Map();
|
||||||
|
this.PendingSubscriptions = [];
|
||||||
|
this.WantsMetadata = new Set();
|
||||||
|
this._FetchMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a NOSTR relay if not already connected
|
||||||
|
*/
|
||||||
|
ConnectToRelay(address: string, options: RelaySettings) {
|
||||||
|
try {
|
||||||
|
if (!this.Sockets.has(address)) {
|
||||||
|
let c = new Connection(address, options);
|
||||||
|
this.Sockets.set(address, c);
|
||||||
|
for (let [_, s] of this.Subscriptions) {
|
||||||
|
c.AddSubscription(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from a relay
|
||||||
|
*/
|
||||||
|
DisconnectRelay(address: string) {
|
||||||
|
let c = this.Sockets.get(address);
|
||||||
|
if (c) {
|
||||||
|
this.Sockets.delete(address);
|
||||||
|
c.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSubscription(sub: Subscriptions) {
|
||||||
|
for (let [_, s] of this.Sockets) {
|
||||||
|
s.AddSubscription(sub);
|
||||||
|
}
|
||||||
|
this.Subscriptions.set(sub.Id, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveSubscription(subId: string) {
|
||||||
|
for (let [_, s] of this.Sockets) {
|
||||||
|
s.RemoveSubscription(subId);
|
||||||
|
}
|
||||||
|
this.Subscriptions.delete(subId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send events to writable relays
|
||||||
|
*/
|
||||||
|
BroadcastEvent(ev: Event) {
|
||||||
|
for (let [_, s] of this.Sockets) {
|
||||||
|
s.SendEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request profile metadata for a set of pubkeys
|
||||||
|
*/
|
||||||
|
TrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||||
|
for (let p of Array.isArray(pk) ? pk : [pk]) {
|
||||||
|
if (p.length > 0) {
|
||||||
|
this.WantsMetadata.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop tracking metadata for a set of pubkeys
|
||||||
|
*/
|
||||||
|
UntrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||||
|
for (let p of Array.isArray(pk) ? pk : [pk]) {
|
||||||
|
if (p.length > 0) {
|
||||||
|
this.WantsMetadata.delete(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request/Response pattern
|
||||||
|
*/
|
||||||
|
RequestSubscription(sub: Subscriptions) {
|
||||||
|
return new Promise<TaggedRawEvent[]>((resolve, reject) => {
|
||||||
|
let events: TaggedRawEvent[] = [];
|
||||||
|
|
||||||
|
// force timeout returning current results
|
||||||
|
let timeout = setTimeout(() => {
|
||||||
|
this.RemoveSubscription(sub.Id);
|
||||||
|
resolve(events);
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
let onEventPassthrough = sub.OnEvent;
|
||||||
|
sub.OnEvent = (ev) => {
|
||||||
|
if (typeof onEventPassthrough === "function") {
|
||||||
|
onEventPassthrough(ev);
|
||||||
|
}
|
||||||
|
if (!events.some(a => a.id === ev.id)) {
|
||||||
|
events.push(ev);
|
||||||
|
} else {
|
||||||
|
let existing = events.find(a => a.id === ev.id);
|
||||||
|
if (existing) {
|
||||||
|
for (let v of ev.relays) {
|
||||||
|
existing.relays.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sub.OnEnd = (c) => {
|
||||||
|
c.RemoveSubscription(sub.Id);
|
||||||
|
if (sub.IsFinished()) {
|
||||||
|
clearInterval(timeout);
|
||||||
|
console.debug(`[${sub.Id}] Finished`);
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.AddSubscription(sub);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _FetchMetadata() {
|
||||||
|
let missing = new Set<HexKey>();
|
||||||
|
let meta = await db.users.bulkGet(Array.from(this.WantsMetadata));
|
||||||
|
let now = new Date().getTime();
|
||||||
|
for (let pk of this.WantsMetadata) {
|
||||||
|
let m = meta.find(a => a?.pubkey === pk);
|
||||||
|
if (!m || m.loaded < (now - ProfileCacheExpire)) {
|
||||||
|
missing.add(pk);
|
||||||
|
// cap 100 missing profiles
|
||||||
|
if (missing.size >= 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.size > 0) {
|
||||||
|
console.debug("Wants profiles: ", missing);
|
||||||
|
|
||||||
|
let sub = new Subscriptions();
|
||||||
|
sub.Id = `profiles:${sub.Id}`;
|
||||||
|
sub.Kinds = new Set([EventKind.SetMetadata]);
|
||||||
|
sub.Authors = missing;
|
||||||
|
sub.OnEvent = async (e) => {
|
||||||
|
let profile = mapEventToProfile(e);
|
||||||
|
if (profile) {
|
||||||
|
await db.users.put(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let results = await this.RequestSubscription(sub);
|
||||||
|
let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a));
|
||||||
|
console.debug("No profiles: ", couldNotFetch);
|
||||||
|
await db.users.bulkPut(couldNotFetch.map(a => {
|
||||||
|
return {
|
||||||
|
pubkey: a,
|
||||||
|
loaded: new Date().getTime()
|
||||||
|
} as MetadataCache;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => this._FetchMetadata(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const System = new NostrSystem();
|
@ -1,11 +1,18 @@
|
|||||||
|
import { HexKey, RawReqFilter, u256 } from ".";
|
||||||
|
|
||||||
export default class Tag {
|
export default class Tag {
|
||||||
constructor(tag, index) {
|
Original: string[];
|
||||||
|
Key: string;
|
||||||
|
Event?: u256;
|
||||||
|
PubKey?: HexKey;
|
||||||
|
Relay?: string;
|
||||||
|
Marker?: string;
|
||||||
|
Index: number;
|
||||||
|
Invalid: boolean;
|
||||||
|
|
||||||
|
constructor(tag: string[], index: number) {
|
||||||
|
this.Original = tag;
|
||||||
this.Key = tag[0];
|
this.Key = tag[0];
|
||||||
this.Event = null;
|
|
||||||
this.PubKey = null;
|
|
||||||
this.Relay = null;
|
|
||||||
this.Marker = null;
|
|
||||||
this.Other = null;
|
|
||||||
this.Index = index;
|
this.Index = index;
|
||||||
this.Invalid = false;
|
this.Invalid = false;
|
||||||
|
|
||||||
@ -13,8 +20,8 @@ export default class Tag {
|
|||||||
case "e": {
|
case "e": {
|
||||||
// ["e", <event-id>, <relay-url>, <marker>]
|
// ["e", <event-id>, <relay-url>, <marker>]
|
||||||
this.Event = tag[1];
|
this.Event = tag[1];
|
||||||
this.Relay = tag.length > 2 ? tag[2] : null;
|
this.Relay = tag.length > 2 ? tag[2] : undefined;
|
||||||
this.Marker = tag.length > 3 ? tag[3] : null;
|
this.Marker = tag.length > 3 ? tag[3] : undefined;
|
||||||
if (!this.Event) {
|
if (!this.Event) {
|
||||||
this.Invalid = true;
|
this.Invalid = true;
|
||||||
}
|
}
|
||||||
@ -32,27 +39,23 @@ export default class Tag {
|
|||||||
this.PubKey = tag[1];
|
this.PubKey = tag[1];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
|
||||||
this.Other = tag;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToObject() {
|
ToObject(): string[] | null {
|
||||||
switch (this.Key) {
|
switch (this.Key) {
|
||||||
case "e": {
|
case "e": {
|
||||||
let ret = ["e", this.Event, this.Relay, this.Marker];
|
let ret = ["e", this.Event, this.Relay, this.Marker];
|
||||||
let trimEnd = ret.reverse().findIndex(a => a != null);
|
let trimEnd = ret.reverse().findIndex(a => a !== undefined);
|
||||||
return ret.reverse().slice(0, ret.length - trimEnd);
|
ret = ret.reverse().slice(0, ret.length - trimEnd);
|
||||||
|
return <string[]>ret;
|
||||||
}
|
}
|
||||||
case "p": {
|
case "p": {
|
||||||
return ["p", this.PubKey];
|
return this.PubKey ? ["p", this.PubKey] : null;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return this.Other;
|
return this.Original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,23 +1,24 @@
|
|||||||
import Event from "./Event";
|
import { u256 } from ".";
|
||||||
|
import { default as NEvent } from "./Event";
|
||||||
import EventKind from "./EventKind";
|
import EventKind from "./EventKind";
|
||||||
|
import Tag from "./Tag";
|
||||||
|
|
||||||
export default class Thread {
|
export default class Thread {
|
||||||
|
Root?: Tag;
|
||||||
|
ReplyTo?: Tag;
|
||||||
|
Mentions: Array<Tag>;
|
||||||
|
PubKeys: Array<u256>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
/** @type {Tag} */
|
|
||||||
this.Root = null;
|
|
||||||
/** @type {Tag} */
|
|
||||||
this.ReplyTo = null;
|
|
||||||
/** @type {Array<Tag>} */
|
|
||||||
this.Mentions = [];
|
this.Mentions = [];
|
||||||
/** @type {Array<String>} */
|
|
||||||
this.PubKeys = [];
|
this.PubKeys = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract thread information from an Event
|
* Extract thread information from an Event
|
||||||
* @param {Event} ev Event to extract thread from
|
* @param ev Event to extract thread from
|
||||||
*/
|
*/
|
||||||
static ExtractThread(ev) {
|
static ExtractThread(ev: NEvent) {
|
||||||
let isThread = ev.Tags.some(a => a.Key === "e");
|
let isThread = ev.Tags.some(a => a.Key === "e");
|
||||||
if (!isThread) {
|
if (!isThread) {
|
||||||
return null;
|
return null;
|
||||||
@ -26,13 +27,13 @@ export default class Thread {
|
|||||||
let shouldWriteMarkers = ev.Kind === EventKind.TextNote;
|
let shouldWriteMarkers = ev.Kind === EventKind.TextNote;
|
||||||
let ret = new Thread();
|
let ret = new Thread();
|
||||||
let eTags = ev.Tags.filter(a => a.Key === "e");
|
let eTags = ev.Tags.filter(a => a.Key === "e");
|
||||||
let marked = eTags.some(a => a.Marker !== null);
|
let marked = eTags.some(a => a.Marker !== undefined);
|
||||||
if (!marked) {
|
if (!marked) {
|
||||||
ret.Root = eTags[0];
|
ret.Root = eTags[0];
|
||||||
ret.Root.Marker = shouldWriteMarkers ? "root" : null;
|
ret.Root.Marker = shouldWriteMarkers ? "root" : undefined;
|
||||||
if (eTags.length > 1) {
|
if (eTags.length > 1) {
|
||||||
ret.ReplyTo = eTags[1];
|
ret.ReplyTo = eTags[1];
|
||||||
ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : null;
|
ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : undefined;
|
||||||
}
|
}
|
||||||
if (eTags.length > 2) {
|
if (eTags.length > 2) {
|
||||||
ret.Mentions = eTags.slice(2);
|
ret.Mentions = eTags.slice(2);
|
||||||
@ -47,7 +48,7 @@ export default class Thread {
|
|||||||
ret.ReplyTo = reply;
|
ret.ReplyTo = reply;
|
||||||
ret.Mentions = eTags.filter(a => a.Marker === "mention");
|
ret.Mentions = eTags.filter(a => a.Marker === "mention");
|
||||||
}
|
}
|
||||||
ret.PubKeys = [...new Set(ev.Tags.filter(a => a.Key === "p").map(a => a.PubKey))]
|
ret.PubKeys = Array.from(new Set(ev.Tags.filter(a => a.Key === "p").map(a => <u256>a.PubKey)));
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,9 +1,55 @@
|
|||||||
export interface RawEvent {
|
export type RawEvent = {
|
||||||
id: string,
|
id: u256,
|
||||||
pubkey: string,
|
pubkey: HexKey,
|
||||||
created_at: number,
|
created_at: number,
|
||||||
kind: number,
|
kind: number,
|
||||||
tags: string[][],
|
tags: string[][],
|
||||||
content: string,
|
content: string,
|
||||||
sig: string
|
sig: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaggedRawEvent extends RawEvent {
|
||||||
|
/**
|
||||||
|
* A list of relays this event was seen on
|
||||||
|
*/
|
||||||
|
relays: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic raw key as hex
|
||||||
|
*/
|
||||||
|
export type HexKey = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 256bit hex id
|
||||||
|
*/
|
||||||
|
export type u256 = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw REQ filter object
|
||||||
|
*/
|
||||||
|
export type RawReqFilter = {
|
||||||
|
ids?: u256[],
|
||||||
|
authors?: u256[],
|
||||||
|
kinds?: number[],
|
||||||
|
"#e"?: u256[],
|
||||||
|
"#p"?: u256[],
|
||||||
|
since?: number,
|
||||||
|
until?: number,
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medatadata event content
|
||||||
|
*/
|
||||||
|
export type UserMetadata = {
|
||||||
|
name?: string,
|
||||||
|
display_name?: string,
|
||||||
|
about?: string,
|
||||||
|
picture?: string,
|
||||||
|
website?: string,
|
||||||
|
banner?: string,
|
||||||
|
nip05?: string,
|
||||||
|
lud06?: string,
|
||||||
|
lud16?: string
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
export interface User {
|
|
||||||
name?: string
|
|
||||||
about?: string
|
|
||||||
display_name?: string
|
|
||||||
nip05?: string
|
|
||||||
pubkey: string
|
|
||||||
picture?: string
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ type RouterParams = {
|
|||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const params = useParams<RouterParams>();
|
const params = useParams<RouterParams>();
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const id = bech32ToHex(params.id);
|
const id = bech32ToHex(params.id ?? "");
|
||||||
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey));
|
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey));
|
||||||
const [content, setContent] = useState<string>();
|
const [content, setContent] = useState<string>();
|
||||||
const { ref, inView, entry } = useInView();
|
const { ref, inView, entry } = useInView();
|
||||||
@ -49,15 +49,17 @@ export default function ChatPage() {
|
|||||||
}, [inView, dmListRef, sortedDms]);
|
}, [inView, dmListRef, sortedDms]);
|
||||||
|
|
||||||
async function sendDm() {
|
async function sendDm() {
|
||||||
let ev = await publisher.sendDm(content, id);
|
if (content) {
|
||||||
console.debug(ev);
|
let ev = await publisher.sendDm(content, id);
|
||||||
publisher.broadcast(ev);
|
console.debug(ev);
|
||||||
setContent("");
|
publisher.broadcast(ev);
|
||||||
|
setContent(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEnter(e: KeyboardEvent) {
|
async function onEnter(e: KeyboardEvent) {
|
||||||
let isEnter = e.code === "Enter";
|
let isEnter = e.code === "Enter";
|
||||||
if(isEnter && !e.shiftKey) {
|
if (isEnter && !e.shiftKey) {
|
||||||
await sendDm();
|
await sendDm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,10 @@ import { Outlet, useNavigate } from "react-router-dom";
|
|||||||
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
|
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { System } from ".."
|
import { System } from "../nostr/System"
|
||||||
import ProfileImage from "../element/ProfileImage";
|
import ProfileImage from "../element/ProfileImage";
|
||||||
import { init } from "../state/Login";
|
import { init } from "../state/Login";
|
||||||
import useLoginFeed from "../feed/LoginFeed";
|
import useLoginFeed from "../feed/LoginFeed";
|
||||||
import useUsersCache from "../feed/UsersFeed";
|
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -19,7 +18,6 @@ export default function Layout(props) {
|
|||||||
const relays = useSelector(s => s.login.relays);
|
const relays = useSelector(s => s.login.relays);
|
||||||
const notifications = useSelector(s => s.login.notifications);
|
const notifications = useSelector(s => s.login.notifications);
|
||||||
const readNotifications = useSelector(s => s.login.readNotifications);
|
const readNotifications = useSelector(s => s.login.readNotifications);
|
||||||
useUsersCache();
|
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -27,7 +25,7 @@ export default function Layout(props) {
|
|||||||
for (let [k, v] of Object.entries(relays)) {
|
for (let [k, v] of Object.entries(relays)) {
|
||||||
System.ConnectToRelay(k, v);
|
System.ConnectToRelay(k, v);
|
||||||
}
|
}
|
||||||
for (let [k, v] of Object.entries(System.Sockets)) {
|
for (let [k, v] of System.Sockets) {
|
||||||
if (!relays[k]) {
|
if (!relays[k]) {
|
||||||
System.DisconnectRelay(k);
|
System.DisconnectRelay(k);
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ export default function NotificationsPage() {
|
|||||||
const etagged = useMemo(() => {
|
const etagged = useMemo(() => {
|
||||||
return notifications?.filter(a => a.kind === EventKind.Reaction)
|
return notifications?.filter(a => a.kind === EventKind.Reaction)
|
||||||
.map(a => {
|
.map(a => {
|
||||||
let ev = Event.FromObject(a);
|
let ev = new Event(a);
|
||||||
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
})
|
})
|
||||||
}, [notifications]);
|
}, [notifications]);
|
||||||
@ -27,12 +27,12 @@ export default function NotificationsPage() {
|
|||||||
const subEvents = useMemo(() => {
|
const subEvents = useMemo(() => {
|
||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `reactions:${sub.Id}`;
|
sub.Id = `reactions:${sub.Id}`;
|
||||||
sub.Kinds.add(EventKind.Reaction);
|
sub.Kinds = new Set([EventKind.Reaction]);
|
||||||
sub.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id));
|
sub.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id));
|
||||||
|
|
||||||
if (etagged.length > 0) {
|
if (etagged.length > 0) {
|
||||||
let reactionsTo = new Subscriptions();
|
let reactionsTo = new Subscriptions();
|
||||||
reactionsTo.Kinds.add(EventKind.TextNote);
|
reactionsTo.Kinds = new Set([EventKind.TextNote]);
|
||||||
reactionsTo.Ids = new Set(etagged);
|
reactionsTo.Ids = new Set(etagged);
|
||||||
sub.OrSubs.push(reactionsTo);
|
sub.OrSubs.push(reactionsTo);
|
||||||
}
|
}
|
||||||
@ -52,7 +52,7 @@ export default function NotificationsPage() {
|
|||||||
let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
|
let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
|
||||||
return <Note data={a} key={a.id} reactions={reactions} />
|
return <Note data={a} key={a.id} reactions={reactions} />
|
||||||
} else if (a.kind === EventKind.Reaction) {
|
} else if (a.kind === EventKind.Reaction) {
|
||||||
let ev = Event.FromObject(a);
|
let ev = new Event(a);
|
||||||
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo);
|
let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo);
|
||||||
return <NoteReaction data={a} key={a.id} root={reactedNote} />
|
return <NoteReaction data={a} key={a.id} root={reactedNote} />
|
||||||
|
@ -105,57 +105,54 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatar() {
|
function avatar() {
|
||||||
return (
|
return (
|
||||||
<div className="avatar-wrapper">
|
<div className="avatar-wrapper">
|
||||||
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
|
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function userDetails() {
|
function userDetails() {
|
||||||
return (
|
return (
|
||||||
<div className="details-wrapper">
|
<div className="details-wrapper">
|
||||||
{username()}
|
{username()}
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
|
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
|
||||||
<FontAwesomeIcon icon={faGear} size="lg" />
|
<FontAwesomeIcon icon={faGear} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : <>
|
) : <>
|
||||||
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
|
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
|
||||||
<FontAwesomeIcon icon={faEnvelope} size="lg" />
|
<FontAwesomeIcon icon={faEnvelope} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<FollowButton pubkey={id} />
|
<FollowButton pubkey={id} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{bio()}
|
{bio()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="profile flex">
|
<div className="profile flex">
|
||||||
{user?.banner && <img alt="banner" className="banner" src={user.banner} /> }
|
{user?.banner && <img alt="banner" className="banner" src={user.banner} />}
|
||||||
{user?.banner ? (
|
{user?.banner ? (
|
||||||
<>
|
<>
|
||||||
{avatar()}
|
{avatar()}
|
||||||
{userDetails()}
|
{userDetails()}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-banner">
|
<div className="no-banner">
|
||||||
{avatar()}
|
{avatar()}
|
||||||
{userDetails()}
|
{userDetails()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
{
|
{Object.entries(ProfileTab).map(([k, v]) => {
|
||||||
Object.entries(ProfileTab).map(([k, v]) => {
|
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
|
||||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
|
})}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
{tabContent()}
|
{tabContent()}
|
||||||
</>
|
</>
|
||||||
|
@ -11,7 +11,6 @@ import useEventPublisher from "../feed/EventPublisher";
|
|||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
import VoidUpload from "../feed/VoidUpload";
|
import VoidUpload from "../feed/VoidUpload";
|
||||||
import { logout, setRelays } from "../state/Login";
|
import { logout, setRelays } from "../state/Login";
|
||||||
import { resetProfile } from "../state/Users";
|
|
||||||
import { hexToBech32, openFile } from "../Util";
|
import { hexToBech32, openFile } from "../Util";
|
||||||
import Relay from "../element/Relay";
|
import Relay from "../element/Relay";
|
||||||
import Copy from "../element/Copy";
|
import Copy from "../element/Copy";
|
||||||
@ -87,7 +86,6 @@ export default function SettingsPage(props) {
|
|||||||
|
|
||||||
let ev = await publisher.metadata(userCopy);
|
let ev = await publisher.metadata(userCopy);
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
dispatch(resetProfile(id));
|
|
||||||
publisher.broadcast(ev);
|
publisher.broadcast(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,71 +1,87 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit'
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
import * as secp from '@noble/secp256k1';
|
import * as secp from '@noble/secp256k1';
|
||||||
import { DefaultRelays } from '../Const';
|
import { DefaultRelays } from '../Const';
|
||||||
|
import { HexKey, RawEvent, TaggedRawEvent } from '../nostr';
|
||||||
|
import { RelaySettings } from '../nostr/Connection';
|
||||||
|
|
||||||
const PrivateKeyItem = "secret";
|
const PrivateKeyItem = "secret";
|
||||||
const PublicKeyItem = "pubkey";
|
const PublicKeyItem = "pubkey";
|
||||||
const NotificationsReadItem = "notifications-read";
|
const NotificationsReadItem = "notifications-read";
|
||||||
|
|
||||||
|
interface LoginStore {
|
||||||
|
/**
|
||||||
|
* If there is no login
|
||||||
|
*/
|
||||||
|
loggedOut?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current user private key
|
||||||
|
*/
|
||||||
|
privateKey?: HexKey,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current users public key
|
||||||
|
*/
|
||||||
|
publicKey?: HexKey,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the logged in users relays
|
||||||
|
*/
|
||||||
|
relays: any,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest relay list timestamp
|
||||||
|
*/
|
||||||
|
latestRelays: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of pubkeys this user follows
|
||||||
|
*/
|
||||||
|
follows: HexKey[],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications for this login session
|
||||||
|
*/
|
||||||
|
notifications: TaggedRawEvent[],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of last read notification
|
||||||
|
*/
|
||||||
|
readNotifications: number,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DM's
|
||||||
|
*/
|
||||||
|
dms: TaggedRawEvent[]
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SetRelaysPayload {
|
||||||
|
relays: any,
|
||||||
|
createdAt: number
|
||||||
|
};
|
||||||
|
|
||||||
const LoginSlice = createSlice({
|
const LoginSlice = createSlice({
|
||||||
name: "Login",
|
name: "Login",
|
||||||
initialState: {
|
initialState: <LoginStore>{
|
||||||
/**
|
|
||||||
* If there is no login
|
|
||||||
*/
|
|
||||||
loggedOut: null,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current user private key
|
|
||||||
*/
|
|
||||||
privateKey: null,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current users public key
|
|
||||||
*/
|
|
||||||
publicKey: null,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configured relays for this user
|
|
||||||
*/
|
|
||||||
relays: {},
|
relays: {},
|
||||||
|
latestRelays: 0,
|
||||||
/**
|
|
||||||
* Newest relay list timestamp
|
|
||||||
*/
|
|
||||||
latestRelays: null,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of pubkeys this user follows
|
|
||||||
*/
|
|
||||||
follows: [],
|
follows: [],
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifications for this login session
|
|
||||||
*/
|
|
||||||
notifications: [],
|
notifications: [],
|
||||||
|
|
||||||
/**
|
|
||||||
* Timestamp of last read notification
|
|
||||||
*/
|
|
||||||
readNotifications: 0,
|
readNotifications: 0,
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypted DM's
|
|
||||||
*/
|
|
||||||
dms: []
|
dms: []
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
init: (state) => {
|
init: (state) => {
|
||||||
state.privateKey = window.localStorage.getItem(PrivateKeyItem);
|
state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined;
|
||||||
if (state.privateKey) {
|
if (state.privateKey) {
|
||||||
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key
|
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key
|
||||||
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey, true));
|
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey));
|
||||||
state.loggedOut = false;
|
state.loggedOut = false;
|
||||||
} else {
|
} else {
|
||||||
state.loggedOut = true;
|
state.loggedOut = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.relays = DefaultRelays;
|
state.relays = Object.fromEntries(DefaultRelays.entries());
|
||||||
|
|
||||||
// check pub key only
|
// check pub key only
|
||||||
let pubKey = window.localStorage.getItem(PublicKeyItem);
|
let pubKey = window.localStorage.getItem(PublicKeyItem);
|
||||||
@ -75,41 +91,45 @@ const LoginSlice = createSlice({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem));
|
let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0");
|
||||||
if (!isNaN(readNotif)) {
|
if (!isNaN(readNotif)) {
|
||||||
state.readNotifications = readNotif;
|
state.readNotifications = readNotif;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setPrivateKey: (state, action) => {
|
setPrivateKey: (state, action: PayloadAction<HexKey>) => {
|
||||||
state.loggedOut = false;
|
state.loggedOut = false;
|
||||||
state.privateKey = action.payload;
|
state.privateKey = action.payload;
|
||||||
window.localStorage.setItem(PrivateKeyItem, action.payload);
|
window.localStorage.setItem(PrivateKeyItem, action.payload);
|
||||||
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload, true));
|
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload));
|
||||||
},
|
},
|
||||||
setPublicKey: (state, action) => {
|
setPublicKey: (state, action: PayloadAction<HexKey>) => {
|
||||||
window.localStorage.setItem(PublicKeyItem, action.payload);
|
window.localStorage.setItem(PublicKeyItem, action.payload);
|
||||||
state.loggedOut = false;
|
state.loggedOut = false;
|
||||||
state.publicKey = action.payload;
|
state.publicKey = action.payload;
|
||||||
},
|
},
|
||||||
setRelays: (state, action) => {
|
setRelays: (state, action: PayloadAction<SetRelaysPayload>) => {
|
||||||
let relays = action.payload.relays;
|
let relays = action.payload.relays;
|
||||||
let createdAt = action.payload.createdAt;
|
let createdAt = action.payload.createdAt;
|
||||||
if(state.latestRelays > createdAt) {
|
if (state.latestRelays > createdAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter out non-websocket urls
|
// filter out non-websocket urls
|
||||||
let filtered = Object.entries(relays)
|
let filtered = new Map<string, RelaySettings>();
|
||||||
.filter(a => a[0].startsWith("ws://") || a[0].startsWith("wss://"));
|
for (let [k, v] of Object.entries(relays)) {
|
||||||
|
if (k.startsWith("wss://") || k.startsWith("ws://")) {
|
||||||
|
filtered.set(k, <RelaySettings>v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.relays = Object.fromEntries(filtered);
|
state.relays = Object.fromEntries(filtered.entries());
|
||||||
state.latestRelays = createdAt;
|
state.latestRelays = createdAt;
|
||||||
},
|
},
|
||||||
removeRelay: (state, action) => {
|
removeRelay: (state, action: PayloadAction<string>) => {
|
||||||
delete state.relays[action.payload];
|
delete state.relays[action.payload];
|
||||||
state.relays = { ...state.relays };
|
state.relays = { ...state.relays };
|
||||||
},
|
},
|
||||||
setFollows: (state, action) => {
|
setFollows: (state, action: PayloadAction<string | string[]>) => {
|
||||||
let existing = new Set(state.follows);
|
let existing = new Set(state.follows);
|
||||||
let update = Array.isArray(action.payload) ? action.payload : [action.payload];
|
let update = Array.isArray(action.payload) ? action.payload : [action.payload];
|
||||||
|
|
||||||
@ -124,7 +144,7 @@ const LoginSlice = createSlice({
|
|||||||
state.follows = Array.from(existing);
|
state.follows = Array.from(existing);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addNotifications: (state, action) => {
|
addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => {
|
||||||
let n = action.payload;
|
let n = action.payload;
|
||||||
if (!Array.isArray(n)) {
|
if (!Array.isArray(n)) {
|
||||||
n = [n];
|
n = [n];
|
||||||
@ -143,7 +163,7 @@ const LoginSlice = createSlice({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addDirectMessage: (state, action) => {
|
addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
|
||||||
let n = action.payload;
|
let n = action.payload;
|
||||||
if (!Array.isArray(n)) {
|
if (!Array.isArray(n)) {
|
||||||
n = [n];
|
n = [n];
|
||||||
@ -166,8 +186,8 @@ const LoginSlice = createSlice({
|
|||||||
window.localStorage.removeItem(PrivateKeyItem);
|
window.localStorage.removeItem(PrivateKeyItem);
|
||||||
window.localStorage.removeItem(PublicKeyItem);
|
window.localStorage.removeItem(PublicKeyItem);
|
||||||
window.localStorage.removeItem(NotificationsReadItem);
|
window.localStorage.removeItem(NotificationsReadItem);
|
||||||
state.privateKey = null;
|
state.privateKey = undefined;
|
||||||
state.publicKey = null;
|
state.publicKey = undefined;
|
||||||
state.follows = [];
|
state.follows = [];
|
||||||
state.notifications = [];
|
state.notifications = [];
|
||||||
state.loggedOut = true;
|
state.loggedOut = true;
|
||||||
@ -176,10 +196,21 @@ const LoginSlice = createSlice({
|
|||||||
},
|
},
|
||||||
markNotificationsRead: (state) => {
|
markNotificationsRead: (state) => {
|
||||||
state.readNotifications = new Date().getTime();
|
state.readNotifications = new Date().getTime();
|
||||||
window.localStorage.setItem(NotificationsReadItem, state.readNotifications);
|
window.localStorage.setItem(NotificationsReadItem, state.readNotifications.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, addDirectMessage, logout, markNotificationsRead } = LoginSlice.actions;
|
export const {
|
||||||
|
init,
|
||||||
|
setPrivateKey,
|
||||||
|
setPublicKey,
|
||||||
|
setRelays,
|
||||||
|
removeRelay,
|
||||||
|
setFollows,
|
||||||
|
addNotifications,
|
||||||
|
addDirectMessage,
|
||||||
|
logout,
|
||||||
|
markNotificationsRead
|
||||||
|
} = LoginSlice.actions;
|
||||||
export const reducer = LoginSlice.reducer;
|
export const reducer = LoginSlice.reducer;
|
@ -1,12 +0,0 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
|
||||||
import { reducer as UsersReducer } from "./Users";
|
|
||||||
import { reducer as LoginReducer } from "./Login";
|
|
||||||
|
|
||||||
const Store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
users: UsersReducer,
|
|
||||||
login: LoginReducer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Store;
|
|
13
src/state/Store.ts
Normal file
13
src/state/Store.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
import { reducer as LoginReducer } from "./Login";
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
login: LoginReducer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
export type AppDispatch = typeof store.dispatch
|
||||||
|
|
||||||
|
export default store;
|
@ -1,97 +0,0 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit'
|
|
||||||
import { ProfileCacheExpire } from '../Const';
|
|
||||||
import { db } from '../db';
|
|
||||||
|
|
||||||
const UsersSlice = createSlice({
|
|
||||||
name: "Users",
|
|
||||||
initialState: {
|
|
||||||
/**
|
|
||||||
* Set of known pubkeys
|
|
||||||
*/
|
|
||||||
pubKeys: [],
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User objects for known pubKeys, populated async
|
|
||||||
*/
|
|
||||||
users: {},
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
addPubKey: (state, action) => {
|
|
||||||
let keys = action.payload;
|
|
||||||
if (!Array.isArray(keys)) {
|
|
||||||
keys = [keys];
|
|
||||||
}
|
|
||||||
let changes = false;
|
|
||||||
let fromCache = false;
|
|
||||||
let temp = new Set(state.pubKeys);
|
|
||||||
for (let k of keys) {
|
|
||||||
if (!temp.has(k)) {
|
|
||||||
changes = true;
|
|
||||||
temp.add(k);
|
|
||||||
|
|
||||||
// load from cache
|
|
||||||
let cache = window.localStorage.getItem(`user:${k}`);
|
|
||||||
if (cache) {
|
|
||||||
let ud = JSON.parse(cache);
|
|
||||||
if (ud.loaded > new Date().getTime() - ProfileCacheExpire) {
|
|
||||||
state.users[ud.pubkey] = ud;
|
|
||||||
fromCache = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changes) {
|
|
||||||
state.pubKeys = Array.from(temp);
|
|
||||||
if (fromCache) {
|
|
||||||
state.users = {
|
|
||||||
...state.users
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setUserData: (state, action) => {
|
|
||||||
let ud = action.payload;
|
|
||||||
if (!Array.isArray(ud)) {
|
|
||||||
ud = [ud];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let x of ud) {
|
|
||||||
let existing = state.users[x.pubkey];
|
|
||||||
if (existing) {
|
|
||||||
if (existing.fromEvent.created_at > x.fromEvent.created_at) {
|
|
||||||
// prevent patching with older metadata
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
x = {
|
|
||||||
...existing,
|
|
||||||
...x
|
|
||||||
};
|
|
||||||
}
|
|
||||||
state.users[x.pubkey] = x;
|
|
||||||
db.users.put({
|
|
||||||
pubkey: x.pubkey,
|
|
||||||
name: x.name,
|
|
||||||
display_name: x.display_name,
|
|
||||||
nip05: x.nip05,
|
|
||||||
picture: x.picture,
|
|
||||||
})
|
|
||||||
window.localStorage.setItem(`user:${x.pubkey}`, JSON.stringify(x));
|
|
||||||
|
|
||||||
state.users = {
|
|
||||||
...state.users
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resetProfile: (state, action) => {
|
|
||||||
if (state.users[action.payload]) {
|
|
||||||
delete state.users[action.payload];
|
|
||||||
state.users = {
|
|
||||||
...state.users
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { addPubKey, setUserData, resetProfile } = UsersSlice.actions;
|
|
||||||
export const reducer = UsersSlice.reducer;
|
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"target": "es6",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
@ -2127,6 +2127,10 @@
|
|||||||
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
|
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
"@types/uuid@^9.0.0":
|
||||||
|
version "9.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2"
|
||||||
|
integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==
|
||||||
|
|
||||||
"@types/ws@^8.5.1":
|
"@types/ws@^8.5.1":
|
||||||
version "8.5.4"
|
version "8.5.4"
|
||||||
|
Loading…
Reference in New Issue
Block a user