make app settings use query store

This commit is contained in:
hzrd149 2024-10-11 14:34:55 +01:00
parent 60b61e96b8
commit bb855a2f8f
27 changed files with 275 additions and 254 deletions

37
pnpm-lock.yaml generated
View File

@ -94,13 +94,13 @@ importers:
version: 0.6.0(typescript@5.6.2)
applesauce-core:
specifier: ^0.6.0
version: 0.6.0(typescript@5.6.2)
version: link:../applesauce/packages/core
applesauce-react:
specifier: ^0.6.0
version: 0.6.0(typescript@5.6.2)
version: link:../applesauce/packages/react
applesauce-signer:
specifier: ^0.6.0
version: 0.6.0(typescript@5.6.2)
version: link:../applesauce/packages/signer
bech32:
specifier: ^2.0.0
version: 2.0.0
@ -2703,14 +2703,6 @@ packages:
resolution:
{ integrity: sha512-23W/7P0hzjVGIp51Yp4ppJgbDFNtrJrF3HO3/M36/z+Msdb3HKiSXmt3bMxEVMY6nJTT+zWneq7mAd7VvwhWaA== }
applesauce-react@0.6.0:
resolution:
{ integrity: sha512-T8fd7ImkrSe0o+FjC5AoLsYeqlLWq5bE5evwavR2+NTLC9CW185qKPPIzbTDrakq4hPADV8by3AOCmD+MY1Riw== }
applesauce-signer@0.6.0:
resolution:
{ integrity: sha512-BunnObvSqIBJ04MMQnpXIflfYEAwqWROCJqQrFZXHy+yXmZjHzPVMkXQLcJ7oXoNVc4CaxgJwp7w8eSmohJKFg== }
argparse@1.0.10:
resolution:
{ integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== }
@ -8301,29 +8293,6 @@ snapshots:
- supports-color
- typescript
applesauce-react@0.6.0(typescript@5.6.2):
dependencies:
applesauce-core: 0.6.0(typescript@5.6.2)
nostr-tools: 2.7.2(typescript@5.6.2)
react: 18.3.1
zen-observable: 0.10.0
transitivePeerDependencies:
- supports-color
- typescript
applesauce-signer@0.6.0(typescript@5.6.2):
dependencies:
"@noble/hashes": 1.5.0
"@noble/secp256k1": 1.7.1
"@scure/base": 1.1.9
"@types/dom-serial": 1.0.6
applesauce-core: 0.6.0(typescript@5.6.2)
debug: 4.3.7
nostr-tools: 2.7.2(typescript@5.6.2)
transitivePeerDependencies:
- supports-color
- typescript
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3

View File

@ -1,10 +1,10 @@
import { AppSettings } from "../../services/settings/migrations";
import { AppSettings } from "../../helpers/app-settings";
import { Nip07Interface } from "applesauce-signer";
export class Account {
readonly type: string = "unknown";
pubkey: string;
localSettings?: AppSettings;
localSettings?: Partial<AppSettings>;
protected _signer?: Nip07Interface | undefined;
public get signer(): Nip07Interface | undefined {

View File

@ -6,7 +6,6 @@ import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import { TrustProvider } from "../../../providers/local/trust-provider";
import { NoteLink } from "../../note/note-link";
@ -17,9 +16,10 @@ import HoverLinkOverlay from "../../hover-link-overlay";
import singleEventService from "../../../services/single-event";
import relayHintService from "../../../services/event-relay-hint";
import localSettings from "../../../services/local-settings";
import useAppSettings from "../../../hooks/use-app-settings";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
const { showSignatureVerification } = useAppSettings();
const enableDrawer = useSubject(localSettings.enableNoteThreadDrawer);
const navigate = enableDrawer ? useNavigateInDrawer() : useNavigate();
const to = `/n/${relayHintService.getSharableEventAddress(event)}`;

View File

@ -4,8 +4,6 @@ import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import { TrustProvider } from "../../../providers/local/trust-provider";
import Timestamp from "../../timestamp";
@ -17,13 +15,14 @@ import { getTorrentTitle } from "../../../helpers/nostr/torrents";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import { MouseEventHandler, useCallback } from "react";
import { nip19 } from "nostr-tools";
import useAppSettings from "../../../hooks/use-app-settings";
export default function EmbeddedTorrentComment({
comment,
...props
}: Omit<CardProps, "children"> & { comment: NostrEvent }) {
const navigate = useNavigateInDrawer();
const { showSignatureVerification } = useSubject(appSettings);
const { showSignatureVerification } = useAppSettings();
const refs = getThreadReferences(comment);
const torrent = useSingleEvent(refs.root?.e?.id, refs.root?.e?.relays);
const linkToTorrent = refs.root?.e && `/torrents/${nip19.neventEncode(refs.root.e)}`;

View File

@ -21,13 +21,14 @@ import { EmbedProps } from "../embed-event";
import userMailboxesService from "../../services/user-mailboxes";
import InputStep from "./input-step";
import lnurlMetadataService from "../../services/lnurl-metadata";
import userMetadataService from "../../services/user-metadata";
import signingService from "../../services/signing";
import accountService from "../../services/account";
import PayStep from "./pay-step";
import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl";
import UserLink from "../user/user-link";
import relayHintService from "../../services/event-relay-hint";
import { queryStore } from "../../services/event-store";
import { getValue } from "applesauce-core/observable";
export type PayRequest = { invoice?: string; pubkey: string; error?: any };
@ -38,7 +39,8 @@ async function getPayRequestForPubkey(
comment?: string,
additionalRelays?: Iterable<string>,
): Promise<PayRequest> {
const metadata = userMetadataService.getSubject(pubkey).value;
const metadata = await getValue(queryStore.profile(pubkey));
if (!metadata) throw new Error("Cant find user metadata");
const address = metadata?.lud16 || metadata?.lud06;
if (!address) throw new Error("User missing lightning address");
const lnurlMetadata = await lnurlMetadataService.requestMetadata(address);

View File

@ -7,7 +7,7 @@ import UserLink from "../user/user-link";
import { ChevronDownIcon, ChevronUpIcon, CheckIcon, ErrorIcon, LightningIcon } from "../icons";
import { InvoiceModalContent } from "../invoice-modal";
import { PropsWithChildren, useEffect, useState } from "react";
import appSettings from "../../services/settings/app-settings";
import useAppSettings from "../../hooks/use-app-settings";
function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string }) {
return (
@ -79,6 +79,7 @@ function ErrorCard({ pubkey, error }: { pubkey: string; error: any }) {
export default function PayStep({ callbacks, onComplete }: { callbacks: PayRequest[]; onComplete: () => void }) {
const [paid, setPaid] = useState<string[]>([]);
const { autoPayWithWebLN } = useAppSettings();
const [payingAll, setPayingAll] = useState(false);
const payAllWithWebLN = async () => {
@ -99,14 +100,16 @@ export default function PayStep({ callbacks, onComplete }: { callbacks: PayReque
};
useEffect(() => {
if (!callbacks.filter((p) => !!p.invoice).some(({ pubkey }) => !paid.includes(pubkey))) {
const withInvoice = callbacks.filter((p) => !!p.invoice);
const hasUnpaid = withInvoice.some(({ pubkey }) => !paid.includes(pubkey));
if (withInvoice.length > 0 && !hasUnpaid) {
onComplete();
}
}, [paid]);
// if autoPayWithWebLN is enabled, try to pay all immediately
useMount(() => {
if (appSettings.value.autoPayWithWebLN) {
if (autoPayWithWebLN) {
payAllWithWebLN();
}
});

View File

@ -1,9 +1,9 @@
import { CSSProperties } from "react";
import { Box, useColorMode } from "@chakra-ui/react";
import { EmbedEventPointer } from "../../embed-event";
import appSettings from "../../../services/settings/app-settings";
import { STEMSTR_RELAY } from "../../../helpers/nostr/stemstr";
import ExpandableEmbed from "../expandable-embed";
import useAppSettings from "../../../hooks/use-app-settings";
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
@ -137,8 +137,8 @@ export function renderStemstrUrl(match: URL) {
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: [STEMSTR_RELAY] } }} />;
}
export function renderSoundCloudUrl(match: URL) {
if (match.hostname !== "soundcloud.com" || match.pathname.split("/").length !== 3) return null;
function SoundCloudEmbed({ match }: { match: URL }) {
const { primaryColor } = useAppSettings();
return (
<iframe
@ -150,9 +150,14 @@ export function renderSoundCloudUrl(match: URL) {
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
match.protocol + match.host + match.pathname,
)}&color=${encodeURIComponent(
"#" + appSettings.value.primaryColor || "ff5500",
"#" + primaryColor || "ff5500",
)}&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true`}
style={setZIndex}
></iframe>
);
}
export function renderSoundCloudUrl(match: URL) {
if (match.hostname !== "soundcloud.com" || match.pathname.split("/").length !== 3) return null;
return <SoundCloudEmbed match={match} />;
}

View File

@ -1,5 +1,5 @@
import { replaceDomain } from "../../../helpers/url";
import appSettings from "../../../services/settings/app-settings";
import useAppSettings from "../../../hooks/use-app-settings";
import { renderGenericUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/reddit.js
@ -13,13 +13,17 @@ const REDDIT_DOMAINS = [
"old.reddit.com",
];
function RedditLink({ url }: { url: URL }) {
const { redditRedirect } = useAppSettings();
const fixed = redditRedirect ? replaceDomain(url, redditRedirect) : url;
return renderGenericUrl(fixed);
}
const bypassPaths = /\/(gallery\/poll\/rpan\/settings\/topics)/;
export function renderRedditUrl(match: URL) {
if (!REDDIT_DOMAINS.includes(match.hostname)) return null;
if (match.pathname.match(bypassPaths)) return null;
const { redditRedirect } = appSettings.value;
const fixed = redditRedirect ? replaceDomain(match, redditRedirect) : match;
return renderGenericUrl(fixed);
return <RedditLink url={match} />;
}

View File

@ -1,14 +1,18 @@
import { replaceDomain } from "../../../helpers/url";
import appSettings from "../../../services/settings/app-settings";
import useAppSettings from "../../../hooks/use-app-settings";
import { renderOpenGraphUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
export const TWITTER_DOMAINS = ["x.com", "twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
function TwitterLink({ url, isLineEnd }: { url: URL; isLineEnd?: boolean }) {
const { twitterRedirect } = useAppSettings();
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(url, twitterRedirect), !!isLineEnd);
else return renderOpenGraphUrl(url, !!isLineEnd);
}
export function renderTwitterUrl(match: URL, isLineEnd: boolean) {
if (!TWITTER_DOMAINS.includes(match.hostname)) return null;
const { twitterRedirect } = appSettings.value;
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(match, twitterRedirect), isLineEnd);
else return renderOpenGraphUrl(match, isLineEnd);
return <TwitterLink url={match} isLineEnd={isLineEnd} />;
}

View File

@ -1,6 +1,6 @@
import { AspectRatio } from "@chakra-ui/react";
import appSettings from "../../../services/settings/app-settings";
import ExpandableEmbed from "../expandable-embed";
import useAppSettings from "../../../hooks/use-app-settings";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/youtube.js
export const YOUTUBE_DOMAINS = [
@ -15,20 +15,15 @@ export const YOUTUBE_DOMAINS = [
"music.youtube.com",
];
export function renderYoutubePlaylistURL(match: URL) {
if (!YOUTUBE_DOMAINS.includes(match.hostname)) return null;
if (!match.pathname.startsWith("/playlist")) return null;
const { youtubeRedirect } = appSettings.value;
const listId = match.searchParams.get("list");
if (!listId) return null;
function YoutubePlaylistEmbed({ url }: { url: URL }) {
const { youtubeRedirect } = useAppSettings();
const listId = url.searchParams.get("list")!;
const embedUrl = new URL(`embed/videoseries`, youtubeRedirect || "https://www.youtube-nocookie.com");
embedUrl.searchParams.set("list", listId);
return (
<ExpandableEmbed label="Youtube Playlist" url={match}>
<ExpandableEmbed label="Youtube Playlist" url={url}>
<AspectRatio ratio={560 / 315} maxWidth="40rem" zIndex={1} position="relative">
<iframe
src={embedUrl.toString()}
@ -42,20 +37,26 @@ export function renderYoutubePlaylistURL(match: URL) {
);
}
export function renderYoutubeVideoURL(match: URL) {
export function renderYoutubePlaylistURL(match: URL) {
if (!YOUTUBE_DOMAINS.includes(match.hostname)) return null;
if (match.pathname.startsWith("/live")) return null;
if (!match.pathname.startsWith("/playlist")) return null;
const { youtubeRedirect } = appSettings.value;
const listId = match.searchParams.get("list");
if (!listId) return null;
var videoId = match.searchParams.get("v");
if (match.hostname === "youtu.be") videoId = match.pathname.split("/")[1];
return <YoutubePlaylistEmbed url={match} />;
}
function YoutubeVideoEmbed({ url }: { url: URL }) {
const { youtubeRedirect } = useAppSettings();
var videoId = url.searchParams.get("v");
if (url.hostname === "youtu.be") videoId = url.pathname.split("/")[1];
if (!videoId) return null;
const embedUrl = new URL(`/embed/${videoId}`, youtubeRedirect || "https://www.youtube-nocookie.com");
return (
<ExpandableEmbed label="Youtube" url={match}>
<ExpandableEmbed label="Youtube" url={url}>
<AspectRatio ratio={16 / 10} maxWidth="40rem" zIndex={1} position="relative">
<iframe
src={embedUrl.toString()}
@ -69,12 +70,21 @@ export function renderYoutubeVideoURL(match: URL) {
);
}
export function renderYoutubeVideoURL(match: URL) {
if (!YOUTUBE_DOMAINS.includes(match.hostname)) return null;
if (match.pathname.startsWith("/live")) return null;
var videoId = match.searchParams.get("v");
if (match.hostname === "youtu.be") videoId = match.pathname.split("/")[1];
if (!videoId) return null;
return <YoutubeVideoEmbed url={match} />;
}
// nostr:nevent1qqszwj6mk665ga4r25w5vzxmy9rsvqj42kk4gnkq2t2utljr6as948qpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk245xvyn
export function renderYoutubeURL(match: URL) {
if (!YOUTUBE_DOMAINS.includes(match.hostname)) return null;
if (match.pathname.startsWith("/live")) return null;
const { youtubeRedirect } = appSettings.value;
return renderYoutubePlaylistURL(match) || renderYoutubeVideoURL(match);
}

View File

@ -22,7 +22,6 @@ import UserLink from "../../user/user-link";
import NoteZapButton from "../note-zap-button";
import { ExpandProvider } from "../../../providers/local/expanded";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import RepostButton from "./components/repost-button";
import QuoteEventButton from "../quote-event-button";
@ -48,6 +47,7 @@ import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import relayHintService from "../../../services/event-relay-hint";
import localSettings from "../../../services/local-settings";
import NotePublishedUsing from "../note-published-using";
import useAppSettings from "../../../hooks/use-app-settings";
export type TimelineNoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
@ -69,7 +69,7 @@ export function TimelineNote({
...props
}: TimelineNoteProps) {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const { showReactions, showSignatureVerification } = useAppSettings();
const hideZapBubbles = useSubject(localSettings.hideZapBubbles);
const replyForm = useDisclosure();

View File

@ -1,6 +1,6 @@
import { ColorModeWithSystem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { safeJson } from "../../helpers/parse";
import { NostrEvent } from "../types/nostr-event";
import { safeJson } from "./parse";
export type AppSettingsV0 = {
version: 0;

View File

@ -1,5 +1,8 @@
import { fixOrientationAndStripMetadata } from "../lib/fix-image-orientation";
import appSettings from "../services/settings/app-settings";
import AppSettingsQuery from "../queries/app-settings";
import accountService from "../services/account";
import { queryStore } from "../services/event-store";
import { AppSettings } from "./app-settings";
export type ImageSize = { width: number; height: number };
const imageSizeCache = new Map<string, ImageSize>();
@ -21,12 +24,23 @@ export function getImageSize(src: string): Promise<{ width: number; height: numb
});
}
// hack to get app settings
let settings: AppSettings | undefined;
let sub: ZenObservable.Subscription;
accountService.current.subscribe((account) => {
if (sub) sub.unsubscribe();
if (!account) return;
sub = queryStore
.runQuery(AppSettingsQuery)(account.pubkey)
.subscribe((v) => (settings = v));
});
export function buildImageProxyURL(src: string, size: string | number) {
let url: URL | null = null;
if (window.IMAGE_PROXY_PATH) {
url = new URL(location.origin);
url.pathname = window.IMAGE_PROXY_PATH;
} else if (appSettings.value.imageProxy) url = new URL(appSettings.value.imageProxy);
} else if (settings?.imageProxy) url = new URL(settings.imageProxy);
if (url === null) return;
url.pathname = url.pathname.replace(/\/$/, "") + "/" + size + "/" + src;

View File

@ -1,12 +1,26 @@
import appSettings from "../services/settings/app-settings";
import AppSettingsQuery from "../queries/app-settings";
import accountService from "../services/account";
import { queryStore } from "../services/event-store";
import { AppSettings } from "./app-settings";
import { convertToUrl } from "./url";
// hack to get app settings
let settings: AppSettings | undefined;
let sub: ZenObservable.Subscription;
accountService.current.subscribe((account) => {
if (sub) sub.unsubscribe();
if (!account) return;
sub = queryStore
.runQuery(AppSettingsQuery)(account.pubkey)
.subscribe((v) => (settings = v));
});
const clearNetFailedHosts = new Set();
const proxyFailedHosts = new Set();
export function createRequestProxyUrl(url: URL | string, corsProxy?: string) {
if (!corsProxy && window.REQUEST_PROXY) corsProxy = new URL(window.REQUEST_PROXY, location.origin).toString();
if (!corsProxy && appSettings.value.corsProxy) corsProxy = appSettings.value.corsProxy;
if (!corsProxy && settings?.corsProxy) corsProxy = settings.corsProxy;
if (!corsProxy) return url;
if (corsProxy.includes("<url>")) {
@ -19,7 +33,7 @@ export function createRequestProxyUrl(url: URL | string, corsProxy?: string) {
}
export function fetchWithProxy(url: URL | string, opts?: RequestInit) {
if (!appSettings.value.corsProxy && !window.REQUEST_PROXY) return fetch(url, opts);
if (!settings?.corsProxy && !window.REQUEST_PROXY) return fetch(url, opts);
const u = typeof url === "string" ? convertToUrl(url) : url;

View File

@ -1,35 +1,42 @@
import { useCallback } from "react";
import { useToast } from "@chakra-ui/react";
import { useCallback, useEffect } from "react";
import appSettings from "../services/settings/app-settings";
import useSubject from "./use-subject";
import { AppSettings } from "../services/settings/migrations";
import { AppSettings, defaultSettings } from "../helpers/app-settings";
import useCurrentAccount from "./use-current-account";
import accountService from "../services/account";
import userAppSettings from "../services/settings/user-app-settings";
import userAppSettings from "../services/user-app-settings";
import { usePublishEvent } from "../providers/global/publish-provider";
import { useStoreQuery } from "applesauce-react";
import AppSettingsQuery from "../queries/app-settings";
import { useReadRelays } from "./use-client-relays";
export default function useAppSettings() {
const account = useCurrentAccount();
const settings = useSubject(appSettings);
const publish = usePublishEvent();
const localSettings = account?.localSettings;
const syncedSettings = useStoreQuery(AppSettingsQuery, account && [account.pubkey]);
const readRelays = useReadRelays();
useEffect(() => {
if (account?.pubkey) userAppSettings.requestAppSettings(account.pubkey, readRelays);
}, [account?.pubkey, readRelays]);
const updateSettings = useCallback(
async (newSettings: Partial<AppSettings>) => {
if (!account) return;
const full: AppSettings = { ...settings, ...newSettings };
const updated: Partial<AppSettings> = { ...syncedSettings, ...newSettings };
if (account.readonly) {
accountService.updateAccountLocalSettings(account.pubkey, full);
appSettings.next(full);
} else {
const draft = userAppSettings.buildAppSettingsEvent(full);
accountService.updateAccountLocalSettings(account.pubkey, updated);
if (!account.readonly) {
const draft = userAppSettings.buildAppSettingsEvent(updated);
await publish("Update Settings", draft);
}
},
[settings, account, publish],
[syncedSettings, account, publish],
);
const settings: AppSettings = { ...defaultSettings, ...localSettings, ...syncedSettings };
return {
...settings,
updateSettings,

View File

@ -1,12 +1,11 @@
import { useColorMode } from "@chakra-ui/react";
import useSubject from "./use-subject";
import appSettings from "../services/settings/app-settings";
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import useAppSettings from "./use-app-settings";
export default function useSetColorMode() {
const { setColorMode } = useColorMode();
const { colorMode } = useSubject(appSettings);
const { colorMode } = useAppSettings();
const [params] = useSearchParams();
useEffect(() => {

View File

@ -14,8 +14,7 @@ import PublishProvider from "./publish-provider";
import WebOfTrustProvider from "./web-of-trust-provider";
import { queryStore } from "../../services/event-store";
// Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
function ThemeProviders({ children }: { children: React.ReactNode }) {
const { theme: themeName, primaryColor, maxPageWidth } = useAppSettings();
const theme = useMemo(
() => buildTheme(themeName, primaryColor, maxPageWidth !== "none" ? maxPageWidth : undefined),
@ -24,25 +23,32 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
return (
<ChakraProvider theme={theme} colorModeManager={localStorageManager}>
<BreakpointProvider>
<QueryStoreProvider store={queryStore}>
<SigningProvider>
<PublishProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>
</SigningProvider>
</QueryStoreProvider>
</BreakpointProvider>
<BreakpointProvider>{children}</BreakpointProvider>
</ChakraProvider>
);
}
// Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
return (
<QueryStoreProvider store={queryStore}>
<ThemeProviders>
<SigningProvider>
<PublishProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>
</SigningProvider>
</ThemeProviders>
</QueryStoreProvider>
);
};

View File

@ -1,7 +1,7 @@
import AppHandlerProvider from "./app-handler-provider";
import DebugModalProvider from "./debug-modal-provider";
import DeleteEventProvider from "./delete-event-provider";
import InvoiceModalProvider from "./invoice-modal";
import InvoiceModalProvider from "./invoice-modal-provider";
import MuteModalProvider from "./mute-modal-provider";
import PostModalProvider from "./post-modal-provider";
import RequireReadRelays from "./require-read-relays";

View File

@ -1,7 +1,7 @@
import React, { useCallback, useContext, useState } from "react";
import InvoiceModal from "../../components/invoice-modal";
import createDefer, { Deferred } from "../../classes/deferred";
import appSettings from "../../services/settings/app-settings";
import useAppSettings from "../../hooks/use-app-settings";
export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>;
@ -20,9 +20,10 @@ export function useInvoiceModalContext() {
export default function InvoiceModalProvider({ children }: { children: React.ReactNode }) {
const [invoice, setInvoice] = useState<string>();
const [defer, setDefer] = useState<Deferred<void>>();
const { autoPayWithWebLN } = useAppSettings();
const requestPay = useCallback(async (invoice: string) => {
if (window.webln && appSettings.value.autoPayWithWebLN) {
if (window.webln && autoPayWithWebLN) {
try {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);

View File

@ -0,0 +1,17 @@
import { Query } from "applesauce-core";
import { AppSettings, defaultSettings } from "../helpers/app-settings";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "../services/user-app-settings";
import { safeJson } from "../helpers/parse";
export default function AppSettingsQuery(pubkey: string): Query<AppSettings> {
return {
key: pubkey,
run: (events) =>
events.replaceable(APP_SETTINGS_KIND, pubkey, APP_SETTING_IDENTIFIER).map((event) => {
if (!event) return defaultSettings;
const parsed = safeJson(event.content, defaultSettings) as Partial<AppSettings>;
return { ...defaultSettings, ...parsed };
}),
};
}

View File

@ -9,7 +9,7 @@ import SerialPortAccount from "../classes/accounts/serial-port-account";
import { PersistentSubject } from "../classes/subject";
import { logger } from "../helpers/debug";
import db from "./db";
import { AppSettings } from "./settings/migrations";
import { AppSettings } from "../helpers/app-settings";
type CommonAccount = {
pubkey: string;
@ -119,7 +119,7 @@ class AccountService {
return db.put("accounts", account.toJSON());
}
updateAccountLocalSettings(pubkey: string, settings: AppSettings) {
updateAccountLocalSettings(pubkey: string, settings: Partial<AppSettings>) {
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
if (account) account.localSettings = settings;
}

View File

@ -2,7 +2,7 @@ import { DBSchema } from "idb";
import { NostrEvent } from "../../types/nostr-event";
import { RelayInformationDocument } from "../relay-info";
import { AppSettings } from "../settings/migrations";
import { AppSettings } from "../../helpers/app-settings";
export interface SchemaV1 {
userMetadata: {

View File

@ -1,47 +0,0 @@
import { PersistentSubject } from "../../classes/subject";
import accountService from "../account";
import userAppSettings from "./user-app-settings";
import clientRelaysService from "../client-relays";
import { defaultSettings } from "./migrations";
import { logger } from "../../helpers/debug";
const log = logger.extend("AppSettings");
export let appSettings = new PersistentSubject(defaultSettings);
appSettings.subscribe((event) => {
log(`Changed`, event);
});
let accountSub: ZenObservable.Subscription;
accountService.current.subscribe(() => {
const account = accountService.current.value;
if (!account) {
appSettings.next(defaultSettings);
return;
}
if (accountSub) accountSub.unsubscribe();
if (account.localSettings) {
appSettings.next(account.localSettings);
log("Loaded user settings from local storage");
}
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.readRelays.value, {
alwaysRequest: true,
});
appSettings.next(subject.value || defaultSettings);
accountSub = subject.subscribe((s) => appSettings.next(s));
});
// clientRelaysService.relays.subscribe(() => {
// // relays changed, look for settings again
// const account = accountService.current.value;
// if (account) {
// userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getInboxURLs(), { alwaysRequest: true });
// }
// });
export default appSettings;

View File

@ -1,50 +0,0 @@
import dayjs from "dayjs";
import { DraftNostrEvent } from "../../types/nostr-event";
import SuperMap from "../../classes/super-map";
import { PersistentSubject } from "../../classes/subject";
import { AppSettings, defaultSettings, parseAppSettings } from "./migrations";
import replaceableEventsService, { RequestOptions } from "../replaceable-events";
export const APP_SETTINGS_KIND = 30078;
export const SETTING_EVENT_IDENTIFIER = "nostrudel-settings";
class UserAppSettings {
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
() => new PersistentSubject<AppSettings>(defaultSettings),
);
getSubject(pubkey: string) {
return this.parsedSubjects.get(pubkey);
}
requestAppSettings(pubkey: string, relays: Iterable<string>, opts?: RequestOptions) {
const sub = this.parsedSubjects.get(pubkey);
const requestSub = replaceableEventsService.requestEvent(
relays,
APP_SETTINGS_KIND,
pubkey,
SETTING_EVENT_IDENTIFIER,
opts,
);
sub.connectWithMapper(requestSub, (event, next) => next(parseAppSettings(event)));
return sub;
}
buildAppSettingsEvent(settings: AppSettings): DraftNostrEvent {
return {
kind: APP_SETTINGS_KIND,
tags: [["d", SETTING_EVENT_IDENTIFIER]],
content: JSON.stringify(settings),
created_at: dayjs().unix(),
};
}
}
const userAppSettings = new UserAppSettings();
if (import.meta.env.DEV) {
// @ts-ignore
window.userAppSettings = userAppSettings;
}
export default userAppSettings;

View File

@ -0,0 +1,44 @@
import dayjs from "dayjs";
import { DraftNostrEvent } from "../types/nostr-event";
import SuperMap from "../classes/super-map";
import { AppSettings } from "../helpers/app-settings";
import replaceableEventsService, { RequestOptions } from "./replaceable-events";
import { queryStore } from "./event-store";
import Observable from "zen-observable";
import AppSettingsQuery from "../queries/app-settings";
export const APP_SETTINGS_KIND = 30078;
export const APP_SETTING_IDENTIFIER = "nostrudel-settings";
class UserAppSettings {
private parsed = new SuperMap<string, Observable<AppSettings>>((pubkey) =>
queryStore.runQuery(AppSettingsQuery)(pubkey),
);
getSubject(pubkey: string) {
return this.parsed.get(pubkey);
}
requestAppSettings(pubkey: string, relays: Iterable<string>, opts?: RequestOptions) {
replaceableEventsService.requestEvent(relays, APP_SETTINGS_KIND, pubkey, APP_SETTING_IDENTIFIER, opts);
return this.parsed.get(pubkey);
}
buildAppSettingsEvent(settings: Partial<AppSettings>): DraftNostrEvent {
return {
kind: APP_SETTINGS_KIND,
tags: [["d", APP_SETTING_IDENTIFIER]],
content: JSON.stringify(settings),
created_at: dayjs().unix(),
};
}
}
const userAppSettings = new UserAppSettings();
if (import.meta.env.DEV) {
// @ts-ignore
window.userAppSettings = userAppSettings;
}
export default userAppSettings;

View File

@ -7,10 +7,9 @@ import accountService from "./account";
import clientRelaysService from "./client-relays";
import { offlineMode } from "./offline-mode";
import replaceableEventsService from "./replaceable-events";
import userAppSettings from "./settings/user-app-settings";
import userMailboxesService from "./user-mailboxes";
import userMetadataService from "./user-metadata";
import userAppSettings, { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "./user-app-settings";
import { USER_BLOSSOM_SERVER_LIST_KIND } from "blossom-client-sdk";
import { queryStore } from "./event-store";
const log = logger.extend("user-event-sync");
@ -18,31 +17,52 @@ 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 });
replaceableEventsService.requestEvent(relays, USER_BLOSSOM_SERVER_LIST_KIND, account.pubkey, undefined, {
alwaysRequest: true,
const requestReplaceable = (relays: Iterable<string>, kind: number, d?: string) => {
replaceableEventsService.requestEvent(relays, kind, account.pubkey, d, {
alwaysRequest: true,
});
};
log("Loading outboxes");
requestReplaceable([...relays, COMMON_CONTACT_RELAY], kinds.RelayList);
const mailboxesSub = queryStore.mailboxes(account.pubkey).subscribe((mailboxes) => {
log("Loading user information");
requestReplaceable(mailboxes?.outboxes || relays, kinds.Metadata);
requestReplaceable(mailboxes?.outboxes || relays, USER_BLOSSOM_SERVER_LIST_KIND);
requestReplaceable(mailboxes?.outboxes || relays, kinds.SearchRelaysList);
requestReplaceable(mailboxes?.outboxes || relays, APP_SETTINGS_KIND, APP_SETTING_IDENTIFIER);
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
log("Loading contacts list");
replaceableEventsService.requestEvent(
[...clientRelaysService.readRelays.value, COMMON_CONTACT_RELAY],
kinds.Contacts,
account.pubkey,
undefined,
{
alwaysRequest: true,
},
);
});
log("Loading contacts list");
replaceableEventsService.requestEvent(
[...clientRelaysService.readRelays.value, COMMON_CONTACT_RELAY],
kinds.Contacts,
account.pubkey,
undefined,
{
alwaysRequest: true,
},
);
return () => {
mailboxesSub.unsubscribe();
};
}
accountService.current.subscribe((account) => {
if (!account) return;
downloadEvents();
});
let unsubscribe: Function | undefined;
function update() {
const account = accountService.current.value;
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
offlineMode.subscribe((offline) => {
if (!offline && accountService.current.value) downloadEvents();
});
if (offlineMode.value) return;
if (!account) return;
unsubscribe = downloadEvents();
}
accountService.current.subscribe(update);
offlineMode.subscribe(update);

View File

@ -2,7 +2,7 @@ import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
import useUserProfile from "../../../hooks/use-user-profile";
import { LightningIcon } from "../../../components/icons";
import ZapModal from "../../../components/event-zap-modal";
import { useInvoiceModalContext } from "../../../providers/route/invoice-modal";
import { useInvoiceModalContext } from "../../../providers/route/invoice-modal-provider";
export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) {
const metadata = useUserProfile(pubkey);