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:
2026-09-17 01:46:21 -04:00
co-authored by Claude Opus 5
parent 470b5217ae
commit 5b0d355417
8 changed files with 428 additions and 6 deletions
@@ -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,