Compare commits
2
Commits
470b5217ae
...
1ff28820f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff28820f3 | ||
|
|
5b0d355417 |
@@ -1171,6 +1171,14 @@ Persists via the `homeRoomSort` setting.
|
||||
|
||||
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
|
||||
|
||||
### Tracking-Parameter Stripping (Gitea #103)
|
||||
|
||||
Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `utm_*`, `fbclid`, `gclid`, YouTube `si=`, Amazon `ref=`/`tag=`, X `s=`/`t=`, TikTok `_r`/`_t`, and ~40 more, plus host-scoped rules so e.g. `si` is only removed on YouTube/Spotify. Runs entirely on the device (`src/app/utils/urlTracking.ts`, unit-tested). Wired at paste (re-inserted through Slate so multi-line pastes still split into paragraphs), at send/schedule/edit on both `body` and `formatted_body`, and at render (linkify + explicit `<a href>` in formatted HTML), so links from other clients are cleaned locally too. `matrix.to` and non-http(s) schemes are never touched; Amazon's `th`/`psc` variant selectors are kept. Toggle in **Settings → Privacy → Strip Tracking Parameters from Links** (default on).
|
||||
|
||||
### Settings Sync Across Devices (Gitea #104)
|
||||
|
||||
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
||||
|
||||
### Media Gallery
|
||||
|
||||
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||
|
||||
+3
-1
@@ -236,7 +236,7 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what
|
||||
|
||||
**✅ Buildable NOW (client-only, no server/infra change):**
|
||||
|
||||
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
|
||||
- [ ] **Custom room tags / sections** (Gitea **#108**, milestone _Features 2026-Q4_) — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
|
||||
|
||||
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
|
||||
|
||||
@@ -258,6 +258,8 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what
|
||||
|
||||
## 📋 Open Feature Backlog
|
||||
|
||||
**Features 2026-Q4 milestone (Gitea):** #103 tracking-param stripping ✅ · #104 settings sync ✅ · #105 desktop keychain + idle lock (research) · #106 search operators (research) · #107 paste-as-code (research) · #108 custom room sections.
|
||||
|
||||
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
|
||||
|
||||
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
ML_DENOISE_REQUIREMENTS,
|
||||
} from '../../../utils/lotusDenoiseUtils';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
@@ -1439,10 +1440,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"
|
||||
@@ -1487,6 +1496,90 @@ function Privacy() {
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||
// device-local (never synced) so switching it off here sticks.
|
||||
function SettingsSyncSection() {
|
||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
const run = async (action: 'push' | 'clear') => {
|
||||
setBusy(action);
|
||||
setNote(null);
|
||||
try {
|
||||
if (action === 'push') {
|
||||
await pushNow();
|
||||
setNote('Pushed — other devices will pick these settings up on their next sync.');
|
||||
} else {
|
||||
await clearRemote();
|
||||
setNote('Cleared — devices keep their current settings until one pushes again.');
|
||||
}
|
||||
} catch {
|
||||
setNote('Failed — check your connection and try again.');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Sync</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Sync Settings Across Devices"
|
||||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{settingsSync && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Use This Device's Settings Everywhere"
|
||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||
after={
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
|
||||
onClick={() => run('push')}
|
||||
>
|
||||
<Text size="B300">Push now</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
|
||||
onClick={() => run('clear')}
|
||||
>
|
||||
<Text size="B300">Clear synced copy</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{note && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S300}` }}
|
||||
>
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||||
@@ -2588,6 +2681,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Privacy />
|
||||
<SettingsSyncSection />
|
||||
<Calls />
|
||||
<AppUpdates />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { ClientEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../utils/accountData';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { Settings, getSettings, settingsAtom } from '../state/settings';
|
||||
import {
|
||||
SETTINGS_SYNC_EVENT,
|
||||
buildSyncedContent,
|
||||
isSyncedSettingsContent,
|
||||
mergeRemoteSettings,
|
||||
pickSyncable,
|
||||
shouldApplyRemote,
|
||||
syncableEqual,
|
||||
} from '../utils/settingsSync';
|
||||
|
||||
// Keyed per account: a different user signing in on this device must not
|
||||
// inherit the previous user's "last synced" stamp.
|
||||
const metaKey = (userId: string): string => `settings-sync-meta:${userId}`;
|
||||
const PUSH_DEBOUNCE_MS = 1500;
|
||||
|
||||
type SyncMeta = { lastSyncedAt: number };
|
||||
|
||||
const readMeta = (userId: string): SyncMeta | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(metaKey(userId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SyncMeta>;
|
||||
return typeof parsed.lastSyncedAt === 'number' ? { lastSyncedAt: parsed.lastSyncedAt } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeMeta = (userId: string, meta: SyncMeta): void => {
|
||||
try {
|
||||
localStorage.setItem(metaKey(userId), JSON.stringify(meta));
|
||||
} catch {
|
||||
/* quota / blocked storage — sync still works for this session */
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #104] Keeps the syncable subset of Settings mirrored to the
|
||||
* `io.lotus.settings` account-data event and applies snapshots other devices
|
||||
* push. Mount once (ClientNonUIFeatures) while a client is running.
|
||||
*
|
||||
* Flow:
|
||||
* - start: if the remote snapshot is newer than what this device last synced,
|
||||
* apply it; otherwise push local if it differs from remote.
|
||||
* - local change (any settingsAtom write): debounce, then push if the syncable
|
||||
* subset differs from the last pushed/applied snapshot — so applying a
|
||||
* remote snapshot never echoes it straight back.
|
||||
* - remote AccountData event: apply if newer than our last sync stamp (our own
|
||||
* push comes back with an equal stamp and is ignored).
|
||||
* Stamps are wall-clock ms but forced monotonic per device, so a device with a
|
||||
* slow clock can still win once it makes a later change.
|
||||
*/
|
||||
export function useSettingsSync(): void {
|
||||
const mx = useMatrixClient();
|
||||
const store = useStore();
|
||||
const [enabled] = useSetting(settingsAtom, 'settingsSync');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
|
||||
let lastSyncedAt: number | null = readMeta(userId)?.lastSyncedAt ?? null;
|
||||
let lastSnapshot: Partial<Settings> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const stamp = (): number => Math.max(Date.now(), (lastSyncedAt ?? 0) + 1);
|
||||
|
||||
const applyRemote = (content: unknown): boolean => {
|
||||
if (!isSyncedSettingsContent(content)) return false;
|
||||
if (!shouldApplyRemote(content, lastSyncedAt)) return false;
|
||||
const merged = mergeRemoteSettings(store.get(settingsAtom), content);
|
||||
lastSyncedAt = content.updatedAt;
|
||||
writeMeta(userId, { lastSyncedAt });
|
||||
store.set(settingsAtom, merged);
|
||||
// Re-read through getSettings() so enum coercion applies to whatever the
|
||||
// other device sent, and remember that coerced view as "already synced".
|
||||
const coerced = getSettings();
|
||||
store.set(settingsAtom, coerced);
|
||||
lastSnapshot = pickSyncable(coerced);
|
||||
return true;
|
||||
};
|
||||
|
||||
const push = (): void => {
|
||||
if (disposed) return;
|
||||
const current = store.get(settingsAtom);
|
||||
const syncable = pickSyncable(current);
|
||||
if (lastSnapshot && syncableEqual(syncable, lastSnapshot)) return;
|
||||
const content = buildSyncedContent(current, stamp());
|
||||
const previousSnapshot = lastSnapshot;
|
||||
const previousStamp = lastSyncedAt;
|
||||
lastSnapshot = content.settings;
|
||||
lastSyncedAt = content.updatedAt;
|
||||
writeMeta(userId, { lastSyncedAt });
|
||||
setAccountData(mx, SETTINGS_SYNC_EVENT, content).catch(() => {
|
||||
// Roll back so the next local change (or reload) retries the push.
|
||||
if (disposed) return;
|
||||
lastSnapshot = previousSnapshot;
|
||||
lastSyncedAt = previousStamp;
|
||||
if (previousStamp !== null) writeMeta(userId, { lastSyncedAt: previousStamp });
|
||||
});
|
||||
};
|
||||
|
||||
const schedulePush = (): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(push, PUSH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Initial reconcile.
|
||||
const existing = getAccountData<unknown>(mx, SETTINGS_SYNC_EVENT);
|
||||
if (!applyRemote(existing)) {
|
||||
// Remote is absent, invalid, or not newer than what we last synced: treat
|
||||
// it as the baseline and push only if this device differs from it.
|
||||
lastSnapshot = isSyncedSettingsContent(existing) ? existing.settings : null;
|
||||
push();
|
||||
}
|
||||
|
||||
const unsubscribe = store.sub(settingsAtom, schedulePush);
|
||||
const handleAccountData = (evt: MatrixEvent): void => {
|
||||
if (evt.getType() !== SETTINGS_SYNC_EVENT) return;
|
||||
applyRemote(evt.getContent());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe();
|
||||
mx.off(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, store, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
|
||||
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
||||
* blanks the account-data event (other devices ignore the invalid content and
|
||||
* keep their local settings until someone pushes again).
|
||||
*/
|
||||
export function useSettingsSyncActions(): {
|
||||
pushNow: () => Promise<void>;
|
||||
clearRemote: () => Promise<void>;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const store = useStore();
|
||||
|
||||
const pushNow = useCallback(async () => {
|
||||
const content = buildSyncedContent(store.get(settingsAtom), Date.now());
|
||||
await setAccountData(mx, SETTINGS_SYNC_EVENT, content);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) writeMeta(userId, { lastSyncedAt: content.updatedAt });
|
||||
}, [mx, store]);
|
||||
|
||||
const clearRemote = useCallback(async () => {
|
||||
await setAccountData(mx, SETTINGS_SYNC_EVENT, {});
|
||||
}, [mx]);
|
||||
|
||||
return { pushNow, clearRemote };
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { notificationPermission, setFavicon, showOsNotification } from '../../ut
|
||||
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
|
||||
import { useSettingsSync } from '../../hooks/useSettingsSync';
|
||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||
@@ -88,6 +90,21 @@ function SystemEmojiFeature() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
|
||||
function TrackingParamsFeature() {
|
||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
useEffect(() => {
|
||||
setStripTrackingOnRender(stripTracking);
|
||||
}, [stripTracking]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// [Gitea #104] Mirror user preferences to account data and apply remote snapshots.
|
||||
function SettingsSyncFeature() {
|
||||
useSettingsSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
function PageZoomFeature() {
|
||||
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||
|
||||
@@ -887,6 +904,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<SearchCacheInvalidationFeature />
|
||||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<TrackingParamsFeature />
|
||||
<SettingsSyncFeature />
|
||||
<FaviconUpdater />
|
||||
<PresenceUpdater />
|
||||
<MuteTimerRestore />
|
||||
|
||||
@@ -22,6 +22,7 @@ import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyj
|
||||
import Linkify from 'linkify-react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ChildNode } from 'domhandler';
|
||||
import { stripTrackingParams } from '../utils/urlTracking';
|
||||
import * as css from '../styles/CustomHtml.css';
|
||||
import {
|
||||
getMxIdLocalPart,
|
||||
@@ -106,11 +107,30 @@ const renderMath = (
|
||||
|
||||
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||
|
||||
// [Gitea #103] Render-time tracking-param stripping. LINKIFY_OPTS is spread
|
||||
// into memoised per-timeline option objects, so the toggle lives in a module
|
||||
// flag that the format callbacks read lazily on every render; the setting's
|
||||
// subscriber (ClientNonUIFeatures) keeps it current.
|
||||
let stripTrackingOnRender = true;
|
||||
export const setStripTrackingOnRender = (enabled: boolean): void => {
|
||||
stripTrackingOnRender = enabled;
|
||||
};
|
||||
const cleanHref = (href: string): string =>
|
||||
stripTrackingOnRender ? stripTrackingParams(href) : href;
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
attributes: {
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
},
|
||||
formatHref: {
|
||||
url: cleanHref,
|
||||
},
|
||||
// Only the visible text of bare URLs is a URL, so cleaning it keeps the
|
||||
// label honest about where the link goes.
|
||||
format: {
|
||||
url: cleanHref,
|
||||
},
|
||||
validate: {
|
||||
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
||||
},
|
||||
@@ -393,6 +413,12 @@ export const getReactCustomHtmlParser = (
|
||||
replace: (domNode) => {
|
||||
if (domNode instanceof Element && 'name' in domNode) {
|
||||
const { name, attribs, children, parent } = domNode;
|
||||
// [Gitea #103] Clean explicit <a href> targets from formatted_body
|
||||
// before any branch below (mention detection, default render) sees them.
|
||||
if (name === 'a' && stripTrackingOnRender && typeof attribs.href === 'string') {
|
||||
const cleaned = stripTrackingParams(attribs.href);
|
||||
if (cleaned !== attribs.href) attribs.href = cleaned;
|
||||
}
|
||||
const props = attributesToProps(attribs);
|
||||
|
||||
if (name === 'h1') {
|
||||
|
||||
@@ -263,6 +263,14 @@ export interface Settings {
|
||||
|
||||
warnOnUnverifiedDevices: boolean;
|
||||
|
||||
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
||||
// send, and from links rendered in the timeline. Local only.
|
||||
stripTrackingParams: boolean;
|
||||
|
||||
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||
settingsSync: boolean;
|
||||
|
||||
pauseAnimations: boolean;
|
||||
|
||||
composerToolbarButtons: ComposerToolbarSettings;
|
||||
@@ -372,6 +380,10 @@ const defaultSettings: Settings = {
|
||||
|
||||
warnOnUnverifiedDevices: false,
|
||||
|
||||
stripTrackingParams: true,
|
||||
|
||||
settingsSync: true,
|
||||
|
||||
pauseAnimations: false,
|
||||
|
||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEVICE_LOCAL_KEYS,
|
||||
SETTINGS_SYNC_VERSION,
|
||||
buildSyncedContent,
|
||||
isSyncedSettingsContent,
|
||||
mergeRemoteSettings,
|
||||
pickSyncable,
|
||||
shouldApplyRemote,
|
||||
syncableEqual,
|
||||
} from './settingsSync';
|
||||
import { Settings } from '../state/settings';
|
||||
|
||||
// A representative slice of Settings; the helpers are generic over keys so a
|
||||
// partial object cast is enough to exercise every branch.
|
||||
const base = {
|
||||
settingsSync: true,
|
||||
pageZoom: 110,
|
||||
isMarkdown: true,
|
||||
quietHoursEnabled: false,
|
||||
quietHoursStart: '22:00',
|
||||
composerToolbarButtons: { showGif: true, order: ['showEmoji', 'showGif'] },
|
||||
pttKey: 'KeyV',
|
||||
callDenoiseModel: 'rnnoise',
|
||||
} as unknown as Settings;
|
||||
|
||||
describe('pickSyncable', () => {
|
||||
it('drops device-local keys and deep-copies objects', () => {
|
||||
const picked = pickSyncable(base);
|
||||
assert.equal('pageZoom' in picked, false);
|
||||
assert.equal('settingsSync' in picked, false);
|
||||
assert.equal('callDenoiseModel' in picked, false);
|
||||
assert.equal(picked.isMarkdown, true);
|
||||
assert.equal(picked.pttKey, 'KeyV');
|
||||
assert.notEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
|
||||
assert.deepEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
|
||||
});
|
||||
|
||||
it('never syncs the sync toggle itself', () => {
|
||||
assert.equal(DEVICE_LOCAL_KEYS.has('settingsSync'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncableEqual', () => {
|
||||
it('is structural and key-order independent', () => {
|
||||
const a = pickSyncable(base);
|
||||
const b = Object.fromEntries(Object.entries(a).reverse()) as Partial<Settings>;
|
||||
assert.equal(syncableEqual(a, b), true);
|
||||
assert.equal(syncableEqual(a, { ...a, isMarkdown: false }), false);
|
||||
assert.equal(syncableEqual(a, { ...a, extra: 1 } as Partial<Settings>), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSyncedSettingsContent', () => {
|
||||
it('accepts a well-formed event and rejects junk', () => {
|
||||
assert.equal(isSyncedSettingsContent(buildSyncedContent(base, 5)), true);
|
||||
assert.equal(isSyncedSettingsContent({}), false);
|
||||
assert.equal(isSyncedSettingsContent(null), false);
|
||||
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 'x', settings: {} }), false);
|
||||
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 1, settings: [] }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeRemoteSettings', () => {
|
||||
it('applies syncable keys, ignores device-local and unknown keys and wrong shapes', () => {
|
||||
const remote = {
|
||||
version: SETTINGS_SYNC_VERSION,
|
||||
updatedAt: 10,
|
||||
settings: {
|
||||
isMarkdown: false,
|
||||
pageZoom: 200, // device-local: ignored
|
||||
quietHoursStart: 42, // wrong type: ignored
|
||||
composerToolbarButtons: 'nope', // wrong type: ignored
|
||||
futureKey: true, // unknown: ignored
|
||||
} as unknown as Partial<Settings>,
|
||||
};
|
||||
const merged = mergeRemoteSettings(base, remote);
|
||||
assert.equal(merged.isMarkdown, false);
|
||||
assert.equal(merged.pageZoom, 110);
|
||||
assert.equal(merged.quietHoursStart, '22:00');
|
||||
assert.deepEqual(merged.composerToolbarButtons, base.composerToolbarButtons);
|
||||
assert.equal('futureKey' in merged, false);
|
||||
// Never mutates the input.
|
||||
assert.equal(base.isMarkdown, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldApplyRemote', () => {
|
||||
it('applies when never synced or strictly newer; ignores echoes', () => {
|
||||
const remote = buildSyncedContent(base, 100);
|
||||
assert.equal(shouldApplyRemote(remote, null), true);
|
||||
assert.equal(shouldApplyRemote(remote, 99), true);
|
||||
assert.equal(shouldApplyRemote(remote, 100), false);
|
||||
assert.equal(shouldApplyRemote(remote, 101), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncedContent', () => {
|
||||
it('stamps version and updatedAt around the syncable subset', () => {
|
||||
const c = buildSyncedContent(base, 7);
|
||||
assert.equal(c.version, SETTINGS_SYNC_VERSION);
|
||||
assert.equal(c.updatedAt, 7);
|
||||
assert.deepEqual(c.settings, pickSyncable(base));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Settings } from '../state/settings';
|
||||
|
||||
/**
|
||||
* [Gitea #104] Settings sync — pure helpers.
|
||||
*
|
||||
* Lotus settings live in `localStorage` per device. A syncable subset is
|
||||
* mirrored to the account-data event `io.lotus.settings` on the user's own
|
||||
* homeserver so a second device picks it up. Everything device-bound stays
|
||||
* local (see DEVICE_LOCAL_KEYS). Conflicts are last-write-wins on the
|
||||
* `updatedAt` stamp inside the event; a device that applied a remote snapshot
|
||||
* remembers it so the resulting local change is not echoed straight back.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SYNC_EVENT = 'io.lotus.settings';
|
||||
export const SETTINGS_SYNC_VERSION = 1;
|
||||
|
||||
export type SyncedSettingsContent = {
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
settings: Partial<Settings>;
|
||||
};
|
||||
|
||||
// Keys that describe THIS device (display, hardware, bandwidth, CPU budget,
|
||||
// transient UI state, per-device permissions) rather than the user's
|
||||
// preferences. Never written to, or read from, the synced event. The sync
|
||||
// toggle itself is local too, so turning it off on one device is not undone by
|
||||
// another.
|
||||
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
||||
'settingsSync',
|
||||
'pageZoom',
|
||||
'mediaAutoLoad',
|
||||
'pauseAnimations',
|
||||
'glassmorphismSidebar',
|
||||
'isPeopleDrawer',
|
||||
'memberSortFilterIndex',
|
||||
'presenceStatus',
|
||||
'showNotifications',
|
||||
'developerTools',
|
||||
'pttMode',
|
||||
'callNoiseSuppression',
|
||||
'callDenoiseModel',
|
||||
'callDenoiseNativeNS',
|
||||
'callDenoiseGate',
|
||||
'callDenoiseGateThreshold',
|
||||
'callAudioBitrate',
|
||||
'screenshareBitrate',
|
||||
'screenshareFramerate',
|
||||
'ringtoneVolume',
|
||||
'soundboardVolume',
|
||||
'cameraOnJoin',
|
||||
]);
|
||||
|
||||
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */
|
||||
export const pickSyncable = (settings: Settings): Partial<Settings> => {
|
||||
const out: Partial<Settings> = {};
|
||||
(Object.keys(settings) as (keyof Settings)[]).forEach((key) => {
|
||||
if (DEVICE_LOCAL_KEYS.has(key)) return;
|
||||
const value = settings[key];
|
||||
if (value === undefined) return;
|
||||
(out as Record<string, unknown>)[key] =
|
||||
typeof value === 'object' && value !== null ? JSON.parse(JSON.stringify(value)) : value;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Structural equality of two syncable subsets (key order independent). */
|
||||
export const syncableEqual = (a: Partial<Settings>, b: Partial<Settings>): boolean => {
|
||||
const ka = Object.keys(a).sort();
|
||||
const kb = Object.keys(b).sort();
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (let i = 0; i < ka.length; i += 1) {
|
||||
if (ka[i] !== kb[i]) return false;
|
||||
const va = (a as Record<string, unknown>)[ka[i]];
|
||||
const vb = (b as Record<string, unknown>)[kb[i]];
|
||||
if (JSON.stringify(va) !== JSON.stringify(vb)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Type guard for what comes back from account data (any client, any version). */
|
||||
export const isSyncedSettingsContent = (content: unknown): content is SyncedSettingsContent => {
|
||||
if (typeof content !== 'object' || content === null) return false;
|
||||
const c = content as Record<string, unknown>;
|
||||
return (
|
||||
typeof c.version === 'number' &&
|
||||
typeof c.updatedAt === 'number' &&
|
||||
Number.isFinite(c.updatedAt) &&
|
||||
typeof c.settings === 'object' &&
|
||||
c.settings !== null &&
|
||||
!Array.isArray(c.settings)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Overlay a remote snapshot onto the local settings. Only syncable keys the
|
||||
* local schema knows about are taken (so a newer client's extra keys, or a
|
||||
* device-local key smuggled in by a buggy writer, are ignored). Values are
|
||||
* copied verbatim; the caller runs the result through the normal
|
||||
* `getSettings()` coercion by persisting it, which is what validates enums.
|
||||
*/
|
||||
export const mergeRemoteSettings = (local: Settings, remote: SyncedSettingsContent): Settings => {
|
||||
const merged: Settings = { ...local };
|
||||
(Object.keys(remote.settings) as (keyof Settings)[]).forEach((key) => {
|
||||
if (DEVICE_LOCAL_KEYS.has(key)) return;
|
||||
if (!(key in local)) return;
|
||||
const value = remote.settings[key];
|
||||
if (value === undefined) return;
|
||||
// Shape guard: a value of a different JS type than this build stores for
|
||||
// the key (e.g. a renamed enum that became an object) is skipped; enum
|
||||
// *values* are validated by getSettings() coercion on the next load.
|
||||
const current = local[key];
|
||||
if (current !== undefined && current !== null && typeof value !== typeof current) return;
|
||||
if (Array.isArray(current) !== Array.isArray(value)) return;
|
||||
(merged as unknown as Record<string, unknown>)[key] = value;
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
export const buildSyncedContent = (
|
||||
settings: Settings,
|
||||
updatedAt: number = Date.now(),
|
||||
): SyncedSettingsContent => ({
|
||||
version: SETTINGS_SYNC_VERSION,
|
||||
updatedAt,
|
||||
settings: pickSyncable(settings),
|
||||
});
|
||||
|
||||
/**
|
||||
* Should a remote snapshot be applied over what this device last synced?
|
||||
* `lastSyncedAt` is the stamp of the snapshot this device last pushed or
|
||||
* applied; anything older or equal is our own echo (or a stale write that lost
|
||||
* the race) and is ignored.
|
||||
*/
|
||||
export const shouldApplyRemote = (
|
||||
remote: SyncedSettingsContent,
|
||||
lastSyncedAt: number | null,
|
||||
): boolean => lastSyncedAt === null || remote.updatedAt > lastSyncedAt;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
stripTrackingParams,
|
||||
stripTrackingParamsInHtml,
|
||||
stripTrackingParamsInText,
|
||||
} from './urlTracking';
|
||||
|
||||
describe('stripTrackingParams', () => {
|
||||
it('removes utm_* and known global params, keeps the rest', () => {
|
||||
const out = stripTrackingParams(
|
||||
'https://example.com/a?utm_source=x&utm_medium=y&fbclid=abc&page=2&gclid=1',
|
||||
);
|
||||
assert.equal(out, 'https://example.com/a?page=2');
|
||||
});
|
||||
|
||||
it('drops the dangling ? when every param was tracking', () => {
|
||||
assert.equal(
|
||||
stripTrackingParams('https://example.com/a?utm_source=x'),
|
||||
'https://example.com/a',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the identical string when nothing changed', () => {
|
||||
const url = 'https://example.com/a?page=2#frag';
|
||||
assert.equal(stripTrackingParams(url), url);
|
||||
});
|
||||
|
||||
it('preserves the fragment', () => {
|
||||
assert.equal(
|
||||
stripTrackingParams('https://example.com/a?utm_source=x#section-3'),
|
||||
'https://example.com/a#section-3',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies host rules only on that host', () => {
|
||||
assert.equal(
|
||||
stripTrackingParams('https://youtu.be/dQw4w9WgXcQ?si=abc123&t=42'),
|
||||
'https://youtu.be/dQw4w9WgXcQ?t=42',
|
||||
);
|
||||
assert.equal(
|
||||
stripTrackingParams('https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=share&si=x'),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
);
|
||||
// `si` is not tracking elsewhere.
|
||||
const other = 'https://example.com/?si=keep';
|
||||
assert.equal(stripTrackingParams(other), other);
|
||||
});
|
||||
|
||||
it('strips amazon affiliate/telemetry params and the /ref= path segment but keeps variant selectors', () => {
|
||||
assert.equal(
|
||||
stripTrackingParams(
|
||||
'https://www.amazon.com/dp/B000000000/ref=sr_1_3?crid=ABC&keywords=x&qid=1&sr=8-3&tag=aff-20&th=1&psc=1&pd_rd_r=zz',
|
||||
),
|
||||
'https://www.amazon.com/dp/B000000000?keywords=x&th=1&psc=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('never rewrites matrix.to permalinks or non-http schemes', () => {
|
||||
const permalink = 'https://matrix.to/#/!room:example.org/$event?via=example.org&utm_source=x';
|
||||
assert.equal(stripTrackingParams(permalink), permalink);
|
||||
assert.equal(
|
||||
stripTrackingParams('mxc://example.org/abc?utm_source=x'),
|
||||
'mxc://example.org/abc?utm_source=x',
|
||||
);
|
||||
assert.equal(stripTrackingParams('mailto:a@b.c?utm_source=x'), 'mailto:a@b.c?utm_source=x');
|
||||
});
|
||||
|
||||
it('returns unparseable input unchanged', () => {
|
||||
assert.equal(stripTrackingParams('https://'), 'https://');
|
||||
assert.equal(stripTrackingParams('not a url'), 'not a url');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = stripTrackingParams('https://x.com/user/status/1?s=20&t=abc');
|
||||
assert.equal(once, 'https://x.com/user/status/1');
|
||||
assert.equal(stripTrackingParams(once), once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripTrackingParamsInText', () => {
|
||||
it('cleans every URL in a block of text and keeps trailing punctuation', () => {
|
||||
const text =
|
||||
'see https://a.example/p?utm_source=x. and (https://b.example/?id=1&fbclid=2) plus https://c.example/?keep=1';
|
||||
assert.equal(
|
||||
stripTrackingParamsInText(text),
|
||||
'see https://a.example/p. and (https://b.example/?id=1) plus https://c.example/?keep=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the identical string when no URL changed', () => {
|
||||
const text = 'nothing https://c.example/?keep=1 here';
|
||||
assert.equal(stripTrackingParamsInText(text), text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripTrackingParamsInHtml', () => {
|
||||
it('unescapes & for parsing and re-escapes in the output', () => {
|
||||
const html =
|
||||
'<a href="https://a.example/p?id=1&utm_source=x&page=2">https://a.example/p?id=1&utm_source=x&page=2</a>';
|
||||
assert.equal(
|
||||
stripTrackingParamsInHtml(html),
|
||||
'<a href="https://a.example/p?id=1&page=2">https://a.example/p?id=1&page=2</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves markup untouched when nothing is stripped', () => {
|
||||
const html = '<p>hi <a href="https://a.example/?x=1">link</a></p>';
|
||||
assert.equal(stripTrackingParamsInHtml(html), html);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* [Gitea #103] Tracking-parameter stripping.
|
||||
*
|
||||
* Shared links routinely carry ad/analytics identifiers (`utm_*`, `fbclid`,
|
||||
* YouTube `si=`, Amazon `ref=`…) that tie every recipient's click back to the
|
||||
* person who shared the link. Everything here is pure and local: a URL goes in,
|
||||
* the same URL minus known tracking params comes out. Unknown params are never
|
||||
* touched, and anything that fails to parse is returned unchanged.
|
||||
*/
|
||||
|
||||
// Exact query-parameter names stripped on every host.
|
||||
const GLOBAL_PARAMS = new Set([
|
||||
// Google Ads / Analytics
|
||||
'gclid',
|
||||
'gclsrc',
|
||||
'dclid',
|
||||
'gbraid',
|
||||
'wbraid',
|
||||
'srsltid',
|
||||
'_ga',
|
||||
'_gl',
|
||||
// Meta / Instagram
|
||||
'fbclid',
|
||||
'igshid',
|
||||
'igsh',
|
||||
// Microsoft / Bing
|
||||
'msclkid',
|
||||
// X / Twitter
|
||||
'twclid',
|
||||
// TikTok
|
||||
'ttclid',
|
||||
// Yandex
|
||||
'yclid',
|
||||
// Mailchimp
|
||||
'mc_cid',
|
||||
'mc_eid',
|
||||
// HubSpot
|
||||
'_hsenc',
|
||||
'_hsmi',
|
||||
'hsCtaTracking',
|
||||
// Marketo / Adobe
|
||||
'mkt_tok',
|
||||
's_kwcid',
|
||||
'ef_id',
|
||||
// Vero / Wicked Reports / Omeda
|
||||
'vero_id',
|
||||
'vero_conv',
|
||||
'wickedid',
|
||||
'oly_anon_id',
|
||||
'oly_enc_id',
|
||||
// Generic referrer tags
|
||||
'ref_src',
|
||||
'ref_url',
|
||||
'spm',
|
||||
]);
|
||||
|
||||
// Query-parameter name prefixes stripped on every host.
|
||||
const GLOBAL_PREFIXES = ['utm_', 'pk_', 'piwik_', 'matomo_'];
|
||||
|
||||
// Host-specific params that only act as tracking on that site. Matched by
|
||||
// hostname suffix (so `www.youtube.com` and `m.youtube.com` both hit `youtube.com`).
|
||||
const AMAZON_HOSTS = [
|
||||
'amazon.com',
|
||||
'amazon.co.uk',
|
||||
'amazon.de',
|
||||
'amazon.ca',
|
||||
'amazon.fr',
|
||||
'amazon.co.jp',
|
||||
];
|
||||
|
||||
const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[] }> = [
|
||||
{
|
||||
hosts: ['youtube.com', 'youtu.be'],
|
||||
params: ['si', 'feature', 'pp'],
|
||||
},
|
||||
{
|
||||
hosts: AMAZON_HOSTS,
|
||||
// Affiliate + search-result telemetry only. `th`/`psc` pick the product
|
||||
// variant that loads and are deliberately NOT stripped.
|
||||
params: [
|
||||
'ref',
|
||||
'ref_',
|
||||
'tag',
|
||||
'linkCode',
|
||||
'linkId',
|
||||
'crid',
|
||||
'sprefix',
|
||||
'qid',
|
||||
'sr',
|
||||
'dib',
|
||||
'dib_tag',
|
||||
'content-id',
|
||||
],
|
||||
prefixes: ['pd_rd_', 'pf_rd_'],
|
||||
},
|
||||
{
|
||||
hosts: ['x.com', 'twitter.com'],
|
||||
params: ['s', 't', 'ref_src'],
|
||||
},
|
||||
{
|
||||
hosts: ['spotify.com'],
|
||||
params: ['si', 'nd'],
|
||||
},
|
||||
{
|
||||
hosts: ['reddit.com'],
|
||||
params: ['share_id', 'rdt', 'context'],
|
||||
},
|
||||
{
|
||||
hosts: ['tiktok.com'],
|
||||
params: ['_r', '_t', 'is_from_webapp', 'sender_device', 'web_id'],
|
||||
},
|
||||
{
|
||||
hosts: ['instagram.com'],
|
||||
params: ['igsh', 'img_index'],
|
||||
},
|
||||
{
|
||||
hosts: ['threads.net'],
|
||||
params: ['igshid'],
|
||||
},
|
||||
{
|
||||
hosts: ['bilibili.com'],
|
||||
params: ['spm_id_from', 'vd_source'],
|
||||
},
|
||||
{
|
||||
hosts: ['ebay.com', 'ebay.co.uk', 'ebay.de'],
|
||||
params: ['_trkparms', '_trksid', 'mkcid', 'mkevt', 'mkrid', 'campid', 'toolid', 'customid'],
|
||||
},
|
||||
];
|
||||
|
||||
// Hosts whose URLs are never rewritten: Matrix permalinks encode room/event
|
||||
// ids after a fragment and have no tracking, but a false positive would break
|
||||
// navigation, so leave the whole family alone.
|
||||
const SKIP_HOSTS = ['matrix.to'];
|
||||
|
||||
const hostMatches = (hostname: string, suffixes: string[]): boolean =>
|
||||
suffixes.some((s) => hostname === s || hostname.endsWith(`.${s}`));
|
||||
|
||||
const isTrackingParam = (name: string, hostname: string): boolean => {
|
||||
if (GLOBAL_PARAMS.has(name)) return true;
|
||||
const lower = name.toLowerCase();
|
||||
if (GLOBAL_PREFIXES.some((p) => lower.startsWith(p))) return true;
|
||||
return HOST_PARAMS.some(
|
||||
(rule) =>
|
||||
hostMatches(hostname, rule.hosts) &&
|
||||
(rule.params.includes(name) || (rule.prefixes ?? []).some((p) => name.startsWith(p))),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove known tracking query parameters from an `http(s)` URL. Returns the
|
||||
* input unchanged when it is not an http(s) URL, cannot be parsed, or has
|
||||
* nothing to strip — so callers can compare by identity to know whether
|
||||
* anything happened. Never touches the fragment or the path (except Amazon's
|
||||
* `/ref=…` path segment, which is a pure referrer tag).
|
||||
*/
|
||||
export const stripTrackingParams = (url: string): string => {
|
||||
if (!/^https?:\/\//i.test(url)) return url;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (hostMatches(hostname, SKIP_HOSTS)) return url;
|
||||
|
||||
let changed = false;
|
||||
const params = parsed.searchParams;
|
||||
Array.from(params.keys()).forEach((name) => {
|
||||
if (isTrackingParam(name, hostname)) {
|
||||
params.delete(name);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hostMatches(hostname, AMAZON_HOSTS)) {
|
||||
const cleanedPath = parsed.pathname.replace(/\/ref=[^/]*/i, '');
|
||||
if (cleanedPath !== parsed.pathname) {
|
||||
parsed.pathname = cleanedPath;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return url;
|
||||
// URLSearchParams re-serialises the remaining query; when it is now empty,
|
||||
// drop the dangling `?` entirely.
|
||||
if (params.toString() === '') parsed.search = '';
|
||||
return parsed.toString();
|
||||
};
|
||||
|
||||
// Matches an http(s) URL inside free text, stopping before whitespace, quotes,
|
||||
// angle brackets or a closing paren/bracket. Trailing sentence punctuation is
|
||||
// trimmed separately so "see https://a.b/?utm_source=x." keeps its full stop.
|
||||
const URL_IN_TEXT = /https?:\/\/[^\s<>"'()[\]]+/gi;
|
||||
const TRAILING_PUNCT = /[.,;:!?]+$/;
|
||||
|
||||
/**
|
||||
* Strip tracking params from every http(s) URL found in a block of text (the
|
||||
* plaintext `body`, or a pasted clipboard string). Returns the input unchanged
|
||||
* if nothing was stripped.
|
||||
*/
|
||||
export const stripTrackingParamsInText = (text: string): string => {
|
||||
let changed = false;
|
||||
const out = text.replace(URL_IN_TEXT, (match) => {
|
||||
const punct = match.match(TRAILING_PUNCT)?.[0] ?? '';
|
||||
const url = punct ? match.slice(0, -punct.length) : match;
|
||||
const cleaned = stripTrackingParams(url);
|
||||
if (cleaned === url) return match;
|
||||
changed = true;
|
||||
return cleaned + punct;
|
||||
});
|
||||
return changed ? out : text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as `stripTrackingParamsInText` for an HTML string (`formatted_body`),
|
||||
* where `&` inside attribute values and text is escaped as `&`. Each URL is
|
||||
* unescaped before stripping and re-escaped after, so the surrounding markup is
|
||||
* left byte-for-byte intact.
|
||||
*/
|
||||
export const stripTrackingParamsInHtml = (html: string): string => {
|
||||
let changed = false;
|
||||
const out = html.replace(URL_IN_TEXT, (match) => {
|
||||
const punct = match.match(TRAILING_PUNCT)?.[0] ?? '';
|
||||
const raw = punct ? match.slice(0, -punct.length) : match;
|
||||
const url = raw.replace(/&/g, '&');
|
||||
const cleaned = stripTrackingParams(url);
|
||||
if (cleaned === url) return match;
|
||||
changed = true;
|
||||
return cleaned.replace(/&/g, '&') + punct;
|
||||
});
|
||||
return changed ? out : html;
|
||||
};
|
||||
@@ -23,6 +23,9 @@ export enum AccountDataEvent {
|
||||
// global default behavior for threads.
|
||||
LotusThreadNotifications = 'io.lotus.thread_notifications',
|
||||
|
||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||
LotusSettings = 'io.lotus.settings',
|
||||
|
||||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||
|
||||
CrossSigningMaster = 'm.cross_signing.master',
|
||||
|
||||
Reference in New Issue
Block a user