mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-12 13:49:33 +02:00
add offline mode
add simple 0 relay warning fix multiple services not using local relay fix bug in nostr-idb not supporting multiple filters
This commit is contained in:
parent
cec7eff183
commit
31a649e867
5
.changeset/bright-shirts-explain.md
Normal file
5
.changeset/bright-shirts-explain.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add offline mode
|
@ -29,8 +29,8 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect": "^3.2.1",
|
||||
"@getalby/bitcoin-connect-react": "^3.2.1",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@react-three/drei": "^9.92.5",
|
||||
"@react-three/fiber": "^8.15.12",
|
||||
@ -56,7 +56,7 @@
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^5.0.4",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-idb": "^2.0.0",
|
||||
"nostr-idb": "^2.0.1",
|
||||
"nostr-tools": "^2.1.3",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
|
@ -85,6 +85,7 @@ import VideosView from "./views/videos";
|
||||
import VideoDetailsView from "./views/videos/video";
|
||||
import BookmarksView from "./views/bookmarks";
|
||||
import MailboxesView from "./views/mailboxes";
|
||||
import RequireReadRelays from "./providers/route/require-read-relays";
|
||||
const TracksView = lazy(() => import("./views/tracks"));
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
|
@ -19,7 +19,7 @@ export default class NostrSubscription {
|
||||
onEvent = new Subject<NostrEvent>();
|
||||
onEOSE = new Subject<IncomingEOSE>();
|
||||
|
||||
constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) {
|
||||
constructor(relayUrl: string | URL, query?: NostrRequestFilter, name?: string) {
|
||||
this.id = nanoid();
|
||||
this.query = query;
|
||||
this.name = name;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { offlineMode } from "../services/offline-mode";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
|
||||
import { NostrOutgoingMessage } from "../types/nostr-query";
|
||||
@ -64,6 +65,8 @@ export default class Relay {
|
||||
}
|
||||
|
||||
open() {
|
||||
if (offlineMode.value) return;
|
||||
|
||||
if (this.okay) return;
|
||||
this.intentionalClose = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
@ -166,7 +169,6 @@ export default class Relay {
|
||||
}
|
||||
|
||||
handleMessage(event: MessageEvent<string>) {
|
||||
// skip empty events
|
||||
if (!event.data) return;
|
||||
|
||||
try {
|
||||
|
@ -22,7 +22,6 @@ import {
|
||||
} from "../helpers/nostr/filter";
|
||||
import { localRelay } from "../services/local-relay";
|
||||
import { relayRequest } from "../helpers/relay";
|
||||
import { Subscription } from "nostr-idb";
|
||||
|
||||
const BLOCK_SIZE = 100;
|
||||
|
||||
|
@ -91,7 +91,11 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
|
||||
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
|
||||
</Section>
|
||||
|
||||
<Section label="Content" p="0" actions={<CopyIconButton aria-label="copy json" text={event.content} />}>
|
||||
<Section
|
||||
label="Content"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" text={event.content} size="sm" />}
|
||||
>
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{event.content}
|
||||
</Code>
|
||||
@ -99,7 +103,7 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
|
||||
<Section
|
||||
label="JSON"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" text={JSON.stringify(event)} />}
|
||||
actions={<CopyIconButton aria-label="copy json" text={JSON.stringify(event)} size="sm" />}
|
||||
>
|
||||
<JsonCode data={event} />
|
||||
</Section>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { Avatar, Box, Button, Flex, FlexProps, Heading, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Avatar, Box, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
@ -9,6 +9,9 @@ import PublishLog from "../publish-log";
|
||||
import NavItems from "./nav-items";
|
||||
import { PostModalContext } from "../../providers/route/post-modal-provider";
|
||||
import { WritingIcon } from "../icons";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { offlineMode } from "../../services/offline-mode";
|
||||
import WifiOff from "../icons/wifi-off";
|
||||
|
||||
const hideScrollbar = css`
|
||||
-ms-overflow-style: none;
|
||||
@ -21,6 +24,7 @@ const hideScrollbar = css`
|
||||
export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
const account = useCurrentAccount();
|
||||
const { openModal } = useContext(PostModalContext);
|
||||
const offline = useSubject(offlineMode);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@ -44,6 +48,14 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
noStrudel
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
{offline && (
|
||||
<IconButton
|
||||
aria-label="Disable offline mode"
|
||||
title="Disable offline mode"
|
||||
icon={<WifiOff boxSize={5} color="orange" />}
|
||||
onClick={() => offlineMode.next(false)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{account && (
|
||||
<>
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { Button, Flex, FlexProps } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { safeRelayUrl } from "../../helpers/relay";
|
||||
import { RelayUrlInput } from "../relay-url-input";
|
||||
|
||||
export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
|
||||
export default function AddRelayForm({
|
||||
onSubmit,
|
||||
...props
|
||||
}: { onSubmit: (relay: string) => void } & Omit<FlexProps, "children" | "onSubmit">) {
|
||||
const { register, handleSubmit, reset } = useForm({
|
||||
defaultValues: {
|
||||
url: "",
|
||||
@ -19,7 +22,7 @@ export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) =
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex as="form" display="flex" gap="2" onSubmit={submit}>
|
||||
<Flex as="form" display="flex" gap="2" onSubmit={submit} {...props}>
|
||||
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" size="sm" borderRadius="md" />
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
|
40
src/components/setup/index.tsx
Normal file
40
src/components/setup/index.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
|
||||
export default function Setup() {
|
||||
const relaysModal = useDisclosure();
|
||||
|
||||
const readRelays = useReadRelays();
|
||||
useEffect(() => (readRelays.size === 0 ? relaysModal.onOpen() : relaysModal.onClose()), [readRelays]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={relaysModal.isOpen} onClose={relaysModal.onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Setup Relays</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody></ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={relaysModal.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="ghost">Use Default</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
export const SEARCH_RELAYS = [
|
||||
import { safeRelayUrl, safeRelayUrls } from "./helpers/relay";
|
||||
|
||||
export const SEARCH_RELAYS = safeRelayUrls([
|
||||
"wss://relay.nostr.band",
|
||||
"wss://search.nos.today",
|
||||
"wss://relay.noswhere.com",
|
||||
// TODO: requires NIP-42 auth
|
||||
// "wss://filter.nostr.wine",
|
||||
];
|
||||
export const COMMON_CONTACT_RELAY = "wss://purplepag.es";
|
||||
]);
|
||||
export const COMMON_CONTACT_RELAY = safeRelayUrl("wss://purplepag.es") as string;
|
||||
|
@ -11,13 +11,13 @@ export function getRelayVariations(relay: string) {
|
||||
} else return [relay, relay + "/"];
|
||||
}
|
||||
|
||||
export function validateRelayURL(relay: string) {
|
||||
if (relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
|
||||
const url = new URL(relay);
|
||||
export function validateRelayURL(relay: string | URL) {
|
||||
if (typeof relay === "string" && relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
|
||||
const url = typeof relay === "string" ? new URL(relay) : relay;
|
||||
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
|
||||
return url;
|
||||
}
|
||||
export function isValidRelayURL(relay: string) {
|
||||
export function isValidRelayURL(relay: string | URL) {
|
||||
try {
|
||||
validateRelayURL(relay);
|
||||
return true;
|
||||
@ -26,6 +26,7 @@ export function isValidRelayURL(relay: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function normalizeRelayURL(relayUrl: string) {
|
||||
const url = validateRelayURL(relayUrl);
|
||||
url.pathname = url.pathname.replace(/\/+/g, "/");
|
||||
@ -34,6 +35,8 @@ export function normalizeRelayURL(relayUrl: string) {
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function safeNormalizeRelayURL(relayUrl: string) {
|
||||
try {
|
||||
return normalizeRelayURL(relayUrl);
|
||||
@ -43,7 +46,7 @@ export function safeNormalizeRelayURL(relayUrl: string) {
|
||||
}
|
||||
|
||||
// TODO: move these to helpers/relay
|
||||
export function safeRelayUrl(relayUrl: string) {
|
||||
export function safeRelayUrl(relayUrl: string | URL) {
|
||||
try {
|
||||
return validateRelayURL(relayUrl).toString();
|
||||
} catch (e) {
|
||||
|
@ -2,7 +2,7 @@ import "./polyfill";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import { GlobalProviders } from "./providers/global";
|
||||
import "./services/controller";
|
||||
import "./services/user-event-sync";
|
||||
|
||||
// setup bitcoin connect
|
||||
import { init, onConnected } from "@getalby/bitcoin-connect-react";
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import TimelineLoader from "../../classes/timeline-loader";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useUserInbox } from "../../hooks/use-user-mailboxes";
|
||||
|
||||
type DMTimelineContextType = {
|
||||
timeline?: TimelineLoader;
|
||||
@ -23,7 +23,7 @@ export function useDMTimeline() {
|
||||
|
||||
export default function DMTimelineProvider({ children }: PropsWithChildren) {
|
||||
const account = useCurrentAccount();
|
||||
const inbox = useReadRelays();
|
||||
const inbox = useUserInbox(account?.pubkey);
|
||||
|
||||
const userMuteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
|
@ -3,6 +3,7 @@ import DeleteEventProvider from "./delete-event-provider";
|
||||
import InvoiceModalProvider from "./invoice-modal";
|
||||
import MuteModalProvider from "./mute-modal-provider";
|
||||
import PostModalProvider from "./post-modal-provider";
|
||||
import RequireReadRelays from "./require-read-relays";
|
||||
|
||||
/** Providers that provide functionality to pages (needs to be rendered under a router) */
|
||||
export function RouteProviders({ children }: { children: React.ReactNode }) {
|
||||
@ -11,7 +12,9 @@ export function RouteProviders({ children }: { children: React.ReactNode }) {
|
||||
<MuteModalProvider>
|
||||
<DebugModalProvider>
|
||||
<InvoiceModalProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
<PostModalProvider>
|
||||
<RequireReadRelays>{children}</RequireReadRelays>
|
||||
</PostModalProvider>
|
||||
</InvoiceModalProvider>
|
||||
</DebugModalProvider>
|
||||
</MuteModalProvider>
|
||||
|
36
src/providers/route/require-read-relays.tsx
Normal file
36
src/providers/route/require-read-relays.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { Button, ButtonGroup, Flex, Heading } from "@chakra-ui/react";
|
||||
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import clientRelaysService, { recommendedReadRelays, recommendedWriteRelays } from "../../services/client-relays";
|
||||
import AddRelayForm from "../../components/relay-management-drawer/add-relay-form";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { offlineMode } from "../../services/offline-mode";
|
||||
|
||||
export default function RequireReadRelays({ children }: PropsWithChildren) {
|
||||
const readRelays = useReadRelays();
|
||||
const offline = useSubject(offlineMode);
|
||||
|
||||
const setDefault = useCallback(() => {
|
||||
clientRelaysService.readRelays.next(recommendedReadRelays);
|
||||
clientRelaysService.writeRelays.next(recommendedWriteRelays);
|
||||
clientRelaysService.saveRelays();
|
||||
}, []);
|
||||
|
||||
if (readRelays.size === 0 && !offline)
|
||||
return (
|
||||
<Flex direction="column" maxW="md" mx="auto" h="full" alignItems="center" justifyContent="center" gap="4">
|
||||
<Heading size="md">Looks like you don't have any relays setup</Heading>
|
||||
<AddRelayForm onSubmit={(url) => clientRelaysService.addRelay(url, RelayMode.ALL)} w="full" />
|
||||
<ButtonGroup ml="auto">
|
||||
<Button onClick={() => offlineMode.next(true)}>Offline mode</Button>
|
||||
<Button colorScheme="primary" onClick={setDefault}>
|
||||
Use recommended
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return children;
|
||||
}
|
@ -1,123 +1,4 @@
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { ProjPointType } from "@noble/curves/abstract/weierstrass";
|
||||
import { bytesToHex, randomBytes } from "@noble/hashes/utils";
|
||||
import {
|
||||
BlindedMessageData,
|
||||
CashuMint,
|
||||
CashuWallet,
|
||||
MintKeys,
|
||||
Proof,
|
||||
SerializedBlindedMessage,
|
||||
getEncodedToken,
|
||||
getDecodedToken,
|
||||
SerializedBlindedSignature,
|
||||
} from "@cashu/cashu-ts";
|
||||
import { bytesToNumber, splitAmount } from "@cashu/cashu-ts/dist/lib/es6/utils";
|
||||
import { hashToCurve, pointFromHex, unblindSignature } from "@cashu/cashu-ts/dist/lib/es6/DHKE";
|
||||
import { BlindedMessage } from "@cashu/cashu-ts/dist/lib/es6/model/BlindedMessage";
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */
|
||||
function blindMessage(secret: Uint8Array, r?: bigint): { B_: ProjPointType<bigint>; r: bigint } {
|
||||
const secretMessageBase64 = textDecoder.decode(secret); //encodeUint8toBase64(secret);
|
||||
const secretMessage = new TextEncoder().encode(secretMessageBase64);
|
||||
const Y = hashToCurve(secretMessage);
|
||||
if (!r) {
|
||||
r = bytesToNumber(secp256k1.utils.randomPrivateKey());
|
||||
}
|
||||
const rG = secp256k1.ProjectivePoint.BASE.multiply(r);
|
||||
const B_ = Y.add(rG);
|
||||
return { B_, r };
|
||||
}
|
||||
|
||||
/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */
|
||||
function constructProofs(
|
||||
promises: Array<SerializedBlindedSignature>,
|
||||
rs: Array<bigint>,
|
||||
secrets: Array<Uint8Array>,
|
||||
keys: MintKeys,
|
||||
): Array<Proof> {
|
||||
return promises.map((p: SerializedBlindedSignature, i: number) => {
|
||||
const C_ = pointFromHex(p.C_);
|
||||
const A = pointFromHex(keys[p.amount]);
|
||||
const C = unblindSignature(C_, rs[i], A);
|
||||
const proof = {
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
secret: textDecoder.decode(secrets[i]), // encodeUint8toBase64(secrets[i]),
|
||||
C: C.toHex(true),
|
||||
};
|
||||
return proof;
|
||||
});
|
||||
}
|
||||
|
||||
class P2PKCashuWallet extends CashuWallet {
|
||||
p2pkCreateRandomBlindedMessages(amount: number, pubkey: string): BlindedMessageData & { amounts: Array<number> } {
|
||||
const amounts = splitAmount(amount);
|
||||
return this.p2pkCreateBlindedMessages(amounts, pubkey);
|
||||
}
|
||||
p2pkCreateBlindedMessages(amounts: Array<number>, pubkey: string): BlindedMessageData & { amounts: Array<number> } {
|
||||
const blindedMessages: Array<SerializedBlindedMessage> = [];
|
||||
const secrets: Array<Uint8Array> = [];
|
||||
const rs: Array<bigint> = [];
|
||||
for (let i = 0; i < amounts.length; i++) {
|
||||
let deterministicR = undefined;
|
||||
let secret = undefined;
|
||||
secret = textEncoder.encode(
|
||||
JSON.stringify([
|
||||
"P2PK",
|
||||
{
|
||||
// NOTE: the order is very important for the token to work with nutshell
|
||||
// This can be removed when nutshell no longer re-encodes the secret when checking the sig
|
||||
data: pubkey,
|
||||
nonce: bytesToHex(randomBytes(16)),
|
||||
},
|
||||
])
|
||||
.replaceAll(/,/g, ", ")
|
||||
.replaceAll(/:/g, ": "),
|
||||
);
|
||||
secrets.push(secret);
|
||||
const { B_, r } = blindMessage(secret, deterministicR);
|
||||
rs.push(r);
|
||||
const blindedMessage = new BlindedMessage(amounts[i], B_);
|
||||
blindedMessages.push(blindedMessage.getSerializedBlindedMessage());
|
||||
}
|
||||
return { blindedMessages, secrets, rs, amounts };
|
||||
}
|
||||
|
||||
async p2pkRequestTokens(
|
||||
amount: number,
|
||||
id: string,
|
||||
pubkey: string,
|
||||
): Promise<{ proofs: Array<Proof>; newKeys?: MintKeys }> {
|
||||
const { blindedMessages, secrets, rs } = this.p2pkCreateRandomBlindedMessages(amount, pubkey);
|
||||
const payloads = { outputs: blindedMessages };
|
||||
const { promises } = await this.mint.mint(payloads, id);
|
||||
return {
|
||||
proofs: constructProofs(
|
||||
promises,
|
||||
rs,
|
||||
secrets,
|
||||
//@ts-ignore
|
||||
await this.getKeys(promises),
|
||||
),
|
||||
//@ts-ignore
|
||||
newKeys: await this.changedKeys(promises),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
const wallet = new P2PKCashuWallet(new CashuMint("https://8333.space:3338"));
|
||||
|
||||
//@ts-ignore
|
||||
window.wallet = wallet;
|
||||
//@ts-ignore
|
||||
window.getDecodedToken = getDecodedToken;
|
||||
//@ts-ignore
|
||||
window.getEncodedToken = getEncodedToken;
|
||||
import { CashuMint } from "@cashu/cashu-ts";
|
||||
|
||||
const mints = new Map<string, CashuMint>();
|
||||
|
||||
|
@ -8,6 +8,14 @@ import { NostrEvent } from "nostr-tools";
|
||||
|
||||
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
export const recommendedReadRelays = new RelaySet([
|
||||
"wss://relay.damus.io/",
|
||||
"wss://nostr.wine/",
|
||||
"wss://relay.snort.social/",
|
||||
"wss://nos.lol/",
|
||||
]);
|
||||
export const recommendedWriteRelays = new RelaySet(["wss://relay.damus.io/", "wss://nos.lol/"]);
|
||||
|
||||
class ClientRelayService {
|
||||
readRelays = new PersistentSubject(new RelaySet());
|
||||
writeRelays = new PersistentSubject(new RelaySet());
|
||||
|
@ -1,11 +0,0 @@
|
||||
import accountService from "./account";
|
||||
import clientRelaysService from "./client-relays";
|
||||
import userAppSettings from "./settings/user-app-settings";
|
||||
import userMailboxesService from "./user-mailboxes";
|
||||
|
||||
accountService.current.subscribe((account) => {
|
||||
if (!account) return;
|
||||
const relays = clientRelaysService.readRelays.value;
|
||||
userMailboxesService.requestMailboxes(account.pubkey, relays, { alwaysRequest: true });
|
||||
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
|
||||
});
|
@ -1,9 +1,11 @@
|
||||
import { kinds, nip25 } from "nostr-tools";
|
||||
import { Filter, kinds, nip25 } from "nostr-tools";
|
||||
|
||||
import NostrRequest from "../classes/nostr-request";
|
||||
import Subject from "../classes/subject";
|
||||
import SuperMap from "../classes/super-map";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { localRelay } from "./local-relay";
|
||||
import { relayRequest } from "../helpers/relay";
|
||||
|
||||
type eventId = string;
|
||||
type relay = string;
|
||||
@ -24,7 +26,7 @@ class EventReactionsService {
|
||||
return subject;
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
handleEvent(event: NostrEvent, cache = true) {
|
||||
if (event.kind !== kinds.Reaction) return;
|
||||
const pointer = nip25.getReactedEventPointer(event);
|
||||
if (!pointer?.id) return;
|
||||
@ -35,6 +37,8 @@ class EventReactionsService {
|
||||
} else if (!subject.value.some((e) => e.id === event.id)) {
|
||||
subject.next([...subject.value, event]);
|
||||
}
|
||||
|
||||
if (cache) localRelay.publish(event);
|
||||
}
|
||||
|
||||
batchRequests() {
|
||||
@ -49,9 +53,14 @@ class EventReactionsService {
|
||||
}
|
||||
|
||||
for (const [relay, ids] of Object.entries(idsFromRelays)) {
|
||||
const filter: Filter = { "#e": ids, kinds: [kinds.Reaction] };
|
||||
|
||||
// load from local relay
|
||||
relayRequest(localRelay, [filter]).then((events) => events.forEach((e) => this.handleEvent(e)));
|
||||
|
||||
const request = new NostrRequest([relay]);
|
||||
request.onEvent.subscribe(this.handleEvent, this);
|
||||
request.start({ "#e": ids, kinds: [kinds.Reaction] });
|
||||
request.start(filter);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { kinds } from "nostr-tools";
|
||||
import { Filter, kinds } from "nostr-tools";
|
||||
|
||||
import NostrRequest from "../classes/nostr-request";
|
||||
import Subject from "../classes/subject";
|
||||
import SuperMap from "../classes/super-map";
|
||||
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
|
||||
import { NostrRequestFilter } from "../types/nostr-query";
|
||||
import { isHexKey } from "../helpers/nip19";
|
||||
import { relayRequest } from "../helpers/relay";
|
||||
import { localRelay } from "./local-relay";
|
||||
|
||||
type eventUID = string;
|
||||
type relay = string;
|
||||
@ -26,7 +27,7 @@ class EventZapsService {
|
||||
return subject;
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
handleEvent(event: NostrEvent, cache = true) {
|
||||
if (event.kind !== kinds.Zap) return;
|
||||
const eventUID = event.tags.find(isETag)?.[1] ?? event.tags.find(isATag)?.[1];
|
||||
if (!eventUID) return;
|
||||
@ -37,6 +38,8 @@ class EventZapsService {
|
||||
} else if (!subject.value.some((e) => e.id === event.id)) {
|
||||
subject.next([...subject.value, event]);
|
||||
}
|
||||
|
||||
if (cache) localRelay.publish(event);
|
||||
}
|
||||
|
||||
batchRequests() {
|
||||
@ -56,13 +59,12 @@ class EventZapsService {
|
||||
const eventIds = ids.filter(isHexKey);
|
||||
const coordinates = ids.filter((id) => id.includes(":"));
|
||||
|
||||
const queries: NostrRequestFilter = [];
|
||||
if (eventIds.length > 0) {
|
||||
queries.push({ "#e": eventIds, kinds: [kinds.Zap] });
|
||||
}
|
||||
if (coordinates.length > 0) {
|
||||
queries.push({ "#a": coordinates, kinds: [kinds.Zap] });
|
||||
}
|
||||
const queries: Filter[] = [];
|
||||
if (eventIds.length > 0) queries.push({ "#e": eventIds, kinds: [kinds.Zap] });
|
||||
if (coordinates.length > 0) queries.push({ "#a": coordinates, kinds: [kinds.Zap] });
|
||||
|
||||
// load from local relay
|
||||
relayRequest(localRelay, queries).then((events) => events.forEach((e) => this.handleEvent(e, false)));
|
||||
|
||||
request.start(queries);
|
||||
}
|
||||
|
4
src/services/offline-mode.ts
Normal file
4
src/services/offline-mode.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PersistentSubject } from "../classes/subject";
|
||||
|
||||
export const offlineMode = new PersistentSubject(localStorage.getItem("offline-mode") === "true");
|
||||
offlineMode.subscribe((v) => localStorage.setItem("offline-mode", v ? "true" : "false"));
|
@ -1,6 +1,7 @@
|
||||
import db from "./db";
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
import { isHexKey } from "../helpers/nip19";
|
||||
import { validateRelayURL } from "../helpers/relay";
|
||||
|
||||
export type RelayInformationDocument = {
|
||||
name: string;
|
||||
@ -22,7 +23,7 @@ function sanitizeInfo(info: RelayInformationDocument) {
|
||||
}
|
||||
|
||||
async function fetchInfo(relay: string) {
|
||||
const url = new URL(relay);
|
||||
const url = validateRelayURL(relay);
|
||||
url.protocol = url.protocol === "ws:" ? "http" : "https";
|
||||
|
||||
const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then(
|
||||
@ -39,11 +40,12 @@ async function fetchInfo(relay: string) {
|
||||
|
||||
const memoryCache = new Map<string, RelayInformationDocument>();
|
||||
async function getInfo(relay: string) {
|
||||
if (memoryCache.has(relay)) return memoryCache.get(relay)!;
|
||||
const url = validateRelayURL(relay).toString();
|
||||
if (memoryCache.has(url)) return memoryCache.get(url)!;
|
||||
|
||||
const cached = await db.get("relayInfo", relay);
|
||||
const cached = await db.get("relayInfo", url);
|
||||
if (cached) {
|
||||
memoryCache.set(relay, cached);
|
||||
memoryCache.set(url, cached);
|
||||
return cached as RelayInformationDocument;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import Relay from "../classes/relay";
|
||||
import Subject from "../classes/subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
import { normalizeRelayURL } from "../helpers/relay";
|
||||
import { safeRelayUrl, validateRelayURL } from "../helpers/relay";
|
||||
import { offlineMode } from "./offline-mode";
|
||||
|
||||
export class RelayPoolService {
|
||||
relays = new Map<string, Relay>();
|
||||
@ -13,23 +14,25 @@ export class RelayPoolService {
|
||||
getRelays() {
|
||||
return Array.from(this.relays.values());
|
||||
}
|
||||
getRelayClaims(url: string) {
|
||||
const normalized = normalizeRelayURL(url);
|
||||
if (!this.relayClaims.has(normalized)) {
|
||||
this.relayClaims.set(normalized, new Set());
|
||||
getRelayClaims(url: string | URL) {
|
||||
url = validateRelayURL(url);
|
||||
const key = url.toString();
|
||||
if (!this.relayClaims.has(key)) {
|
||||
this.relayClaims.set(key, new Set());
|
||||
}
|
||||
return this.relayClaims.get(normalized) as Set<any>;
|
||||
return this.relayClaims.get(key) as Set<any>;
|
||||
}
|
||||
|
||||
requestRelay(url: string, connect = true) {
|
||||
const normalized = normalizeRelayURL(url);
|
||||
if (!this.relays.has(normalized)) {
|
||||
const newRelay = new Relay(normalized);
|
||||
this.relays.set(normalized, newRelay);
|
||||
requestRelay(url: string | URL, connect = true) {
|
||||
url = validateRelayURL(url);
|
||||
const key = url.toString();
|
||||
if (!this.relays.has(key)) {
|
||||
const newRelay = new Relay(key);
|
||||
this.relays.set(key, newRelay);
|
||||
this.onRelayCreated.next(newRelay);
|
||||
}
|
||||
|
||||
const relay = this.relays.get(normalized) as Relay;
|
||||
const relay = this.relays.get(key) as Relay;
|
||||
if (connect && !relay.okay) {
|
||||
try {
|
||||
relay.open();
|
||||
@ -50,6 +53,8 @@ export class RelayPoolService {
|
||||
}
|
||||
}
|
||||
reconnectRelays() {
|
||||
if (offlineMode.value) return;
|
||||
|
||||
for (const [url, relay] of this.relays.entries()) {
|
||||
const claims = this.getRelayClaims(url).size;
|
||||
if (!relay.okay && claims > 0) {
|
||||
@ -64,13 +69,15 @@ export class RelayPoolService {
|
||||
}
|
||||
|
||||
// id can be anything
|
||||
addClaim(url: string, id: any) {
|
||||
const normalized = normalizeRelayURL(url);
|
||||
this.getRelayClaims(normalized).add(id);
|
||||
addClaim(url: string | URL, id: any) {
|
||||
url = validateRelayURL(url);
|
||||
const key = url.toString();
|
||||
this.getRelayClaims(key).add(id);
|
||||
}
|
||||
removeClaim(url: string, id: any) {
|
||||
const normalized = normalizeRelayURL(url);
|
||||
this.getRelayClaims(normalized).delete(id);
|
||||
removeClaim(url: string | URL, id: any) {
|
||||
url = validateRelayURL(url);
|
||||
const key = url.toString();
|
||||
this.getRelayClaims(key).delete(id);
|
||||
}
|
||||
|
||||
get connectedCount() {
|
||||
@ -97,6 +104,14 @@ document.addEventListener("visibilitychange", () => {
|
||||
}
|
||||
});
|
||||
|
||||
offlineMode.subscribe((offline) => {
|
||||
if (offline) {
|
||||
for (const [_, relay] of relayPoolService.relays) {
|
||||
relay.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.relayPoolService = relayPoolService;
|
||||
|
@ -5,7 +5,6 @@ import Subject from "../classes/subject";
|
||||
import SuperMap from "../classes/super-map";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import relayInfoService from "./relay-info";
|
||||
import { normalizeRelayURL } from "../helpers/relay";
|
||||
import { localRelay } from "./local-relay";
|
||||
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
|
||||
|
||||
@ -45,7 +44,6 @@ class RelayStatsService {
|
||||
}
|
||||
|
||||
requestSelfReported(relay: string) {
|
||||
relay = normalizeRelayURL(relay);
|
||||
const sub = this.selfReported.get(relay);
|
||||
|
||||
if (sub.value === undefined) {
|
||||
@ -62,7 +60,6 @@ class RelayStatsService {
|
||||
}
|
||||
|
||||
requestMonitorStats(relay: string) {
|
||||
relay = normalizeRelayURL(relay);
|
||||
const sub = this.monitorStats.get(relay);
|
||||
|
||||
if (sub.value === undefined) {
|
||||
|
@ -183,8 +183,10 @@ class ReplaceableEventLoaderService {
|
||||
private async readFromCache() {
|
||||
if (this.readFromCachePromises.size === 0) return;
|
||||
|
||||
const loading = new Map<string, Deferred<boolean>>();
|
||||
|
||||
const kindFilters: Record<number, Filter> = {};
|
||||
for (const [cord] of this.readFromCachePromises) {
|
||||
for (const [cord, p] of this.readFromCachePromises) {
|
||||
const [kindStr, pubkey, d] = cord.split(":") as [string, string] | [string, string, string];
|
||||
const kind = parseInt(kindStr);
|
||||
kindFilters[kind] = kindFilters[kind] || { kinds: [kind] };
|
||||
@ -196,34 +198,35 @@ class ReplaceableEventLoaderService {
|
||||
const arr = (kindFilters[kind]["#d"] = kindFilters[kind]["#d"] || []);
|
||||
arr.push(d);
|
||||
}
|
||||
|
||||
loading.set(cord, p);
|
||||
}
|
||||
const filters = Array.from(Object.values(kindFilters));
|
||||
|
||||
for (const [cord] of loading) this.readFromCachePromises.delete(cord);
|
||||
|
||||
const events = await relayRequest(localRelay, filters);
|
||||
for (const event of events) {
|
||||
this.handleEvent(event, false);
|
||||
const cord = getEventCoordinate(event);
|
||||
const promise = this.readFromCachePromises.get(cord);
|
||||
const promise = loading.get(cord);
|
||||
if (promise) promise.resolve(true);
|
||||
this.readFromCachePromises.delete(cord);
|
||||
loading.delete(cord);
|
||||
}
|
||||
|
||||
// resolve remaining promises
|
||||
for (const [_, promise] of this.readFromCachePromises) promise.resolve();
|
||||
this.readFromCachePromises.clear();
|
||||
for (const [_, promise] of loading) promise.resolve();
|
||||
|
||||
if (events.length > 0) this.dbLog(`Read ${events.length} events from database`);
|
||||
}
|
||||
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
||||
loadFromCache(cord: string) {
|
||||
const dedupe = this.loadCacheDedupe.get(cord);
|
||||
const dedupe = this.readFromCachePromises.get(cord);
|
||||
if (dedupe) return dedupe;
|
||||
|
||||
// add to read queue
|
||||
const promise = createDefer<boolean>();
|
||||
this.readFromCachePromises.set(cord, promise);
|
||||
|
||||
this.loadCacheDedupe.set(cord, promise);
|
||||
this.readFromCacheThrottle();
|
||||
|
||||
return promise;
|
||||
|
@ -71,12 +71,12 @@ class UserContactsService {
|
||||
return sub;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
receiveEvent(event: NostrEvent) {
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
const userContactsService = new UserContactsService();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
41
src/services/user-event-sync.ts
Normal file
41
src/services/user-event-sync.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { COMMON_CONTACT_RELAY } from "../const";
|
||||
import { logger } from "../helpers/debug";
|
||||
import accountService from "./account";
|
||||
import clientRelaysService from "./client-relays";
|
||||
import { offlineMode } from "./offline-mode";
|
||||
import userAppSettings from "./settings/user-app-settings";
|
||||
import userContactsService from "./user-contacts";
|
||||
import userMailboxesService from "./user-mailboxes";
|
||||
import userMetadataService from "./user-metadata";
|
||||
|
||||
const log = logger.extend("user-event-sync");
|
||||
|
||||
function loadContactsList() {
|
||||
const account = accountService.current.value!;
|
||||
|
||||
log("Loading contacts list");
|
||||
userContactsService.requestContacts(account.pubkey, [...clientRelaysService.readRelays.value, COMMON_CONTACT_RELAY], {
|
||||
alwaysRequest: true,
|
||||
});
|
||||
}
|
||||
|
||||
function downloadEvents() {
|
||||
const account = accountService.current.value!;
|
||||
const relays = clientRelaysService.readRelays.value;
|
||||
|
||||
log("Loading user information");
|
||||
userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
|
||||
userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
|
||||
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
|
||||
|
||||
loadContactsList();
|
||||
}
|
||||
|
||||
accountService.current.subscribe((account) => {
|
||||
if (!account) return;
|
||||
downloadEvents();
|
||||
});
|
||||
|
||||
offlineMode.subscribe((offline) => {
|
||||
if (!offline && accountService.current.value) downloadEvents();
|
||||
});
|
@ -10,7 +10,7 @@ import replaceableEventLoaderService, { RequestOptions } from "./replaceable-eve
|
||||
const WRITE_USER_SEARCH_BATCH_TIME = 500;
|
||||
|
||||
class UserMetadataService {
|
||||
private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
|
||||
private metadata = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
|
||||
const sub = new Subject<Kind0ParsedContent>();
|
||||
sub.subscribe((metadata) => {
|
||||
if (metadata) {
|
||||
@ -21,10 +21,10 @@ class UserMetadataService {
|
||||
return sub;
|
||||
});
|
||||
getSubject(pubkey: string) {
|
||||
return this.parsedSubjects.get(pubkey);
|
||||
return this.metadata.get(pubkey);
|
||||
}
|
||||
requestMetadata(pubkey: string, relays: Iterable<string>, opts: RequestOptions = {}) {
|
||||
const sub = this.parsedSubjects.get(pubkey);
|
||||
const sub = this.metadata.get(pubkey);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, kinds.Metadata, pubkey, undefined, opts);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
|
||||
return sub;
|
||||
|
@ -12,12 +12,15 @@ import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { isValidRelayURL } from "../../helpers/relay";
|
||||
import { useReadRelays, useWriteRelays } from "../../hooks/use-client-relays";
|
||||
import { offlineMode } from "../../services/offline-mode";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
|
||||
export default function RelaysView() {
|
||||
const [search, setSearch] = useState("");
|
||||
const deboundedSearch = useDeferredValue(search);
|
||||
const isSearching = deboundedSearch.length > 2;
|
||||
const addRelayModal = useDisclosure();
|
||||
const offline = useSubject(offlineMode);
|
||||
|
||||
const readRelays = useReadRelays();
|
||||
const writeRelays = useWriteRelays();
|
||||
@ -43,6 +46,7 @@ export default function RelaysView() {
|
||||
<VerticalPageLayout>
|
||||
<Flex alignItems="center" gap="2" wrap="wrap">
|
||||
<Input type="search" placeholder="search" value={search} onChange={(e) => setSearch(e.target.value)} w="auto" />
|
||||
{!offline && <Button onClick={() => offlineMode.next(true)}>Offline</Button>}
|
||||
<Spacer />
|
||||
<Button as={RouterLink} to="/relays/popular">
|
||||
Popular Relays
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react";
|
||||
import { Divider, Flex, Heading, SimpleGrid, Switch } from "@chakra-ui/react";
|
||||
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
@ -19,11 +19,13 @@ import { useAppTitle } from "../../hooks/use-app-title";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||
import { useRouteStateBoolean } from "../../hooks/use-route-state-value";
|
||||
|
||||
function StreamsPage() {
|
||||
useAppTitle("Streams");
|
||||
const relays = useRelaySelectionRelays();
|
||||
const userMuteFilter = useClientSideMuteFilter();
|
||||
const showEnded = useRouteStateBoolean("ended", false);
|
||||
|
||||
const eventFilter = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
@ -56,8 +58,11 @@ function StreamsPage() {
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex gap="2" wrap="wrap" alignItems="center">
|
||||
<PeopleListSelection />
|
||||
<Switch checked={showEnded.isOpen} onChange={showEnded.onToggle}>
|
||||
Show Ended
|
||||
</Switch>
|
||||
<RelaySelectionButton ml="auto" />
|
||||
</Flex>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
@ -69,14 +74,18 @@ function StreamsPage() {
|
||||
<StreamCard key={stream.event.id} stream={stream} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Heading size="lg" mt="4">
|
||||
Ended
|
||||
</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||
{endedStreams.map((stream) => (
|
||||
<StreamCard key={stream.event.id} stream={stream} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{showEnded.isOpen && (
|
||||
<>
|
||||
<Heading size="lg" mt="4">
|
||||
Ended
|
||||
</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||
{endedStreams.map((stream) => (
|
||||
<StreamCard key={stream.event.id} stream={stream} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</IntersectionObserverProvider>
|
||||
</VerticalPageLayout>
|
||||
|
@ -5261,10 +5261,10 @@ normalize-package-data@^2.5.0:
|
||||
semver "2 || 3 || 4 || 5"
|
||||
validate-npm-package-license "^3.0.1"
|
||||
|
||||
nostr-idb@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.0.0.tgz#0fdce309613ffb19d12de64d3ffb050e0c7df208"
|
||||
integrity sha512-qLflqSVaK02ClRXMUdNMOd6DRJZALgoHx+Y8Y+TOnnBc4utq8akPrsdjkCit2+N02PklcJh2kCIZSP9fl7cHJw==
|
||||
nostr-idb@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.0.1.tgz#701cb0b69f93bbcdb55bb746920767cf0026a7c7"
|
||||
integrity sha512-4FT9cmfq2FYWfMO+0DrkxeS7/aI3mcijBxuVQalx17se0nVkTVXV1WT21sM5eaCBZOQld41LkwBGc4lyXhktsw==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
idb "^8.0.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user