feat(privacy): strip tracking parameters from links on paste, send and render (#103)
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid, YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every recipient's click back to the person who shared the link. New src/app/utils/urlTracking.ts is a pure, local stripper: a global list + utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only removed on youtube/spotify. matrix.to and non-http(s) schemes are never rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc` variant selectors are deliberately kept. 13 unit tests. Wired at three points, all behind a new Settings → Privacy toggle (`stripTrackingParams`, default on): - paste: plain-text pastes are cleaned and re-inserted through Slate's own insertData so multi-line pastes still split into paragraphs; - send: RoomInput submit + schedule paths and MessageEditor saves clean both `body` and `formatted_body` (the HTML variant unescapes `&` around each URL and re-escapes it so the markup is untouched); - render: linkify `formatHref`/`format` and explicit `<a href>` in formatted_body are cleaned, so links sent from other clients are safe to click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so the toggle is a module flag kept current by ClientNonUIFeatures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -140,6 +140,7 @@ import { DraftIndicator } from './DraftIndicator';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -258,6 +259,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||||
// toolbar button entirely when it's off so it never opens an empty popover.
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
@@ -389,7 +392,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
[setSelectedFiles, room],
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||||
const handlePaste = useCallback<React.ClipboardEventHandler>(
|
||||
(evt) => {
|
||||
handleFilePaste(evt);
|
||||
if (evt.defaultPrevented || !stripTracking) return;
|
||||
const text = evt.clipboardData?.getData('text/plain');
|
||||
if (!text) return;
|
||||
const cleaned = stripTrackingParamsInText(text);
|
||||
if (cleaned === text) return;
|
||||
// Re-run Slate's own plain-text insertion with the cleaned string so
|
||||
// multi-line pastes still split into paragraphs exactly as before.
|
||||
if (typeof DataTransfer === 'undefined') return;
|
||||
evt.preventDefault();
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', cleaned);
|
||||
ReactEditor.insertData(editor, dt);
|
||||
},
|
||||
[handleFilePaste, stripTracking, editor],
|
||||
);
|
||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||
const { gifApiKey } = useClientConfig();
|
||||
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -623,6 +644,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
plainText = trimCommand(commandName, plainText);
|
||||
customHtml = trimCommand(commandName, customHtml);
|
||||
}
|
||||
if (stripTracking) {
|
||||
plainText = stripTrackingParamsInText(plainText);
|
||||
customHtml = stripTrackingParamsInHtml(customHtml);
|
||||
}
|
||||
if (commandName === Command.Me) {
|
||||
msgType = MsgType.Emote;
|
||||
} else if (commandName === Command.Notice) {
|
||||
@@ -718,6 +743,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
isMarkdown,
|
||||
commands,
|
||||
setToast,
|
||||
stripTracking,
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -740,8 +766,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
if (plainText === '') return null;
|
||||
|
||||
const body = plainText;
|
||||
const formattedBody = customHtml;
|
||||
const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText;
|
||||
const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml;
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
|
||||
const content: IContent = {
|
||||
@@ -769,7 +795,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
||||
}, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]);
|
||||
|
||||
const handleScheduleClick = useCallback(() => {
|
||||
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../../utils/room';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../../utils/urlTracking';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
|
||||
type MessageEditorProps = {
|
||||
@@ -87,6 +88,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
// [Gitea #103] Same paste/send stripping as RoomInput, for edits.
|
||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
const [toolbar, setToolbar] = useState(globalToolbar);
|
||||
const isComposing = useComposingCheck();
|
||||
|
||||
@@ -117,8 +120,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
const customHtml = trimCustomHtml(
|
||||
const rawPlainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
const rawCustomHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
@@ -126,6 +129,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
allowMath: true,
|
||||
}),
|
||||
);
|
||||
const plainText = stripTracking ? stripTrackingParamsInText(rawPlainText) : rawPlainText;
|
||||
const customHtml = stripTracking ? stripTrackingParamsInHtml(rawCustomHtml) : rawCustomHtml;
|
||||
|
||||
// Media caption edit: preserve the media, change only body/formatted_body.
|
||||
// An empty caption is valid (it removes the caption → body falls back to
|
||||
@@ -239,6 +244,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}, [
|
||||
stripTracking,
|
||||
mx,
|
||||
editor,
|
||||
roomId,
|
||||
|
||||
@@ -1439,10 +1439,18 @@ function Privacy() {
|
||||
settingsAtom,
|
||||
'warnOnUnverifiedDevices',
|
||||
);
|
||||
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Privacy</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Strip Tracking Parameters from Links"
|
||||
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
|
||||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Typing & Read Receipts"
|
||||
|
||||
Reference in New Issue
Block a user