mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-08 20:08:02 +02:00
store app settings in nostr event
This commit is contained in:
parent
dc33622e84
commit
a209b9d2fe
5
.changeset/happy-suits-mix.md
Normal file
5
.changeset/happy-suits-mix.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve database migration
|
5
.changeset/lucky-beds-grin.md
Normal file
5
.changeset/lucky-beds-grin.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Store app settings in NIP-78 Arbitrary app data event with local fallback
|
28
src/app.tsx
28
src/app.tsx
@ -1,6 +1,6 @@
|
|||||||
import React, { Suspense } from "react";
|
import React, { Suspense, useEffect } from "react";
|
||||||
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useLocation } from "react-router-dom";
|
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useLocation } from "react-router-dom";
|
||||||
import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
|
import { Button, Flex, Spinner, Text, useColorMode } from "@chakra-ui/react";
|
||||||
import { ErrorBoundary } from "./components/error-boundary";
|
import { ErrorBoundary } from "./components/error-boundary";
|
||||||
import { Page } from "./components/page";
|
import { Page } from "./components/page";
|
||||||
import { normalizeToHex } from "./helpers/nip19";
|
import { normalizeToHex } from "./helpers/nip19";
|
||||||
@ -32,6 +32,7 @@ import DirectMessagesView from "./views/dm";
|
|||||||
import DirectMessageChatView from "./views/dm/chat";
|
import DirectMessageChatView from "./views/dm/chat";
|
||||||
import NostrLinkView from "./views/link";
|
import NostrLinkView from "./views/link";
|
||||||
import UserReportsTab from "./views/user/reports";
|
import UserReportsTab from "./views/user/reports";
|
||||||
|
import appSettings from "./services/app-settings";
|
||||||
// code split search view because QrScanner library is 400kB
|
// code split search view because QrScanner library is 400kB
|
||||||
const SearchView = React.lazy(() => import("./views/search"));
|
const SearchView = React.lazy(() => import("./views/search"));
|
||||||
|
|
||||||
@ -135,10 +136,19 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const App = () => (
|
export const App = () => {
|
||||||
<ErrorBoundary>
|
const { setColorMode } = useColorMode();
|
||||||
<Suspense fallback={<Spinner />}>
|
const { colorMode } = useSubject(appSettings);
|
||||||
<RouterProvider router={router} />
|
|
||||||
</Suspense>
|
useEffect(() => {
|
||||||
</ErrorBoundary>
|
setColorMode(colorMode);
|
||||||
);
|
}, [colorMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -3,6 +3,7 @@ import { NostrSubscription } from "./nostr-subscription";
|
|||||||
import { SuperMap } from "./super-map";
|
import { SuperMap } from "./super-map";
|
||||||
import { NostrEvent } from "../types/nostr-event";
|
import { NostrEvent } from "../types/nostr-event";
|
||||||
import Subject from "./subject";
|
import Subject from "./subject";
|
||||||
|
import { NostrQuery } from "../types/nostr-query";
|
||||||
|
|
||||||
type pubkey = string;
|
type pubkey = string;
|
||||||
type relay = string;
|
type relay = string;
|
||||||
@ -10,6 +11,7 @@ type relay = string;
|
|||||||
class PubkeyEventRequestSubscription {
|
class PubkeyEventRequestSubscription {
|
||||||
private subscription: NostrSubscription;
|
private subscription: NostrSubscription;
|
||||||
private kind: number;
|
private kind: number;
|
||||||
|
private dTag?: string;
|
||||||
|
|
||||||
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||||
|
|
||||||
@ -17,8 +19,9 @@ class PubkeyEventRequestSubscription {
|
|||||||
|
|
||||||
private requestedPubkeys = new Map<pubkey, Date>();
|
private requestedPubkeys = new Map<pubkey, Date>();
|
||||||
|
|
||||||
constructor(relay: string, kind: number, name?: string) {
|
constructor(relay: string, kind: number, name?: string, dTag?: string) {
|
||||||
this.kind = kind;
|
this.kind = kind;
|
||||||
|
this.dTag = dTag;
|
||||||
this.subscription = new NostrSubscription(relay, undefined, name);
|
this.subscription = new NostrSubscription(relay, undefined, name);
|
||||||
|
|
||||||
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
||||||
@ -26,7 +29,10 @@ class PubkeyEventRequestSubscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: NostrEvent) {
|
private handleEvent(event: NostrEvent) {
|
||||||
|
// reject the event if its the wrong kind
|
||||||
if (event.kind !== this.kind) return;
|
if (event.kind !== this.kind) return;
|
||||||
|
// reject the event if has the wrong d tag or is missing one
|
||||||
|
if (this.dTag && !event.tags.some((t) => t[0] === "d" && t[1] === this.dTag)) return;
|
||||||
|
|
||||||
// remove the pubkey from the waiting list
|
// remove the pubkey from the waiting list
|
||||||
this.requestedPubkeys.delete(event.pubkey);
|
this.requestedPubkeys.delete(event.pubkey);
|
||||||
@ -79,7 +85,9 @@ class PubkeyEventRequestSubscription {
|
|||||||
// update the subscription
|
// update the subscription
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
if (this.requestedPubkeys.size > 0) {
|
if (this.requestedPubkeys.size > 0) {
|
||||||
this.subscription.setQuery({ authors: Array.from(this.requestedPubkeys.keys()), kinds: [this.kind] });
|
const query: NostrQuery = { authors: Array.from(this.requestedPubkeys.keys()), kinds: [this.kind] };
|
||||||
|
if (this.dTag) query["#d"] = [this.dTag];
|
||||||
|
this.subscription.setQuery(query);
|
||||||
if (this.subscription.state !== NostrSubscription.OPEN) {
|
if (this.subscription.state !== NostrSubscription.OPEN) {
|
||||||
this.subscription.open();
|
this.subscription.open();
|
||||||
}
|
}
|
||||||
@ -93,15 +101,17 @@ class PubkeyEventRequestSubscription {
|
|||||||
export class PubkeyEventRequester {
|
export class PubkeyEventRequester {
|
||||||
private kind: number;
|
private kind: number;
|
||||||
private name?: string;
|
private name?: string;
|
||||||
|
private dTag?: string;
|
||||||
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||||
|
|
||||||
private subscriptions = new SuperMap<relay, PubkeyEventRequestSubscription>(
|
private subscriptions = new SuperMap<relay, PubkeyEventRequestSubscription>(
|
||||||
(relay) => new PubkeyEventRequestSubscription(relay, this.kind, this.name)
|
(relay) => new PubkeyEventRequestSubscription(relay, this.kind, this.name, this.dTag)
|
||||||
);
|
);
|
||||||
|
|
||||||
constructor(kind: number, name?: string) {
|
constructor(kind: number, name?: string, dTag?: string) {
|
||||||
this.kind = kind;
|
this.kind = kind;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.dTag = dTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSubject(pubkey: string) {
|
getSubject(pubkey: string) {
|
||||||
|
@ -59,7 +59,7 @@ export class Subject<Value> implements Connectable<Value> {
|
|||||||
|
|
||||||
connect(connectable: Connectable<Value>) {
|
connect(connectable: Connectable<Value>) {
|
||||||
if (!this.upstream.has(connectable)) {
|
if (!this.upstream.has(connectable)) {
|
||||||
const handler = this.next;
|
const handler = this.next.bind(this);
|
||||||
this.upstream.set(connectable, handler);
|
this.upstream.set(connectable, handler);
|
||||||
connectable.subscribe(handler, this);
|
connectable.subscribe(handler, this);
|
||||||
|
|
||||||
|
@ -12,13 +12,13 @@ import { UserDnsIdentityIcon } from "./user-dns-identity";
|
|||||||
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19";
|
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19";
|
||||||
import { convertTimestampToDate } from "../helpers/date";
|
import { convertTimestampToDate } from "../helpers/date";
|
||||||
import useSubject from "../hooks/use-subject";
|
import useSubject from "../hooks/use-subject";
|
||||||
import settings from "../services/settings";
|
import appSettings from "../services/app-settings";
|
||||||
import EventVerificationIcon from "./event-verification-icon";
|
import EventVerificationIcon from "./event-verification-icon";
|
||||||
import { useReadRelayUrls } from "../hooks/use-client-relays";
|
import { useReadRelayUrls } from "../hooks/use-client-relays";
|
||||||
|
|
||||||
const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
|
const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
const showSignatureVerification = useSubject(settings.showSignatureVerification);
|
const { showSignatureVerification } = useSubject(appSettings);
|
||||||
|
|
||||||
const readRelays = useReadRelayUrls();
|
const readRelays = useReadRelayUrls();
|
||||||
const contacts = useUserContacts(account.pubkey, readRelays);
|
const contacts = useUserContacts(account.pubkey, readRelays);
|
||||||
|
@ -30,7 +30,7 @@ import ReactionButton from "./buttons/reaction-button";
|
|||||||
import NoteZapButton from "./note-zap-button";
|
import NoteZapButton from "./note-zap-button";
|
||||||
import { ExpandProvider } from "./expanded";
|
import { ExpandProvider } from "./expanded";
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import settings from "../../services/settings";
|
import appSettings from "../../services/app-settings";
|
||||||
import EventVerificationIcon from "../event-verification-icon";
|
import EventVerificationIcon from "../event-verification-icon";
|
||||||
import { ReplyButton } from "./buttons/reply-button";
|
import { ReplyButton } from "./buttons/reply-button";
|
||||||
import { RepostButton } from "./buttons/repost-button";
|
import { RepostButton } from "./buttons/repost-button";
|
||||||
@ -45,8 +45,7 @@ export type NoteProps = {
|
|||||||
export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteProps) => {
|
export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
const showReactions = useSubject(settings.showReactions);
|
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||||
const showSignatureVerification = useSubject(settings.showSignatureVerification);
|
|
||||||
|
|
||||||
const readRelays = useReadRelayUrls();
|
const readRelays = useReadRelayUrls();
|
||||||
const contacts = useUserContacts(account.pubkey, readRelays);
|
const contacts = useUserContacts(account.pubkey, readRelays);
|
||||||
|
@ -4,7 +4,7 @@ import { InlineInvoiceCard } from "../inline-invoice-card";
|
|||||||
import { TweetEmbed } from "../tweet-embed";
|
import { TweetEmbed } from "../tweet-embed";
|
||||||
import { UserLink } from "../user-link";
|
import { UserLink } from "../user-link";
|
||||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||||
import settings from "../../services/settings";
|
import appSettings from "../../services/app-settings";
|
||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import QuoteNote from "./quote-note";
|
import QuoteNote from "./quote-note";
|
||||||
import { useExpand } from "./expanded";
|
import { useExpand } from "./expanded";
|
||||||
@ -165,7 +165,7 @@ const embeds: EmbedType[] = [
|
|||||||
regexp:
|
regexp:
|
||||||
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
|
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,6})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i,
|
||||||
render: (match, event, trusted) => {
|
render: (match, event, trusted) => {
|
||||||
const ImageComponent = trusted || !settings.blurImages.value ? Image : BlurredImage;
|
const ImageComponent = trusted || !appSettings.value.blurImages ? Image : BlurredImage;
|
||||||
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" />;
|
return <ImageComponent src={match[0]} width="100%" maxWidth="30rem" />;
|
||||||
},
|
},
|
||||||
name: "Image",
|
name: "Image",
|
||||||
@ -253,7 +253,7 @@ const embeds: EmbedType[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MediaEmbed = ({ children, type }: { children: JSX.Element | string; type: EmbedType }) => {
|
const MediaEmbed = ({ children, type }: { children: JSX.Element | string; type: EmbedType }) => {
|
||||||
const [show, setShow] = useState(settings.autoShowMedia.value);
|
const [show, setShow] = useState(appSettings.value.autoShowMedia);
|
||||||
|
|
||||||
return show ? (
|
return show ? (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
|
@ -4,7 +4,7 @@ import { useUserMetadata } from "../hooks/use-user-metadata";
|
|||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
import { getIdenticon } from "../services/identicon";
|
import { getIdenticon } from "../services/identicon";
|
||||||
import { safeUrl } from "../helpers/parse";
|
import { safeUrl } from "../helpers/parse";
|
||||||
import settings from "../services/settings";
|
import appSettings from "../services/app-settings";
|
||||||
import useSubject from "../hooks/use-subject";
|
import useSubject from "../hooks/use-subject";
|
||||||
|
|
||||||
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
|
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
|
||||||
@ -17,7 +17,7 @@ export type UserAvatarProps = Omit<AvatarProps, "src"> & {
|
|||||||
pubkey: string;
|
pubkey: string;
|
||||||
};
|
};
|
||||||
export const UserAvatar = React.memo(({ pubkey, ...props }: UserAvatarProps) => {
|
export const UserAvatar = React.memo(({ pubkey, ...props }: UserAvatarProps) => {
|
||||||
const proxyUserMedia = useSubject(settings.proxyUserMedia);
|
const { proxyUserMedia } = useSubject(appSettings);
|
||||||
const metadata = useUserMetadata(pubkey);
|
const metadata = useUserMetadata(pubkey);
|
||||||
const picture = useMemo(() => {
|
const picture = useMemo(() => {
|
||||||
if (metadata?.picture) {
|
if (metadata?.picture) {
|
||||||
|
@ -32,7 +32,7 @@ import { useSigningContext } from "../providers/signing-provider";
|
|||||||
import QrCodeSvg from "./qr-code-svg";
|
import QrCodeSvg from "./qr-code-svg";
|
||||||
import { CopyIconButton } from "./copy-icon-button";
|
import { CopyIconButton } from "./copy-icon-button";
|
||||||
import { useIsMobile } from "../hooks/use-is-mobile";
|
import { useIsMobile } from "../hooks/use-is-mobile";
|
||||||
import settings from "../services/settings";
|
import appSettings from "../services/app-settings";
|
||||||
import useSubject from "../hooks/use-subject";
|
import useSubject from "../hooks/use-subject";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@ -63,7 +63,7 @@ export default function ZapModal({
|
|||||||
const [promptInvoice, setPromptInvoice] = useState<string>();
|
const [promptInvoice, setPromptInvoice] = useState<string>();
|
||||||
const { isOpen: showQr, onToggle: toggleQr } = useDisclosure();
|
const { isOpen: showQr, onToggle: toggleQr } = useDisclosure();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const zapAmounts = useSubject(settings.zapAmounts);
|
const { zapAmounts } = useSubject(appSettings);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -173,7 +173,7 @@ export default function ZapModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const payInvoice = (invoice: string) => {
|
const payInvoice = (invoice: string) => {
|
||||||
switch (settings.lightningPayMode.value) {
|
switch (appSettings.value.lightningPayMode) {
|
||||||
case "webln":
|
case "webln":
|
||||||
payWithWebLn(invoice);
|
payWithWebLn(invoice);
|
||||||
break;
|
break;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { PersistentSubject } from "../classes/subject";
|
import { PersistentSubject } from "../classes/subject";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
|
import { AppSettings } from "./user-app-settings";
|
||||||
|
|
||||||
export type Account = {
|
export type Account = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
@ -8,6 +9,7 @@ export type Account = {
|
|||||||
secKey?: ArrayBuffer;
|
secKey?: ArrayBuffer;
|
||||||
iv?: Uint8Array;
|
iv?: Uint8Array;
|
||||||
useExtension?: boolean;
|
useExtension?: boolean;
|
||||||
|
localSettings?: AppSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
class AccountService {
|
class AccountService {
|
||||||
@ -35,6 +37,11 @@ class AccountService {
|
|||||||
if (this.hasAccount(account.pubkey)) {
|
if (this.hasAccount(account.pubkey)) {
|
||||||
// replace account
|
// replace account
|
||||||
this.accounts.next(this.accounts.value.map((acc) => (acc.pubkey === account.pubkey ? account : acc)));
|
this.accounts.next(this.accounts.value.map((acc) => (acc.pubkey === account.pubkey ? account : acc)));
|
||||||
|
|
||||||
|
// if this is the current account. update it
|
||||||
|
if (this.current.value?.pubkey === account.pubkey) {
|
||||||
|
this.current.next(account);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// add account
|
// add account
|
||||||
this.accounts.next(this.accounts.value.concat(account));
|
this.accounts.next(this.accounts.value.concat(account));
|
||||||
@ -48,6 +55,16 @@ class AccountService {
|
|||||||
db.delete("accounts", pubkey);
|
db.delete("accounts", pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAccountLocalSettings(pubkey: string, settings: AppSettings) {
|
||||||
|
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
|
||||||
|
if (account) {
|
||||||
|
const updated = { ...account, localSettings: settings };
|
||||||
|
|
||||||
|
// update account
|
||||||
|
this.addAccount(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switchAccount(pubkey: string) {
|
switchAccount(pubkey: string) {
|
||||||
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
|
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
|
||||||
if (account) {
|
if (account) {
|
||||||
|
45
src/services/app-settings.ts
Normal file
45
src/services/app-settings.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { PersistentSubject } from "../classes/subject";
|
||||||
|
import accountService from "./account";
|
||||||
|
import userAppSettings, { AppSettings, defaultSettings } from "./user-app-settings";
|
||||||
|
import clientRelaysService from "./client-relays";
|
||||||
|
import signingService from "./signing";
|
||||||
|
import { nostrPostAction } from "../classes/nostr-post-action";
|
||||||
|
|
||||||
|
export let appSettings = new PersistentSubject(defaultSettings);
|
||||||
|
export async function updateSettings(settings: Partial<AppSettings>) {
|
||||||
|
try {
|
||||||
|
const account = accountService.current.value;
|
||||||
|
if (!account) return;
|
||||||
|
const json: AppSettings = { ...appSettings.value, ...settings };
|
||||||
|
|
||||||
|
if (account.readonly) {
|
||||||
|
accountService.updateAccountLocalSettings(account.pubkey, json);
|
||||||
|
appSettings.next(json);
|
||||||
|
} else {
|
||||||
|
const draft = userAppSettings.buildAppSettingsEvent({ ...appSettings.value, ...settings });
|
||||||
|
const event = await signingService.requestSignature(draft, account);
|
||||||
|
userAppSettings.receiveEvent(event);
|
||||||
|
await nostrPostAction(clientRelaysService.getWriteUrls(), event).onComplete;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSettings() {
|
||||||
|
const account = accountService.current.value;
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
appSettings.disconnectAll();
|
||||||
|
|
||||||
|
if (account.readonly) {
|
||||||
|
if (account.localSettings) {
|
||||||
|
appSettings.next(account.localSettings);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls());
|
||||||
|
appSettings.connect(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountService.current.subscribe(loadSettings);
|
||||||
|
clientRelaysService.relays.subscribe(loadSettings);
|
||||||
|
|
||||||
|
export default appSettings;
|
@ -1,61 +1,56 @@
|
|||||||
import { openDB, deleteDB } from "idb";
|
import { openDB, deleteDB } from "idb";
|
||||||
|
|
||||||
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
|
import { IDBPDatabase } from "idb";
|
||||||
import { CustomSchema } from "./schema";
|
import { SchemaV1, SchemaV2 } from "./schema";
|
||||||
|
|
||||||
type MigrationFunction = (
|
|
||||||
database: IDBPDatabase<CustomSchema>,
|
|
||||||
transaction: IDBPTransaction<CustomSchema, StoreNames<CustomSchema>[], "versionchange">,
|
|
||||||
event: IDBVersionChangeEvent
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const MIGRATIONS: MigrationFunction[] = [
|
|
||||||
// 0 -> 1
|
|
||||||
function (db, transaction, event) {
|
|
||||||
const userMetadata = db.createObjectStore("userMetadata", {
|
|
||||||
keyPath: "pubkey",
|
|
||||||
});
|
|
||||||
userMetadata.createIndex("created_at", "created_at");
|
|
||||||
|
|
||||||
const userRelays = db.createObjectStore("userRelays", {
|
|
||||||
keyPath: "pubkey",
|
|
||||||
});
|
|
||||||
userRelays.createIndex("created_at", "created_at");
|
|
||||||
|
|
||||||
const contacts = db.createObjectStore("userContacts", {
|
|
||||||
keyPath: "pubkey",
|
|
||||||
});
|
|
||||||
contacts.createIndex("created_at", "created_at");
|
|
||||||
|
|
||||||
const userFollows = db.createObjectStore("userFollows", {
|
|
||||||
keyPath: "pubkey",
|
|
||||||
});
|
|
||||||
userFollows.createIndex("follows", "follows", { multiEntry: true, unique: false });
|
|
||||||
|
|
||||||
const dnsIdentifiers = db.createObjectStore("dnsIdentifiers");
|
|
||||||
dnsIdentifiers.createIndex("pubkey", "pubkey", { unique: false });
|
|
||||||
dnsIdentifiers.createIndex("name", "name", { unique: false });
|
|
||||||
dnsIdentifiers.createIndex("domain", "domain", { unique: false });
|
|
||||||
dnsIdentifiers.createIndex("updated", "updated", { unique: false });
|
|
||||||
|
|
||||||
db.createObjectStore("settings");
|
|
||||||
db.createObjectStore("relayInfo");
|
|
||||||
db.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
|
|
||||||
db.createObjectStore("accounts", { keyPath: "pubkey" });
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const dbName = "storage";
|
const dbName = "storage";
|
||||||
const version = 1;
|
const version = 2;
|
||||||
const db = await openDB<CustomSchema>(dbName, version, {
|
const db = await openDB<SchemaV2>(dbName, version, {
|
||||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||||
// TODO: why is newVersion sometimes null?
|
if (oldVersion < 1) {
|
||||||
// @ts-ignore
|
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
|
||||||
for (let i = oldVersion; i <= newVersion; i++) {
|
|
||||||
if (MIGRATIONS[i]) {
|
const userMetadata = v0.createObjectStore("userMetadata", {
|
||||||
console.log(`Running database migration ${i}`);
|
keyPath: "pubkey",
|
||||||
MIGRATIONS[i](db, transaction, event);
|
});
|
||||||
}
|
userMetadata.createIndex("created_at", "created_at");
|
||||||
|
|
||||||
|
const userRelays = v0.createObjectStore("userRelays", {
|
||||||
|
keyPath: "pubkey",
|
||||||
|
});
|
||||||
|
userRelays.createIndex("created_at", "created_at");
|
||||||
|
|
||||||
|
const contacts = v0.createObjectStore("userContacts", {
|
||||||
|
keyPath: "pubkey",
|
||||||
|
});
|
||||||
|
contacts.createIndex("created_at", "created_at");
|
||||||
|
|
||||||
|
const userFollows = v0.createObjectStore("userFollows", {
|
||||||
|
keyPath: "pubkey",
|
||||||
|
});
|
||||||
|
userFollows.createIndex("follows", "follows", { multiEntry: true, unique: false });
|
||||||
|
|
||||||
|
const dnsIdentifiers = v0.createObjectStore("dnsIdentifiers");
|
||||||
|
dnsIdentifiers.createIndex("pubkey", "pubkey", { unique: false });
|
||||||
|
dnsIdentifiers.createIndex("name", "name", { unique: false });
|
||||||
|
dnsIdentifiers.createIndex("domain", "domain", { unique: false });
|
||||||
|
dnsIdentifiers.createIndex("updated", "updated", { unique: false });
|
||||||
|
|
||||||
|
v0.createObjectStore("settings");
|
||||||
|
v0.createObjectStore("relayInfo");
|
||||||
|
v0.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
|
||||||
|
v0.createObjectStore("accounts", { keyPath: "pubkey" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
const v1 = db as unknown as IDBPDatabase<SchemaV1>;
|
||||||
|
const v2 = db as unknown as IDBPDatabase<SchemaV2>;
|
||||||
|
|
||||||
|
v1.deleteObjectStore("settings");
|
||||||
|
const settings = v2.createObjectStore("settings", {
|
||||||
|
keyPath: "pubkey",
|
||||||
|
});
|
||||||
|
settings.createIndex("created_at", "created_at");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,7 @@ import { NostrEvent } from "../../types/nostr-event";
|
|||||||
import { Account } from "../account";
|
import { Account } from "../account";
|
||||||
import { RelayInformationDocument } from "../relay-info";
|
import { RelayInformationDocument } from "../relay-info";
|
||||||
|
|
||||||
export interface CustomSchema extends DBSchema {
|
export interface SchemaV1 extends DBSchema {
|
||||||
userMetadata: {
|
userMetadata: {
|
||||||
key: string;
|
key: string;
|
||||||
value: NostrEvent;
|
value: NostrEvent;
|
||||||
@ -49,3 +49,11 @@ export interface CustomSchema extends DBSchema {
|
|||||||
value: Account;
|
value: Account;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SchemaV2 extends SchemaV1 {
|
||||||
|
settings: {
|
||||||
|
key: string;
|
||||||
|
value: NostrEvent;
|
||||||
|
indexes: { created_at: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
import { PersistentSubject } from "../classes/subject";
|
|
||||||
import db from "./db";
|
|
||||||
import { Account } from "./account";
|
|
||||||
|
|
||||||
export enum LightningPayMode {
|
|
||||||
Prompt = "prompt",
|
|
||||||
Webln = "webln",
|
|
||||||
External = "external",
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
blurImages: new PersistentSubject(true),
|
|
||||||
autoShowMedia: new PersistentSubject(true),
|
|
||||||
proxyUserMedia: new PersistentSubject(false),
|
|
||||||
showReactions: new PersistentSubject(true),
|
|
||||||
showSignatureVerification: new PersistentSubject(false),
|
|
||||||
accounts: new PersistentSubject<Account[]>([]),
|
|
||||||
lightningPayMode: new PersistentSubject<LightningPayMode>(LightningPayMode.Prompt),
|
|
||||||
zapAmounts: new PersistentSubject<number[]>([50, 200, 500, 1000]),
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
let loading = true;
|
|
||||||
|
|
||||||
// load
|
|
||||||
for (const [key, subject] of Object.entries(settings)) {
|
|
||||||
const value = await db.get("settings", key);
|
|
||||||
// @ts-ignore
|
|
||||||
if (value !== undefined) subject.next(value);
|
|
||||||
|
|
||||||
// save
|
|
||||||
subject.subscribe((newValue) => {
|
|
||||||
if (loading) return;
|
|
||||||
db.put("settings", newValue, key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
await loadSettings();
|
|
||||||
|
|
||||||
export default settings;
|
|
105
src/services/user-app-settings.ts
Normal file
105
src/services/user-app-settings.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { CachedPubkeyEventRequester } from "../classes/cached-pubkey-event-requester";
|
||||||
|
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||||
|
import { SuperMap } from "../classes/super-map";
|
||||||
|
import { PersistentSubject } from "../classes/subject";
|
||||||
|
import { safeJson } from "../helpers/parse";
|
||||||
|
import moment from "moment";
|
||||||
|
import { ColorMode } from "@chakra-ui/react";
|
||||||
|
import db from "./db";
|
||||||
|
|
||||||
|
const DTAG = "nostrudel-settings";
|
||||||
|
|
||||||
|
export enum LightningPayMode {
|
||||||
|
Prompt = "prompt",
|
||||||
|
Webln = "webln",
|
||||||
|
External = "external",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
colorMode: ColorMode;
|
||||||
|
blurImages: boolean;
|
||||||
|
autoShowMedia: boolean;
|
||||||
|
proxyUserMedia: boolean;
|
||||||
|
showReactions: boolean;
|
||||||
|
showSignatureVerification: boolean;
|
||||||
|
lightningPayMode: LightningPayMode;
|
||||||
|
zapAmounts: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultSettings: AppSettings = {
|
||||||
|
colorMode: "light",
|
||||||
|
blurImages: true,
|
||||||
|
autoShowMedia: true,
|
||||||
|
proxyUserMedia: false,
|
||||||
|
showReactions: true,
|
||||||
|
showSignatureVerification: false,
|
||||||
|
lightningPayMode: LightningPayMode.Prompt,
|
||||||
|
zapAmounts: [50, 200, 500, 1000],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseAppSettings(event: NostrEvent): AppSettings {
|
||||||
|
const json = safeJson(event.content, {});
|
||||||
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
...json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserAppSettings {
|
||||||
|
requester: CachedPubkeyEventRequester;
|
||||||
|
constructor() {
|
||||||
|
this.requester = new CachedPubkeyEventRequester(30078, "user-app-data", DTAG);
|
||||||
|
this.requester.readCache = this.readCache;
|
||||||
|
this.requester.writeCache = this.writeCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
readCache(pubkey: string) {
|
||||||
|
return db.get("settings", pubkey);
|
||||||
|
}
|
||||||
|
writeCache(pubkey: string, event: NostrEvent) {
|
||||||
|
return db.put("settings", event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
|
||||||
|
() => new PersistentSubject<AppSettings>(defaultSettings)
|
||||||
|
);
|
||||||
|
getSubject(pubkey: string) {
|
||||||
|
return this.parsedSubjects.get(pubkey);
|
||||||
|
}
|
||||||
|
requestAppSettings(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||||
|
const sub = this.parsedSubjects.get(pubkey);
|
||||||
|
const requestSub = this.requester.requestEvent(pubkey, relays, alwaysRequest);
|
||||||
|
sub.connectWithHandler(requestSub, (event, next) => next(parseAppSettings(event)));
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveEvent(event: NostrEvent) {
|
||||||
|
this.requester.handleEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.requester.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAppSettingsEvent(settings: AppSettings): DraftNostrEvent {
|
||||||
|
return {
|
||||||
|
kind: 30078,
|
||||||
|
tags: [["d", DTAG]],
|
||||||
|
content: JSON.stringify(settings),
|
||||||
|
created_at: moment().unix(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAppSettings = new UserAppSettings();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
userAppSettings.update();
|
||||||
|
}, 1000 * 2);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// @ts-ignore
|
||||||
|
window.userAppSettings = userAppSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default userAppSettings;
|
@ -1,7 +1,8 @@
|
|||||||
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
|
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
|
||||||
export type PTag = ["p", string] | ["p", string, string];
|
export type PTag = ["p", string] | ["p", string, string];
|
||||||
export type RTag = ["r", string] | ["r", string, string];
|
export type RTag = ["r", string] | ["r", string, string];
|
||||||
export type Tag = string[] | ETag | PTag | RTag;
|
export type DTag = ["d"] | ["d", string];
|
||||||
|
export type Tag = string[] | ETag | PTag | RTag | DTag;
|
||||||
|
|
||||||
export type NostrEvent = {
|
export type NostrEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -30,3 +31,6 @@ export function isPTag(tag: Tag): tag is PTag {
|
|||||||
export function isRTag(tag: Tag): tag is RTag {
|
export function isRTag(tag: Tag): tag is RTag {
|
||||||
return tag[0] === "r" && tag[1] !== undefined;
|
return tag[0] === "r" && tag[1] !== undefined;
|
||||||
}
|
}
|
||||||
|
export function isDTag(tag: Tag): tag is DTag {
|
||||||
|
return tag[0] === "d";
|
||||||
|
}
|
||||||
|
@ -12,6 +12,7 @@ export type NostrQuery = {
|
|||||||
kinds?: number[];
|
kinds?: number[];
|
||||||
"#e"?: string[];
|
"#e"?: string[];
|
||||||
"#p"?: string[];
|
"#p"?: string[];
|
||||||
|
"#d"?: string[];
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
@ -3,7 +3,6 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Switch,
|
Switch,
|
||||||
useColorMode,
|
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
AccordionButton,
|
AccordionButton,
|
||||||
@ -11,13 +10,11 @@ import {
|
|||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import settings from "../../services/settings";
|
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import appSettings, { updateSettings } from "../../services/app-settings";
|
||||||
|
|
||||||
export default function DisplaySettings() {
|
export default function DisplaySettings() {
|
||||||
const blurImages = useSubject(settings.blurImages);
|
const { blurImages, colorMode } = useSubject(appSettings);
|
||||||
|
|
||||||
const { colorMode, setColorMode } = useColorMode();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
@ -39,7 +36,7 @@ export default function DisplaySettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="use-dark-theme"
|
id="use-dark-theme"
|
||||||
isChecked={colorMode === "dark"}
|
isChecked={colorMode === "dark"}
|
||||||
onChange={(v) => setColorMode(v.target.checked ? "dark" : "light")}
|
onChange={(v) => updateSettings({ colorMode: v.target.checked ? "dark" : "light" })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
@ -54,7 +51,7 @@ export default function DisplaySettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="blur-images"
|
id="blur-images"
|
||||||
isChecked={blurImages}
|
isChecked={blurImages}
|
||||||
onChange={(v) => settings.blurImages.next(v.target.checked)}
|
onChange={(v) => updateSettings({ blurImages: v.target.checked })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
|
@ -11,16 +11,17 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import settings, { LightningPayMode } from "../../services/settings";
|
import appSettings, { updateSettings } from "../../services/app-settings";
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import { LightningIcon } from "../../components/icons";
|
import { LightningIcon } from "../../components/icons";
|
||||||
|
import { LightningPayMode } from "../../services/user-app-settings";
|
||||||
|
|
||||||
export default function LightningSettings() {
|
export default function LightningSettings() {
|
||||||
const lightningPayMode = useSubject(settings.lightningPayMode);
|
const { lightningPayMode, zapAmounts } = useSubject(appSettings);
|
||||||
const zapAmounts = useSubject(settings.zapAmounts);
|
|
||||||
|
|
||||||
const [zapInput, setZapInput] = useState(zapAmounts.join(","));
|
const [zapInput, setZapInput] = useState(zapAmounts.join(","));
|
||||||
|
useEffect(() => setZapInput(zapAmounts.join(",")), [zapAmounts.join(",")]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
@ -41,7 +42,7 @@ export default function LightningSettings() {
|
|||||||
<Select
|
<Select
|
||||||
id="lightning-payment-mode"
|
id="lightning-payment-mode"
|
||||||
value={lightningPayMode}
|
value={lightningPayMode}
|
||||||
onChange={(e) => settings.lightningPayMode.next(e.target.value as LightningPayMode)}
|
onChange={(e) => updateSettings({ lightningPayMode: e.target.value as LightningPayMode })}
|
||||||
>
|
>
|
||||||
<option value="prompt">Prompt</option>
|
<option value="prompt">Prompt</option>
|
||||||
<option value="webln">WebLN</option>
|
<option value="webln">WebLN</option>
|
||||||
@ -71,7 +72,7 @@ export default function LightningSettings() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
settings.zapAmounts.next(amounts);
|
updateSettings({ zapAmounts: amounts });
|
||||||
setZapInput(amounts.join(","));
|
setZapInput(amounts.join(","));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -10,14 +10,11 @@ import {
|
|||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import settings from "../../services/settings";
|
import appSettings, { updateSettings } from "../../services/app-settings";
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
|
||||||
export default function PerformanceSettings() {
|
export default function PerformanceSettings() {
|
||||||
const autoShowMedia = useSubject(settings.autoShowMedia);
|
const { autoShowMedia, proxyUserMedia, showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||||
const proxyUserMedia = useSubject(settings.proxyUserMedia);
|
|
||||||
const showReactions = useSubject(settings.showReactions);
|
|
||||||
const showSignatureVerification = useSubject(settings.showSignatureVerification);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
@ -39,7 +36,7 @@ export default function PerformanceSettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="proxy-user-media"
|
id="proxy-user-media"
|
||||||
isChecked={proxyUserMedia}
|
isChecked={proxyUserMedia}
|
||||||
onChange={(v) => settings.proxyUserMedia.next(v.target.checked)}
|
onChange={(v) => updateSettings({ proxyUserMedia: v.target.checked })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
@ -56,7 +53,7 @@ export default function PerformanceSettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="auto-show-embeds"
|
id="auto-show-embeds"
|
||||||
isChecked={autoShowMedia}
|
isChecked={autoShowMedia}
|
||||||
onChange={(v) => settings.autoShowMedia.next(v.target.checked)}
|
onChange={(v) => updateSettings({ autoShowMedia: v.target.checked })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>Disabled: Images and videos will show expandable buttons</FormHelperText>
|
<FormHelperText>Disabled: Images and videos will show expandable buttons</FormHelperText>
|
||||||
@ -69,7 +66,7 @@ export default function PerformanceSettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="show-reactions"
|
id="show-reactions"
|
||||||
isChecked={showReactions}
|
isChecked={showReactions}
|
||||||
onChange={(v) => settings.showReactions.next(v.target.checked)}
|
onChange={(v) => updateSettings({ showReactions: v.target.checked })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>Enabled: Show reactions on notes</FormHelperText>
|
<FormHelperText>Enabled: Show reactions on notes</FormHelperText>
|
||||||
@ -82,7 +79,7 @@ export default function PerformanceSettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
id="show-sig-verify"
|
id="show-sig-verify"
|
||||||
isChecked={showSignatureVerification}
|
isChecked={showSignatureVerification}
|
||||||
onChange={(v) => settings.showSignatureVerification.next(v.target.checked)}
|
onChange={(v) => updateSettings({ showSignatureVerification: v.target.checked })}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<FormHelperText>Enabled: show signature verification on notes</FormHelperText>
|
<FormHelperText>Enabled: show signature verification on notes</FormHelperText>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user