pl-fe: WIP: Allow configuring url-purify

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-03-06 19:07:37 +01:00
parent 215eb39a8c
commit 076ff88f58
9 changed files with 172 additions and 36 deletions

View File

@ -82,7 +82,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
preview,
withMedia,
}) => {
const { cleanUrls, displaySpoilers } = useSettings();
const { urlPrivacy, displaySpoilers } = useSettings();
const { greentext } = usePlFeConfig();
const [collapsed, setCollapsed] = useState<boolean | null>(null);
@ -146,7 +146,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
mentions: status.mentions,
hasQuote: !!status.quote_id,
emojis: status.emojis,
}, true, cleanUrls, greentext), [content]);
}, true, urlPrivacy.clearLinksInContent, greentext), [content]);
useEffect(() => {
setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96);

View File

@ -13,10 +13,12 @@ interface ISettingToggle {
settingPath: string[];
/** Callback when the setting is toggled. */
onChange: (settingPath: string[], checked: boolean) => void;
/** Whether the toggle is disabled. */
disabled?: boolean;
}
/** Stateful toggle to change user settings. */
const SettingToggle: React.FC<ISettingToggle> = ({ id, settings, settingPath, onChange }) => {
const SettingToggle: React.FC<ISettingToggle> = ({ id, settings, settingPath, onChange, disabled }) => {
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
onChange(settingPath, target.checked);
@ -27,6 +29,7 @@ const SettingToggle: React.FC<ISettingToggle> = ({ id, settings, settingPath, on
id={id}
checked={!!get(settings, settingPath)}
onChange={handleChange}
disabled={disabled}
/>
);
};

View File

@ -42,6 +42,7 @@ const messages = defineMessages({
security: { id: 'settings.security', defaultMessage: 'Security' },
sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' },
settings: { id: 'settings.settings', defaultMessage: 'Settings' },
urlPrivacy: { id: 'settings.url_privacy', defaultMessage: 'URL privacy' },
});
/** User settings page. */
@ -92,39 +93,31 @@ const Settings = () => {
</List>
</CardBody>
{any([
features.changeEmail,
features.changePassword,
features.manageMfa,
features.sessions,
]) && (
<>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.security)} />
</CardHeader>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.security)} />
</CardHeader>
<CardBody>
<List>
{features.changeEmail && <ListItem label={intl.formatMessage(messages.changeEmail)} to='/settings/email' />}
{features.changePassword && <ListItem label={intl.formatMessage(messages.changePassword)} to='/settings/password' />}
{features.manageMfa && (
<>
<ListItem label={intl.formatMessage(messages.configureMfa)} to='/settings/mfa'>
<span>
{isMfaEnabled ?
intl.formatMessage(messages.mfaEnabled) :
intl.formatMessage(messages.mfaDisabled)}
</span>
</ListItem>
</>
)}
{features.sessions && (
<ListItem label={intl.formatMessage(messages.sessions)} to='/settings/tokens' />
)}
</List>
</CardBody>
</>
)}
<CardBody>
<List>
{features.changeEmail && <ListItem label={intl.formatMessage(messages.changeEmail)} to='/settings/email' />}
{features.changePassword && <ListItem label={intl.formatMessage(messages.changePassword)} to='/settings/password' />}
{features.manageMfa && (
<>
<ListItem label={intl.formatMessage(messages.configureMfa)} to='/settings/mfa'>
<span>
{isMfaEnabled ?
intl.formatMessage(messages.mfaEnabled) :
intl.formatMessage(messages.mfaDisabled)}
</span>
</ListItem>
</>
)}
{features.sessions && (
<ListItem label={intl.formatMessage(messages.sessions)} to='/settings/tokens' />
)}
<ListItem label={intl.formatMessage(messages.urlPrivacy)} to='/settings/url_privacy' />
</List>
</CardBody>
{features.chats ? (
<>

View File

@ -139,6 +139,7 @@ import {
StatusHoverCard,
TestTimeline,
ThemeEditor,
UrlPrivacy,
UserIndex,
} from './util/async-components';
import GlobalHotkeys from './util/global-hotkeys';
@ -304,6 +305,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ chil
<WrappedRoute path='/settings/mfa' layout={DefaultLayout} component={MfaForm} exact />
<WrappedRoute path='/settings/tokens' layout={DefaultLayout} component={AuthTokenList} content={children} />
{features.interactionRequests && <WrappedRoute path='/settings/interaction_policies' layout={DefaultLayout} component={InteractionPolicies} content={children} />}
<WrappedRoute path='/settings/url_privacy' layout={DefaultLayout} component={UrlPrivacy} content={children} />
<WrappedRoute path='/settings' layout={DefaultLayout} component={Settings} content={children} />
<WrappedRoute path='/pl-fe/config' adminOnly layout={DefaultLayout} component={PlFeConfig} content={children} />

View File

@ -90,6 +90,7 @@ export const Share = lazy(() => import('pl-fe/features/share'));
export const Status = lazy(() => import('pl-fe/features/status'));
export const TestTimeline = lazy(() => import('pl-fe/features/test-timeline'));
export const ThemeEditor = lazy(() => import('pl-fe/features/theme-editor'));
export const UrlPrivacy = lazy(() => import('pl-fe/features/url-privacy'));
export const UserIndex = lazy(() => import('pl-fe/features/admin/user-index'));
// Panels

View File

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { changeSetting } from 'pl-fe/actions/settings';
import List, { ListItem } from 'pl-fe/components/list';
import Button from 'pl-fe/components/ui/button';
import Card, { CardBody, CardHeader, CardTitle } from 'pl-fe/components/ui/card';
import Column from 'pl-fe/components/ui/column';
import Form from 'pl-fe/components/ui/form';
import FormActions from 'pl-fe/components/ui/form-actions';
import FormGroup from 'pl-fe/components/ui/form-group';
import Input from 'pl-fe/components/ui/input';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useSettings } from 'pl-fe/hooks/use-settings';
import SettingToggle from '../notifications/components/setting-toggle';
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' },
});
const UrlPrivacy = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const settings = useSettings();
useEffect(() => {
}, [dispatch]);
const onToggleChange = (key: string[], checked: boolean) => {
dispatch(changeSetting(key, checked));
};
return (
<Column label={intl.formatMessage(messages.urlPrivacy)} transparent withHeader={false}>
<Card className='space-y-4' variant='rounded'>
<CardHeader backHref='/settings'>
<CardTitle title={intl.formatMessage(messages.urlPrivacy)} />
</CardHeader>
<CardBody>
<Form>
<List>
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_compose' defaultMessage='Suggest removing tracking parameters when composing a post' />}>
<SettingToggle settings={settings} settingPath={['urlPrivacy', 'clearLinksInCompose']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_content' defaultMessage='Remove tracking parameters from displayed posts' />}>
<SettingToggle settings={settings} settingPath={['urlPrivacy', 'clearLinksInContent']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='url_privacy.allow_referral_marketing' defaultMessage='Make exception for referral marketing parameters' />}>
<SettingToggle
settings={settings}
settingPath={['urlPrivacy', 'allowReferralMarketing']}
onChange={onToggleChange}
disabled={!(settings.urlPrivacy.clearLinksInCompose || settings.urlPrivacy.clearLinksInContent)}
/>
</ListItem>
</List>
<FormGroup
labelText={<FormattedMessage id='url_privacy.rules_url.label' defaultMessage='URL cleaning rules database address' />}
hintText={<FormattedMessage id='url_privacy.rules_url.placeholder' defaultMessage='Rules database in ClearURLs-compatible format, eg. {url}' values={{ url: 'https://rules2.clearurls.xyz/data.minify.json' }} />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.rulesUrlPlaceholder)}
value={settings.urlPrivacy.rulesUrl}
// onChange={handleChange('tileServer', (e) => e.target.value)}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='url_privacy.hash_url.label' defaultMessage='URL cleaning rules hash address (optional)' />}
hintText={<FormattedMessage id='url_privacy.hash_url.placeholder' defaultMessage='SHA256 hash of rules database, used to avoid unnecessary fetches, eg. {url}' values={{ url: 'https://rules2.clearurls.xyz/rules.minify.hash' }} />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.hashUrlPlaceholder)}
value={settings.urlPrivacy.rulesUrl}
// onChange={handleChange('tileServer', (e) => e.target.value)}
/>
</FormGroup>
<FormActions>
<Button type='submit'>
<FormattedMessage id='url_privacy.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
</CardBody>
</Card>
</Column>
);
};
export { UrlPrivacy as default };

View File

@ -1447,6 +1447,7 @@
"settings.security": "Security",
"settings.sessions": "Active sessions",
"settings.settings": "Settings",
"settings.url_privacy": "URL privacy",
"shared.tos": "Terms of Service",
"signup_panel.sign_in.title": "Sign in",
"signup_panel.sign_in.title.external": "Sign in to external instance",
@ -1632,6 +1633,14 @@
"upload_form.preview": "Preview",
"upload_form.undo": "Delete",
"upload_progress.label": "Uploading…",
"url_privacy.allow_referral_marketing": "Make exception for referral marketing parameters",
"url_privacy.clear_links_in_compose": "Suggest removing tracking parameters when composing a post",
"url_privacy.clear_links_in_content": "Remove tracking parameters from displayed posts",
"url_privacy.hash_url.label": "URL cleaning rules hash address (optional)",
"url_privacy.hash_url.placeholder": "SHA256 hash of rules database, used to avoid unnecessary fetches, eg. {url}",
"url_privacy.rules_url.label": "URL cleaning rules database address",
"url_privacy.rules_url.placeholder": "Rules database in ClearURLs-compatible format, eg. {url}",
"url_privacy.save": "Save",
"video.download": "Download file",
"video.exit_fullscreen": "Exit full screen",
"video.fullscreen": "Full screen",

View File

@ -37,7 +37,13 @@ const settingsSchema = v.object({
autoTranslate: v.fallback(v.boolean(), false),
knownLanguages: v.fallback(v.array(v.string()), []),
showWrenchButton: v.fallback(v.boolean(), false),
cleanUrls: v.fallback(v.boolean(), false),
urlPrivacy: coerceObject({
clearLinksInCompose: v.fallback(v.boolean(), true),
clearLinksInContent: v.fallback(v.boolean(), true),
allowReferralMarketing: v.fallback(v.boolean(), false),
rulesUrl: v.fallback(v.string(), ''),
hashUrl: v.fallback(v.string(), ''),
}),
theme: v.fallback(v.optional(v.object({
brandColor: v.fallback(v.string(), ''),

View File

@ -14,6 +14,9 @@
// I hope I got this relicensing stuff right xD
import { URLPurify, type SerializedRules } from '@mkljczk/url-purify';
import KVStore from 'pl-fe/storage/kv-store';
import { store } from 'pl-fe/store';
// Adapted from ClearURLs Rules
// https://github.com/ClearURLs/Rules/blob/master/data.min.json
// Licensed under the LGPL-3.0 license.
@ -88,6 +91,24 @@ const DEFAULT_RULESET: SerializedRules = {
const Purify = new URLPurify({
rulesFromMemory: DEFAULT_RULESET,
onFetchedRules: (hash, rules) => {
const me = store.getState().auth.me;
KVStore.setItem('url-purify-rules:last', me || '');
KVStore.setItem(`url-purify-rules:${me}`, {
hash,
rules,
fetchedAt: Date.now(),
});
},
});
KVStore.getItem('url-purify-rules:last', (url: string) => {
if (!url) return;
KVStore.getItem(`url-purify-rules:${url}`, (rules: any) => {
if (!rules) return;
Purify.setRules(rules.rules, rules.hash);
});
});
export default Purify;