small setting fixes

This commit is contained in:
hzrd149 2023-07-24 08:18:10 -05:00
parent 1f04766d0e
commit 85dd32ae29
31 changed files with 293 additions and 192 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Add logging to app setting services

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix color theme picker

View File

@ -18,6 +18,7 @@
"bech32": "^2.0.0",
"cheerio": "^1.0.0-rc.12",
"dayjs": "^1.11.9",
"debug": "^4.3.4",
"framer-motion": "^7.10.3",
"hls.js": "^1.4.7",
"idb": "^7.1.1",
@ -40,6 +41,7 @@
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@testing-library/cypress": "^9.0.0",
"@types/debug": "^4.1.8",
"@types/identicon.js": "^2.3.1",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",

View File

@ -1,9 +1,11 @@
import dayjs from "dayjs";
import debug, { Debugger } from "debug";
import { NostrSubscription } from "./nostr-subscription";
import { SuperMap } from "./super-map";
import { NostrEvent } from "../types/nostr-event";
import Subject from "./subject";
import { NostrQuery } from "../types/nostr-query";
import { nameOrPubkey } from "../helpers/debug";
type pubkey = string;
type relay = string;
@ -19,13 +21,17 @@ class PubkeyEventRequestSubscription {
private requestedPubkeys = new Map<pubkey, Date>();
constructor(relay: string, kind: number, name?: string, dTag?: string) {
log: Debugger;
constructor(relay: string, kind: number, name?: string, dTag?: string, log?: Debugger) {
this.kind = kind;
this.dTag = dTag;
this.subscription = new NostrSubscription(relay, undefined, name);
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
this.log = log || debug("misc");
}
private handleEvent(event: NostrEvent) {
@ -41,6 +47,7 @@ class PubkeyEventRequestSubscription {
const current = sub.value;
if (!current || event.created_at > current.created_at) {
this.log(`Found newer event for ${nameOrPubkey(event.pubkey)}`);
sub.next(event);
}
}
@ -57,6 +64,7 @@ class PubkeyEventRequestSubscription {
const sub = this.subjects.get(pubkey);
if (!sub.value) {
this.log(`Adding ${nameOrPubkey(pubkey)} to queue`);
this.requestNext.add(pubkey);
}
@ -79,6 +87,7 @@ class PubkeyEventRequestSubscription {
if (dayjs(date).isBefore(timeout)) {
this.requestedPubkeys.delete(pubkey);
needsUpdate = true;
this.log(`Request for ${nameOrPubkey(pubkey)} expired`);
}
}
@ -87,7 +96,10 @@ class PubkeyEventRequestSubscription {
if (this.requestedPubkeys.size > 0) {
const query: NostrQuery = { authors: Array.from(this.requestedPubkeys.keys()), kinds: [this.kind] };
if (this.dTag) query["#d"] = [this.dTag];
this.log(`Updating query with ${query.authors?.length} pubkeys`);
this.subscription.setQuery(query);
if (this.subscription.state !== NostrSubscription.OPEN) {
this.subscription.open();
}
@ -105,13 +117,17 @@ export class PubkeyEventRequester {
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
private subscriptions = new SuperMap<relay, PubkeyEventRequestSubscription>(
(relay) => new PubkeyEventRequestSubscription(relay, this.kind, this.name, this.dTag)
(relay) => new PubkeyEventRequestSubscription(relay, this.kind, this.name, this.dTag, this.log.extend(relay))
);
constructor(kind: number, name?: string, dTag?: string) {
log: Debugger;
constructor(kind: number, name?: string, dTag?: string, log?: Debugger) {
this.kind = kind;
this.name = name;
this.dTag = dTag;
this.log = log || debug("misc");
}
getSubject(pubkey: string) {
@ -120,25 +136,29 @@ export class PubkeyEventRequester {
handleEvent(event: NostrEvent) {
if (event.kind !== this.kind) return;
const sub = this.subjects.get(event.pubkey);
const sub = this.subjects.get(event.pubkey);
const current = sub.value;
if (!current || event.created_at > current.created_at) {
this.log(`New event for ${nameOrPubkey(event.pubkey)}`);
sub.next(event);
}
}
private connected = new WeakSet<any>();
requestEvent(pubkey: string, relays: string[]) {
this.log(`Requesting event for ${nameOrPubkey(pubkey)}`);
const sub = this.subjects.get(pubkey);
for (const relay of relays) {
const relaySub = this.subscriptions.get(relay).requestEvent(pubkey);
if (!this.connected.has(relaySub)) {
relaySub.subscribe((event) => event && this.handleEvent(event));
this.connected.add(relaySub);
}
sub.connectWithHandler(relaySub, (event, next, current) => {
if (event.kind !== this.kind) return;
if (!current || event.created_at > current.created_at) {
this.log(`Event for ${nameOrPubkey(event.pubkey)} from connection`);
next(event);
}
});
}
return sub;

View File

@ -1,5 +1,6 @@
import Subject from "./subject";
/** @deprecated */
export class PubkeySubjectCache<T> {
subjects = new Map<string, Subject<T | null>>();
relays = new Map<string, Set<string>>();

View File

@ -19,6 +19,8 @@ export class Subject<Value> implements Connectable<Value> {
}
next(value: Value) {
if (this.value === value) return;
this.value = value;
for (const [listener, ctx] of this.listeners) {
if (ctx) listener.call(ctx, value);

View File

@ -1,5 +1,5 @@
import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import { ImageGalleryLink } from "../image-gallery";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { useTrusted } from "../../providers/trust";

View File

@ -1,5 +1,5 @@
import { replaceDomain } from "../../helpers/url";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import { renderGenericUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/reddit.js

View File

@ -1,5 +1,5 @@
import { replaceDomain } from "../../helpers/url";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import { renderOpenGraphUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js

View File

@ -1,5 +1,5 @@
import { AspectRatio, list } from "@chakra-ui/react";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import { renderOpenGraphUrl } from "./common";
import { replaceDomain } from "../../helpers/url";

View File

@ -7,7 +7,7 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";

View File

@ -25,7 +25,7 @@ import ReactionButton from "./buttons/reaction-button";
import NoteZapButton from "./note-zap-button";
import { ExpandProvider } from "./expanded";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/app-settings";
import appSettings from "../../services/settings/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { ReplyButton } from "./buttons/reply-button";
import { RepostButton } from "./buttons/repost-button";

View File

@ -4,7 +4,7 @@ import { useUserMetadata } from "../hooks/use-user-metadata";
import { useAsync } from "react-use";
import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import appSettings from "../services/app-settings";
import appSettings from "../services/settings/app-settings";
import useSubject from "../hooks/use-subject";
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {

View File

@ -24,7 +24,7 @@ import { Kind } from "nostr-tools";
import clientRelaysService from "../services/client-relays";
import { getEventRelays } from "../services/event-relays";
import { useSigningContext } from "../providers/signing-provider";
import appSettings from "../services/app-settings";
import appSettings from "../services/settings/app-settings";
import useSubject from "../hooks/use-subject";
import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata";
import { requestZapInvoice } from "../helpers/zaps";

View File

@ -1,4 +1,4 @@
import appSettings from "../services/app-settings";
import appSettings from "../services/settings/app-settings";
import { convertToUrl } from "./url";
const corsFailedHosts = new Set();

11
src/helpers/debug.ts Normal file
View File

@ -0,0 +1,11 @@
import debug from "debug";
import userMetadataService from "../services/user-metadata";
export const logger = debug("noStrudel");
debug.enable("noStrudel:*");
export function nameOrPubkey(pubkey: string) {
const parsed = userMetadataService.getSubject(pubkey).value;
return parsed?.name || parsed?.display_name || pubkey;
}

View File

@ -1,8 +1,8 @@
import { useCallback } from "react";
import appSettings, { replaceSettings } from "../services/app-settings";
import appSettings, { replaceSettings } from "../services/settings/app-settings";
import useSubject from "./use-subject";
import { AppSettings } from "../services/user-app-settings";
import { useToast } from "@chakra-ui/react";
import { AppSettings } from "../services/settings/migrations";
export default function useAppSettings() {
const settings = useSubject(appSettings);

View File

@ -1,6 +1,6 @@
import { useColorMode } from "@chakra-ui/react";
import useSubject from "./use-subject";
import appSettings from "../services/app-settings";
import appSettings from "../services/settings/app-settings";
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";

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/app-settings";
import appSettings from "../services/settings/app-settings";
export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>;

View File

@ -1,6 +1,6 @@
import { PersistentSubject } from "../classes/subject";
import db from "./db";
import { AppSettings } from "./user-app-settings";
import { AppSettings } from "./settings/migrations";
export type Account = {
pubkey: string;

View File

@ -1,45 +0,0 @@
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 replaceSettings(newSettings: AppSettings) {
const account = accountService.current.value;
if (!account) return;
if (account.readonly) {
accountService.updateAccountLocalSettings(account.pubkey, newSettings);
appSettings.next(newSettings);
} else {
const draft = userAppSettings.buildAppSettingsEvent(newSettings);
const event = await signingService.requestSignature(draft, account);
userAppSettings.receiveEvent(event);
await nostrPostAction(clientRelaysService.getWriteUrls(), event).onComplete;
}
}
export async function loadSettings() {
const account = accountService.current.value;
if (!account) {
appSettings.next(defaultSettings);
return;
}
appSettings.disconnectAll();
if (account.readonly) {
if (account.localSettings) {
appSettings.next(account.localSettings);
}
} else {
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
appSettings.connect(subject);
}
}
accountService.current.subscribe(loadSettings);
clientRelaysService.relays.subscribe(loadSettings);
export default appSettings;

View File

@ -0,0 +1,61 @@
import { PersistentSubject } from "../../classes/subject";
import accountService from "../account";
import userAppSettings from "./user-app-settings";
import clientRelaysService from "../client-relays";
import signingService from "../signing";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { AppSettings, 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);
});
export async function replaceSettings(newSettings: AppSettings) {
const account = accountService.current.value;
if (!account) return;
if (account.readonly) {
accountService.updateAccountLocalSettings(account.pubkey, newSettings);
appSettings.next(newSettings);
} else {
const draft = userAppSettings.buildAppSettingsEvent(newSettings);
const event = await signingService.requestSignature(draft, account);
userAppSettings.receiveEvent(event);
await nostrPostAction(clientRelaysService.getWriteUrls(), event).onComplete;
}
}
accountService.current.subscribe(() => {
const account = accountService.current.value;
if (!account) {
appSettings.next(defaultSettings);
return;
}
appSettings.disconnectAll();
if (account.localSettings) {
appSettings.next(account.localSettings);
log("Loaded user settings from local storage");
}
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
appSettings.next(defaultSettings);
appSettings.connect(subject);
});
clientRelaysService.relays.subscribe(() => {
// relays changed, look for settings again
const account = accountService.current.value;
if (account) {
userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
}
});
export default appSettings;

View File

@ -0,0 +1,67 @@
import { ColorModeWithSystem } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { safeJson } from "../../helpers/parse";
export type AppSettingsV0 = {
version: 0;
colorMode: ColorModeWithSystem;
blurImages: boolean;
autoShowMedia: boolean;
proxyUserMedia: boolean;
showReactions: boolean;
showSignatureVerification: boolean;
autoPayWithWebLN: boolean;
customZapAmounts: string;
primaryColor: string;
imageProxy: string;
corsProxy: string;
showContentWarning: boolean;
twitterRedirect?: string;
redditRedirect?: string;
youtubeRedirect?: string;
};
export function isV0(settings: { version: number }): settings is AppSettingsV0 {
return settings.version === undefined || settings.version === 0;
}
export type AppSettings = AppSettingsV0;
export const defaultSettings: AppSettings = {
version: 0,
colorMode: "system",
blurImages: true,
autoShowMedia: true,
proxyUserMedia: false,
showReactions: true,
showSignatureVerification: false,
autoPayWithWebLN: true,
customZapAmounts: "50,200,500,1000,2000,5000",
primaryColor: "#8DB600",
imageProxy: "",
corsProxy: "",
showContentWarning: true,
twitterRedirect: undefined,
redditRedirect: undefined,
youtubeRedirect: undefined,
};
export function upgradeSettings(settings: { version: number }) {
if (isV0(settings)) return settings;
return null;
}
export function parseAppSettings(event: NostrEvent): AppSettings {
const json = safeJson(event.content, {});
const upgraded = upgradeSettings(json);
return upgraded
? {
...defaultSettings,
...upgraded,
}
: defaultSettings;
}

View File

@ -0,0 +1,66 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import db from "../db";
import { logger } from "../../helpers/debug";
import { SuperMap } from "../../classes/super-map";
import { PersistentSubject } from "../../classes/subject";
import { CachedPubkeyEventRequester } from "../../classes/cached-pubkey-event-requester";
import { AppSettings, defaultSettings, parseAppSettings } from "./migrations";
const DTAG = "nostrudel-settings";
class UserAppSettings {
requester: CachedPubkeyEventRequester;
log = logger.extend("UserAppSettings");
constructor() {
this.requester = new CachedPubkeyEventRequester(30078, "user-app-data", DTAG, this.log.extend("requester"));
this.requester.readCache = (pubkey) => db.get("settings", pubkey);
this.requester.writeCache = (pubkey, event) => db.put("settings", event);
}
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
(pubkey) => 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: dayjs().unix(),
};
}
}
const userAppSettings = new UserAppSettings();
setInterval(() => {
userAppSettings.update();
}, 1000 * 2);
if (import.meta.env.DEV) {
// @ts-ignore
window.userAppSettings = userAppSettings;
}
export default userAppSettings;

View File

@ -1,110 +0,0 @@
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 dayjs from "dayjs";
import { ColorMode } from "@chakra-ui/react";
import db from "./db";
const DTAG = "nostrudel-settings";
export type AppSettings = {
colorMode: ColorMode;
blurImages: boolean;
autoShowMedia: boolean;
proxyUserMedia: boolean;
showReactions: boolean;
showSignatureVerification: boolean;
autoPayWithWebLN: boolean;
customZapAmounts: string;
primaryColor: string;
imageProxy: string;
corsProxy: string;
showContentWarning: boolean;
twitterRedirect?: string;
redditRedirect?: string;
youtubeRedirect?: string;
};
export const defaultSettings: AppSettings = {
colorMode: "light",
blurImages: true,
autoShowMedia: true,
proxyUserMedia: false,
showReactions: true,
showSignatureVerification: false,
autoPayWithWebLN: true,
customZapAmounts: "50,200,500,1000,2000,5000",
primaryColor: "#8DB600",
imageProxy: "",
corsProxy: "",
showContentWarning: true,
twitterRedirect: undefined,
redditRedirect: undefined,
youtubeRedirect: undefined,
};
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 = (pubkey) => db.get("settings", pubkey);
this.requester.writeCache = (pubkey, event) => 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: dayjs().unix(),
};
}
}
const userAppSettings = new UserAppSettings();
setInterval(() => {
userAppSettings.update();
}, 1000 * 2);
if (import.meta.env.DEV) {
// @ts-ignore
window.userAppSettings = userAppSettings;
}
export default userAppSettings;

View File

@ -11,8 +11,10 @@ import {
AccordionIcon,
FormHelperText,
Input,
Stack,
Select,
} from "@chakra-ui/react";
import { AppSettings } from "../../services/user-app-settings";
import { AppSettings } from "../../services/settings/migrations";
export default function DisplaySettings() {
const { register } = useFormContext<AppSettings>();
@ -30,15 +32,14 @@ export default function DisplaySettings() {
<AccordionPanel>
<Flex direction="column" gap="4">
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="colorMode" mb="0">
Use dark theme
</FormLabel>
<Switch id="colorMode" {...register("colorMode")} />
</Flex>
<FormHelperText>
<span>Enables hacker mode</span>
</FormHelperText>
<FormLabel htmlFor="colorMode" mb="0">
Use dark theme
</FormLabel>
<Select id="colorMode" {...register("colorMode")}>
<option value="system">System Default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</Select>
</FormControl>
<FormControl>
<Flex alignItems="center">

View File

@ -16,6 +16,9 @@ export default function SettingsView() {
const form = useForm({
mode: "all",
values: settings,
resetOptions: {
keepDirty: true,
},
});
const saveSettings = form.handleSubmit(async (values) => {
@ -48,7 +51,7 @@ export default function SettingsView() {
</Link>
<Button
ml="auto"
isLoading={form.formState.isLoading || form.formState.isValidating}
isLoading={form.formState.isLoading || form.formState.isValidating || form.formState.isSubmitting}
isDisabled={!form.formState.isDirty}
colorScheme="brand"
type="submit"

View File

@ -14,8 +14,8 @@ import {
FormErrorMessage,
} from "@chakra-ui/react";
import { LightningIcon } from "../../components/icons";
import { AppSettings } from "../../services/user-app-settings";
import { useFormContext } from "react-hook-form";
import { AppSettings } from "../../services/settings/migrations";
export default function LightningSettings() {
const { register, formState } = useFormContext<AppSettings>();

View File

@ -14,8 +14,8 @@ import {
Link,
FormErrorMessage,
} from "@chakra-ui/react";
import { AppSettings } from "../../services/user-app-settings";
import { safeUrl } from "../../helpers/parse";
import { AppSettings } from "../../services/settings/migrations";
export default function PerformanceSettings() {
const { register, formState } = useFormContext<AppSettings>();

View File

@ -13,8 +13,8 @@ import {
FormErrorMessage,
} from "@chakra-ui/react";
import { useFormContext } from "react-hook-form";
import { AppSettings } from "../../services/user-app-settings";
import { safeUrl } from "../../helpers/parse";
import { AppSettings } from "../../services/settings/migrations";
async function validateInvidiousUrl(url?: string) {
if (!url) return true;

View File

@ -2521,6 +2521,13 @@
dependencies:
"@types/filesystem" "*"
"@types/debug@^4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
integrity sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==
dependencies:
"@types/ms" "*"
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@ -2572,6 +2579,11 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
"@types/ms@*":
version "0.7.31"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*":
version "20.4.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.2.tgz#129cc9ae69f93824f92fac653eebfb4812ab4af9"