pl-fe: support configuring redirect services, i think it works but idk

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-15 11:39:03 +02:00
parent 7b8439e82d
commit d85f63e6cf
10 changed files with 296 additions and 42 deletions

View File

@ -55,7 +55,7 @@
"@lexical/selection": "^0.29.0",
"@lexical/utils": "^0.29.0",
"@mkljczk/react-hotkeys": "^1.3.0",
"@mkljczk/url-purify": "^0.0.2",
"@mkljczk/url-purify": "^0.0.3",
"@reach/combobox": "^0.18.0",
"@reach/rect": "^0.18.0",
"@reach/tabs": "^0.18.0",
@ -136,6 +136,7 @@
"sass": "^1.86.3",
"stringz": "^2.1.0",
"tiny-queue": "^0.2.1",
"use-mutative": "^1.2.1",
"util": "^0.12.5",
"valibot": "^1.0.0-beta.12",
"zustand": "^5.0.3",

View File

@ -50,6 +50,8 @@ interface IParsedContent {
emojis?: Array<CustomEmoji>;
/** Whether to call a function to remove tracking parameters from URLs. */
cleanUrls?: boolean;
/** Whether to call a function to redirect URLs to popular websites to privacy-respecting proxy services. */
redirectUrls?: boolean;
/** Whether to display link target domain when it's not part of the text. */
displayTargetHost?: boolean;
greentext?: boolean;
@ -100,6 +102,7 @@ function parseContent({
hasQuote,
emojis,
cleanUrls = false,
redirectUrls = false,
displayTargetHost = true,
greentext = false,
speakAsCat = false,
@ -172,7 +175,7 @@ function parseContent({
if (cleanUrls) {
try {
href = Purify.clearUrl(href);
href = Purify.clearUrl(href, cleanUrls, redirectUrls);
} catch (_) {
//
}
@ -275,9 +278,11 @@ function parseContent({
const ParsedContent: React.FC<IParsedContent> = React.memo((props) => {
const { urlPrivacy } = useSettings();
if (props.cleanUrls === undefined) {
props = { ...props, cleanUrls: urlPrivacy.clearLinksInContent, displayTargetHost: urlPrivacy.displayTargetHost };
}
props = { ...props };
if (props.cleanUrls === undefined) props.cleanUrls = urlPrivacy.clearLinksInContent;
if (props.redirectUrls === undefined) props.redirectUrls = urlPrivacy.redirectLinksMode !== 'off';
if (props.displayTargetHost === undefined) props.displayTargetHost = urlPrivacy.displayTargetHost;
return parseContent(props, false);
}, (prevProps, nextProps) => prevProps.html === nextProps.html);

View File

@ -147,6 +147,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
hasQuote: !!status.quote_id,
emojis: status.emojis,
cleanUrls: urlPrivacy.clearLinksInContent,
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
displayTargetHost: urlPrivacy.displayTargetHost,
greentext,
speakAsCat: status.account.speak_as_cat,

View File

@ -41,7 +41,7 @@ const StatePlugin: React.FC<IStatePlugin> = ({ composeId, isWysiwyg }) => {
editorState.read(() => {
const compareUrl = (url: string) => {
const cleanUrl = Purify.clearUrl(url);
const cleanUrl = Purify.clearUrl(url, true, false);
return {
originalUrl: url,
cleanUrl,

View File

@ -1,5 +1,7 @@
import { mappings } from '@mkljczk/url-purify';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, FormattedList, FormattedMessage, useIntl } from 'react-intl';
import { useMutative } from 'use-mutative';
import { changeSetting } from 'pl-fe/actions/settings';
import List, { ListItem } from 'pl-fe/components/list';
@ -12,16 +14,27 @@ import FormGroup from 'pl-fe/components/ui/form-group';
import Input from 'pl-fe/components/ui/input';
import Toggle from 'pl-fe/components/ui/toggle';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useSettings } from 'pl-fe/hooks/use-settings';
import KVStore from 'pl-fe/storage/kv-store';
import { KVStoreRedirectServicesItem } from 'pl-fe/utils/url-purify';
import { SelectDropdown } from '../forms';
const messages = defineMessages({
urlPrivacy: { id: 'settings.url_privacy', defaultMessage: 'URL privacy' },
rulesUrlPlaceholder: { id: 'url_privacy.rules_url.placeholder', defaultMessage: 'Rules URL' },
hashUrlPlaceholder: { id: 'url_privacy.hash_url.placeholder', defaultMessage: 'Hash URL' },
redirectLinksModeOff: { id: 'url_privacy.redirect_links_mode.off', defaultMessage: 'Disabled' },
redirectLinksModeAuto: { id: 'url_privacy.redirect_links_mode.auto', defaultMessage: 'From URL' },
redirectLinksModeManual: { id: 'url_privacy.redirect_links_mode.manual', defaultMessage: 'Specify manually' },
redirectServicesUrlPlaceholder: { id: 'url_privacy.redirect_services_url.placeholder', defaultMessage: 'Rules URL' },
redirectServicePlaceholder: { id: 'url_privacy.redirect_services_url.placeholder', defaultMessage: 'eg. https://proxy.example.org' },
});
const UrlPrivacy = () => {
const dispatch = useAppDispatch();
const me = useAppSelector((state) => state.me);
const intl = useIntl();
const { urlPrivacy } = useSettings();
@ -29,25 +42,62 @@ const UrlPrivacy = () => {
const [displayTargetHost, setDisplayTargetHost] = useState(urlPrivacy.displayTargetHost);
const [clearLinksInCompose, setClearLinksInCompose] = useState(urlPrivacy.clearLinksInCompose);
const [clearLinksInContent, setClearLinksInContent] = useState(urlPrivacy.clearLinksInContent);
// const [allowReferralMarketing, setAllowReferralMarketing] = useState(urlPrivacy.allowReferralMarketing);
const [hashUrl, setHashUrl] = useState(urlPrivacy.hashUrl);
const [rulesUrl, setRulesUrl] = useState(urlPrivacy.rulesUrl);
const [redirectLinksMode, setRedirectLinksMode] = useState(urlPrivacy.redirectLinksMode);
const [redirectServicesUrl, setRedirectServicesUrl] = useState(urlPrivacy.redirectServicesUrl);
const [redirectServices, setRedirectServices] = useMutative(urlPrivacy.redirectServices);
const onSubmit = () => {
dispatch(changeSetting(['urlPrivacy'], {
const value = {
...urlPrivacy,
displayTargetHost,
clearLinksInCompose,
clearLinksInContent,
// allowReferralMarketing,
hashUrl,
rulesUrl,
}, {
redirectLinksMode,
redirectServicesUrl,
redirectServices,
};
switch (redirectLinksMode) {
case 'off':
value.redirectServicesUrl = '';
value.redirectServices = {};
break;
case 'manual':
value.redirectServicesUrl = '';
break;
case 'auto':
value.redirectServices = {};
break;
}
dispatch(changeSetting(['urlPrivacy'], value, {
save: true,
showAlert: true,
}));
};
const handleChangeRedirectLinksMode = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (redirectLinksMode === 'auto' && event.target.value === 'manual') {
KVStore.getItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${me}`).then((services) => {
if (!services?.redirectServices) return;
setRedirectServices(
Object.fromEntries(
mappings.map(({ name, targets }) => ([
name,
services.redirectServices.find((service) => targets.includes(service.type) && service.instances.length)?.instances[0].split('|')[0] || '',
])),
),
);
}).catch(() => { });
}
return setRedirectLinksMode(event.target.value as 'off');
};
useEffect(() => {
}, [dispatch]);
@ -74,10 +124,6 @@ const UrlPrivacy = () => {
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_content' defaultMessage='Remove tracking parameters from displayed posts' />}>
<Toggle checked={clearLinksInContent} onChange={({ target }) => setClearLinksInContent(target.checked)} />
</ListItem>
{/* <ListItem label={<FormattedMessage id='url_privacy.allow_referral_marketing' defaultMessage='Make exception for referral marketing parameters' />}>
<Toggle checked={allowReferralMarketing} onChange={({ target }) => setAllowReferralMarketing(target.checked)} disabled={!clearLinksInCompose && !clearLinksInContent} />
</ListItem> */}
</List>
<FormGroup
@ -104,6 +150,55 @@ const UrlPrivacy = () => {
/>
</FormGroup>
<List>
<ListItem label={<FormattedMessage id='url_privacy.redirect_links_mode' defaultMessage='Redirect links to popular websites to privacy-respecting proxy services' />}>
<SelectDropdown
className='max-w-fit'
items={{
off: intl.formatMessage(messages.redirectLinksModeOff),
auto: intl.formatMessage(messages.redirectLinksModeAuto),
manual: intl.formatMessage(messages.redirectLinksModeManual),
}}
defaultValue={redirectLinksMode}
onChange={handleChangeRedirectLinksMode}
/>
</ListItem>
</List>
{redirectLinksMode === 'auto' && (
<FormGroup
labelText={<FormattedMessage id='url_privacy.redirect_services_url.label' defaultMessage='Redirect services URLs database address' />}
hintText={<FormattedMessage id='url_privacy.redirect_services_url.hint' defaultMessage='URLs database in Farside-compatible format, eg. {url}' values={{ url: 'https://raw.githubusercontent.com/benbusby/farside/refs/heads/main/services.json' }} />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.redirectServicesUrlPlaceholder)}
value={redirectServicesUrl}
onChange={({ target }) => setRedirectServicesUrl(target.value)}
/>
</FormGroup>
)}
{redirectLinksMode === 'manual' && (
mappings.map((service) => (
<FormGroup
key={service.name}
labelText={<FormattedMessage id='url_privacy.redirect_services.name' defaultMessage='{name}' values={{ name: service.name }} />}
hintText={<FormattedMessage id='url_privacy.redirect_services.patterns' defaultMessage='Matches: {pattern}, eg. {services}, leave empty for no redirect' values={{ pattern: service.urlPattern, services: <FormattedList value={service.targets} /> }} />}
>
<Input
outerClassName='grow'
type='text'
value={redirectServices[service.name]}
onChange={(e) => setRedirectServices((services) => {
services[service.name] = e.target.value;
})}
placeholder={intl.formatMessage(messages.redirectServicePlaceholder)}
/>
</FormGroup>
))
)}
<FormActions>
<Button type='submit'>
<FormattedMessage id='url_privacy.save' defaultMessage='Save' />

View File

@ -1674,6 +1674,17 @@
"url_privacy.hash_url.hint": "SHA256 hash of rules database, used to avoid unnecessary fetches, eg. {url}",
"url_privacy.hash_url.label": "URL cleaning rules hash address (optional)",
"url_privacy.hash_url.placeholder": "Hash URL",
"url_privacy.redirect_links_mode": "Redirect links to popular websites to privacy-respecting proxy services",
"url_privacy.redirect_links_mode.auto": "From URL",
"url_privacy.redirect_links_mode.manual": "Specify manually",
"url_privacy.redirect_links_mode.off": "Disabled",
"url_privacy.redirect_services.name": "{name}",
"url_privacy.redirect_services.patterns": "Matches: {pattern}, eg. {services}, leave empty for no redirect",
"url_privacy.redirect_services_update.fail": "Failed to update redirect services URL",
"url_privacy.redirect_services_update.success": "Successfully updated redirect services",
"url_privacy.redirect_services_url.hint": "URLs database in Farside-compatible format, eg. {url}",
"url_privacy.redirect_services_url.label": "Redirect services URLs database address",
"url_privacy.redirect_services_url.placeholder": "eg. https://proxy.example.org",
"url_privacy.rules_url.hint": "Rules database in ClearURLs-compatible format, eg. {url}",
"url_privacy.rules_url.label": "URL cleaning rules database address",
"url_privacy.rules_url.placeholder": "Rules URL",

View File

@ -44,6 +44,9 @@ const settingsSchema = v.object({
rulesUrl: v.fallback(v.string(), ''),
hashUrl: v.fallback(v.string(), ''),
displayTargetHost: v.fallback(v.boolean(), true),
redirectLinksMode: v.fallback(v.picklist(['off', 'auto', 'manual']), 'off'),
redirectServicesUrl: v.fallback(v.string(), ''),
redirectServices: v.fallback(v.record(v.string(), v.string()), {}),
}),
theme: v.fallback(v.optional(v.object({

View File

@ -4,8 +4,9 @@ import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
import { settingsSchema, type Settings } from 'pl-fe/schemas/pl-fe/settings';
import KVStore from 'pl-fe/storage/kv-store';
import toast from 'pl-fe/toast';
import { updateRulesFromUrl } from 'pl-fe/utils/url-purify';
import { KVStoreRedirectServicesItem, resetRules, setManualRedirectServices, updateRedirectServicesFromUrl, updateRulesFromUrl } from 'pl-fe/utils/url-purify';
import type { Emoji } from 'pl-fe/features/emoji';
import type { store } from 'pl-fe/store';
@ -15,8 +16,10 @@ let lazyStore: typeof store;
import('pl-fe/store').then(({ store }) => lazyStore = store).catch(() => {});
const messages = defineMessages({
updateSuccess: { id: 'url_privacy.update.success', defaultMessage: 'Successfully updated rules database' },
updateFail: { id: 'url_privacy.update.fail', defaultMessage: 'Failed to update rules database URL' },
rulesUpdateSuccess: { id: 'url_privacy.update.success', defaultMessage: 'Successfully updated rules database' },
rulesUpdateFail: { id: 'url_privacy.update.fail', defaultMessage: 'Failed to update rules database URL' },
redirectServicesUpdateSuccess: { id: 'url_privacy.redirect_services_update.success', defaultMessage: 'Successfully updated redirect services' },
redirectServicesUpdateFail: { id: 'url_privacy.redirect_services_update.fail', defaultMessage: 'Failed to update redirect services URL' },
});
const settingsSchemaPartial = v.partial(settingsSchema);
@ -50,14 +53,35 @@ const changeSetting = (object: APIEntity, path: string[], value: any, root?: Set
const mergeSettings = (state: State, updating = false) => {
const mergedSettings = { ...state.defaultSettings, ...state.userSettings };
if (updating && mergedSettings.urlPrivacy.rulesUrl && state.settings.urlPrivacy.rulesUrl !== mergedSettings.urlPrivacy.rulesUrl) {
if (updating) {
const me = lazyStore?.getState().me;
if (me) {
updateRulesFromUrl(me, mergedSettings.urlPrivacy.rulesUrl, mergedSettings.urlPrivacy.hashUrl).then(() => {
toast.success(messages.updateSuccess);
}).catch(() => {
toast.error(messages.updateFail);
});
if (mergedSettings.urlPrivacy.rulesUrl && state.settings.urlPrivacy.rulesUrl !== mergedSettings.urlPrivacy.rulesUrl) {
updateRulesFromUrl(me, mergedSettings.urlPrivacy.rulesUrl, mergedSettings.urlPrivacy.hashUrl).then(() => {
toast.success(messages.rulesUpdateSuccess);
}).catch(() => {
toast.error(messages.rulesUpdateFail);
});
} else if (!mergedSettings.urlPrivacy.rulesUrl && state.settings.urlPrivacy.rulesUrl !== mergedSettings.urlPrivacy.rulesUrl) {
resetRules(me).then(() => {
toast.success(messages.rulesUpdateSuccess);
}).catch(() => {
toast.error(messages.rulesUpdateFail);
});
}
if (mergedSettings.urlPrivacy.redirectLinksMode === 'auto' && mergedSettings.urlPrivacy.redirectServicesUrl && state.settings.urlPrivacy.redirectServicesUrl !== mergedSettings.urlPrivacy.redirectServicesUrl) {
updateRedirectServicesFromUrl(me, mergedSettings.urlPrivacy.redirectServicesUrl).then(() => {
toast.success(messages.redirectServicesUpdateSuccess);
}).catch(() => {
toast.error(messages.redirectServicesUpdateFail);
});
} else if (mergedSettings.urlPrivacy.redirectLinksMode === 'manual') {
setManualRedirectServices(me, mergedSettings.urlPrivacy.redirectServices).then(() => {
toast.success(messages.redirectServicesUpdateSuccess);
}).catch(() => {
toast.error(messages.redirectServicesUpdateFail);
});
}
}
}
state.settings = mergedSettings;
@ -80,6 +104,45 @@ const useSettingsStore = create<State>()(mutative((set) => ({
if (typeof settings !== 'object') return;
state.userSettings = v.parse(settingsSchemaPartial, settings);
const me = lazyStore?.getState().me;
if (me) {
KVStore.getItem<string>('url-purify-rules:last').then((value) => {
if (value !== me) {
if (state.userSettings.urlPrivacy?.rulesUrl) {
updateRulesFromUrl(me, state.userSettings.urlPrivacy.rulesUrl, state.userSettings.urlPrivacy.hashUrl).then(() => {
toast.success(messages.rulesUpdateSuccess);
}).catch(() => {
toast.error(messages.rulesUpdateFail);
});
} else {
resetRules(me);
}
switch (state.userSettings.urlPrivacy?.redirectLinksMode) {
case 'auto':
updateRedirectServicesFromUrl(me, state.userSettings.urlPrivacy?.redirectServicesUrl);
break;
case 'manual':
setManualRedirectServices(me, state.userSettings.urlPrivacy.redirectServices);
break;
default:
setManualRedirectServices(me, {});
break;
}
} else {
KVStore.getItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${me}`).then((services) => {
if (state.userSettings.urlPrivacy?.redirectLinksMode === 'auto') {
if (services?.redirectServicesUrl !== state.userSettings.urlPrivacy?.redirectServicesUrl) {
updateRedirectServicesFromUrl(me, state.userSettings.urlPrivacy?.redirectServicesUrl);
}
} else {
setManualRedirectServices(me, state.userSettings.urlPrivacy?.redirectServices || {});
}
}).catch(() => {});
}
}).catch(() => {});
}
mergeSettings(state);
}),

View File

@ -12,19 +12,25 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// I hope I got this relicensing stuff right xD
import { URLPurify, type SerializedRules } from '@mkljczk/url-purify';
import { mappings, URLPurify, type SerializedRules, type SerializedServices } from '@mkljczk/url-purify';
import KVStore from 'pl-fe/storage/kv-store';
import { Me } from 'pl-fe/types/pl-fe';
interface KVStoreItem {
hashUrl: string;
rulesUrl: string;
hash: string;
interface KVStoreRulesItem {
hashUrl?: string;
rulesUrl?: string;
hash?: string;
rules: SerializedRules;
fetchedAt: number;
}
interface KVStoreRedirectServicesItem {
redirectServicesUrl?: string;
redirectServices: SerializedServices;
fetchedAt: number;
}
const sha256 = async (message: string) =>
Array.from(new Uint8Array(
await window.crypto.subtle.digest('SHA-256', (new TextEncoder()).encode(message)),
@ -106,10 +112,17 @@ const DEFAULT_RULESET: SerializedRules = {
const Purify = new URLPurify({
rulesFromMemory: DEFAULT_RULESET,
instancePickMode: 'random',
});
const updateRulesFromUrl = async (user: Me, rulesUrl: string, hashUrl: string, oldHash?: string) => {
if (oldHash) {
const resetRules = async (user: Me) => {
Purify.setRules(DEFAULT_RULESET);
await KVStore.setItem('url-purify-rules:last', user);
return KVStore.removeItem(`url-purify-rules:${user}`);
};
const updateRulesFromUrl = async (user: Me, rulesUrl: string, hashUrl?: string, oldHash?: string) => {
if (oldHash && hashUrl) {
const newHash = await fetch(hashUrl).then((response) => response.text());
if (newHash === oldHash) return;
}
@ -121,7 +134,7 @@ const updateRulesFromUrl = async (user: Me, rulesUrl: string, hashUrl: string, o
Purify.setRules(parsedRules, hash);
await KVStore.setItem('url-purify-rules:last', user);
return KVStore.setItem<KVStoreItem>(`url-purify-rules:${user}`, {
return KVStore.setItem<KVStoreRulesItem>(`url-purify-rules:${user}`, {
hashUrl,
rulesUrl,
hash,
@ -130,25 +143,82 @@ const updateRulesFromUrl = async (user: Me, rulesUrl: string, hashUrl: string, o
});
};
const updateRedirectServicesFromUrl = async (user: Me, redirectServicesUrl: string) => {
const redirectServices = await fetch(redirectServicesUrl).then((response) => response.text());
const parsedRedirectServices = JSON.parse(redirectServices);
Purify.setRedirectServices(parsedRedirectServices);
await KVStore.setItem('url-purify-redirect-services:last', user);
return KVStore.setItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${user}`, {
redirectServicesUrl,
redirectServices: parsedRedirectServices,
fetchedAt: Date.now(),
});
};
const setManualRedirectServices = async (user: Me, redirectServices: Record<string, string>) => {
const parsedRedirectServices: SerializedServices = Object.entries(redirectServices).filter(([_, instance]) => instance.trim()).map((service) => ({
fallback: service[1],
instances: [service[1]],
test_url: '',
type: mappings.find((mapping) => mapping.name === service[0])?.targets[0] || 'unknown',
}));
Purify.setRedirectServices(parsedRedirectServices);
await KVStore.setItem('url-purify-redirect-services:last', user);
return KVStore.setItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${user}`, {
redirectServices: parsedRedirectServices,
fetchedAt: Date.now(),
});
};
const getRulesForUser = (user: Me) => {
if (!user || typeof user !== 'string') return;
KVStore.getItem<KVStoreItem>(`url-purify-rules:${user}`, (rules) => {
if (!rules) return;
KVStore.getItem<KVStoreRulesItem>(`url-purify-rules:${user}`).then((rules) => {
if (!rules?.rulesUrl) return;
Purify.setRules(rules.rules, rules.hash);
if (rules.fetchedAt + 1000 * 60 * 60 * 24 < Date.now()) {
updateRulesFromUrl(user, rules.rulesUrl, rules.hashUrl, rules.hash);
}
});
}).catch(() => {});
};
const getRulesFromMemory = () => {
KVStore.getItem('url-purify-rules:last', (url: string) => {
KVStore.getItem<string>('url-purify-rules:last').then((url) => {
if (!url) return;
getRulesForUser(url);
}).catch(() => {});
};
getRulesFromMemory();
const getRedirectServicesForUser = (user: Me) => {
if (!user || typeof user !== 'string') return;
KVStore.getItem<KVStoreRedirectServicesItem>(`url-purify-redirect-services:${user}`).then((services) => {
if (!services) return;
Purify.setRedirectServices(services.redirectServices);
export { Purify as default, getRulesForUser, updateRulesFromUrl };
if (services.redirectServicesUrl && services.fetchedAt + 1000 * 60 * 60 * 24 < Date.now()) {
updateRedirectServicesFromUrl(user, services.redirectServicesUrl);
}
}).catch(() => {});
};
const getRedirectServicesFromMemory = () => {
KVStore.getItem<string>('url-purify-redirect-services:last').then((url) => {
if (!url) return;
getRedirectServicesForUser(url);
}).catch(() => {});
};
getRulesFromMemory();
getRedirectServicesFromMemory();
export {
Purify as default,
resetRules,
setManualRedirectServices,
updateRedirectServicesFromUrl,
updateRulesFromUrl,
type KVStoreRedirectServicesItem,
};

View File

@ -1775,10 +1775,10 @@
lodash "^4.17.21"
mousetrap "^1.6.5"
"@mkljczk/url-purify@^0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@mkljczk/url-purify/-/url-purify-0.0.2.tgz#f6aed0f750ab49b8cf9d3e8169b2b358c1d4519f"
integrity sha512-OH5bb84cf7rpwPxE8y0JzkdPMCysC1ZO5wEZZf/cxBGa5io6XKV1f6WHZwU/CYUaBFDYana3ctU4FZdCNH6slg==
"@mkljczk/url-purify@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@mkljczk/url-purify/-/url-purify-0.0.3.tgz#e5121927617b007d2f91f6e08c73552a4f3c06dc"
integrity sha512-3O4QO/nH9yV/GKim+yKvQF1cKWN0dBAsxC5Ve50d1PaUYQFSd4y733eRGt+zRcPZrvgyAkyZZ5Bx7dAHWX+bBQ==
"@napi-rs/wasm-runtime@^0.2.7":
version "0.2.7"
@ -8851,6 +8851,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-mutative@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/use-mutative/-/use-mutative-1.2.1.tgz#ee86017899f48027bf87b9d42c6c3a237997a536"
integrity sha512-cXvnAWmCxjcxYNitIwLJJEmjLISQTIfj0T88xfk1xNM0knmnOfEQPeU4+CDWBAcK0IXP7ey8fKeGc0seh+5OBA==
use-sync-external-store@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc"