diff --git a/packages/pl-fe/src/components/status-content.tsx b/packages/pl-fe/src/components/status-content.tsx index b1f3999ce..a71af2c31 100644 --- a/packages/pl-fe/src/components/status-content.tsx +++ b/packages/pl-fe/src/components/status-content.tsx @@ -82,7 +82,7 @@ const StatusContent: React.FC = React.memo(({ preview, withMedia, }) => { - const { cleanUrls, displaySpoilers } = useSettings(); + const { urlPrivacy, displaySpoilers } = useSettings(); const { greentext } = usePlFeConfig(); const [collapsed, setCollapsed] = useState(null); @@ -146,7 +146,7 @@ const StatusContent: React.FC = 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); diff --git a/packages/pl-fe/src/features/notifications/components/setting-toggle.tsx b/packages/pl-fe/src/features/notifications/components/setting-toggle.tsx index d3fa09016..060cf6360 100644 --- a/packages/pl-fe/src/features/notifications/components/setting-toggle.tsx +++ b/packages/pl-fe/src/features/notifications/components/setting-toggle.tsx @@ -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 = ({ id, settings, settingPath, onChange }) => { +const SettingToggle: React.FC = ({ id, settings, settingPath, onChange, disabled }) => { const handleChange: React.ChangeEventHandler = ({ target }) => { onChange(settingPath, target.checked); @@ -27,6 +29,7 @@ const SettingToggle: React.FC = ({ id, settings, settingPath, on id={id} checked={!!get(settings, settingPath)} onChange={handleChange} + disabled={disabled} /> ); }; diff --git a/packages/pl-fe/src/features/settings/index.tsx b/packages/pl-fe/src/features/settings/index.tsx index f89b76503..a1522f515 100644 --- a/packages/pl-fe/src/features/settings/index.tsx +++ b/packages/pl-fe/src/features/settings/index.tsx @@ -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 = () => { - {any([ - features.changeEmail, - features.changePassword, - features.manageMfa, - features.sessions, - ]) && ( - <> - - - + + + - - - {features.changeEmail && } - {features.changePassword && } - {features.manageMfa && ( - <> - - - {isMfaEnabled ? - intl.formatMessage(messages.mfaEnabled) : - intl.formatMessage(messages.mfaDisabled)} - - - - )} - {features.sessions && ( - - )} - - - - )} + + + {features.changeEmail && } + {features.changePassword && } + {features.manageMfa && ( + <> + + + {isMfaEnabled ? + intl.formatMessage(messages.mfaEnabled) : + intl.formatMessage(messages.mfaDisabled)} + + + + )} + {features.sessions && ( + + )} + + + {features.chats ? ( <> diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index d550867e4..115d363fa 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -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 = React.memo(({ chil {features.interactionRequests && } + diff --git a/packages/pl-fe/src/features/ui/util/async-components.ts b/packages/pl-fe/src/features/ui/util/async-components.ts index 6ebc3fcb4..0561a2bef 100644 --- a/packages/pl-fe/src/features/ui/util/async-components.ts +++ b/packages/pl-fe/src/features/ui/util/async-components.ts @@ -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 diff --git a/packages/pl-fe/src/features/url-privacy/index.tsx b/packages/pl-fe/src/features/url-privacy/index.tsx new file mode 100644 index 000000000..8bc4a73e7 --- /dev/null +++ b/packages/pl-fe/src/features/url-privacy/index.tsx @@ -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 ( + + + + + + + +
+ + }> + + + + }> + + + + }> + + + + + } + hintText={} + > + e.target.value)} + /> + + + } + hintText={} + > + e.target.value)} + /> + + + + + +
+
+
+
+ ); +}; + +export { UrlPrivacy as default }; diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index ffbb06b4f..2f27f5d5e 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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", diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index 289603159..e0fb07d3d 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -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(), ''), diff --git a/packages/pl-fe/src/utils/url-purify.ts b/packages/pl-fe/src/utils/url-purify.ts index 194cf3bc6..a3b32e405 100644 --- a/packages/pl-fe/src/utils/url-purify.ts +++ b/packages/pl-fe/src/utils/url-purify.ts @@ -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;