Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2322f751 |
@@ -71,7 +71,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
@@ -494,7 +494,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -640,7 +639,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
sendRoomEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
@@ -122,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -411,9 +410,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
// [Gitea #259] The media gallery / activity log register filtered timeline
|
||||
// sets whose events the room re-emits as live: don't count them twice.
|
||||
if (isFromFilteredTimelineSet(data)) return;
|
||||
onArrive(mEvent);
|
||||
};
|
||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||
@@ -498,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||
const retentionMs = effectiveRetentionMs(
|
||||
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||
);
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
@@ -112,7 +111,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
if (toStart || removed || !data?.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||
else feed(ev);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType } from '../../../types/matrix/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
@@ -69,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||
import {
|
||||
getRoomRetentionMs,
|
||||
getRoomRetentionSetAt,
|
||||
isExpired,
|
||||
retentionDeletesAllowed,
|
||||
} from '../../utils/retention';
|
||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||
@@ -680,7 +684,6 @@ function MessageNotifications() {
|
||||
data,
|
||||
) => {
|
||||
if (mx.getSyncState() !== 'SYNCING') return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||
if (
|
||||
!room ||
|
||||
@@ -912,6 +915,9 @@ function RetentionSweeper() {
|
||||
mx.getRooms().forEach((room) => {
|
||||
const maxLifetime = getRoomRetentionMs(room);
|
||||
if (!maxLifetime) return;
|
||||
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||
const setAt = getRoomRetentionSetAt(room);
|
||||
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -1120,7 +1126,6 @@ function ClockSkewFeature() {
|
||||
data,
|
||||
) => {
|
||||
if (!data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Only events our homeserver stamped: a federated event's
|
||||
// origin_server_ts is the other server's clock.
|
||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
@@ -237,7 +236,6 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Single-owner rule: thread replies drive the room badge via
|
||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||
// never double-driven / mis-attributed to the main timeline.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { IRoomTimelineData } from 'matrix-js-sdk';
|
||||
import { isFromFilteredTimelineSet } from './room';
|
||||
|
||||
const data = (filter: unknown): IRoomTimelineData =>
|
||||
({
|
||||
liveEvent: true,
|
||||
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
|
||||
}) as unknown as IRoomTimelineData;
|
||||
|
||||
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
|
||||
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
|
||||
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
|
||||
assert.equal(isFromFilteredTimelineSet(undefined), false);
|
||||
assert.equal(
|
||||
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
||||
import {
|
||||
effectiveRetentionMs,
|
||||
isExpired,
|
||||
RETENTION_DELETE_GRACE_MS,
|
||||
RETENTION_MIN_MS,
|
||||
RETENTION_PRESETS,
|
||||
retentionDeletesAllowed,
|
||||
} from './retention';
|
||||
|
||||
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
|
||||
'all presets above the floor',
|
||||
);
|
||||
});
|
||||
|
||||
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
||||
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
||||
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
||||
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
||||
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
||||
assert.equal(effectiveRetentionMs(0), undefined);
|
||||
assert.equal(effectiveRetentionMs(-5), undefined);
|
||||
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
||||
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
||||
});
|
||||
|
||||
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
||||
const now = 1_000 * HOUR;
|
||||
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
||||
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
||||
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
||||
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
||||
});
|
||||
|
||||
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
|
||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||
];
|
||||
|
||||
/**
|
||||
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
||||
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
||||
* from any client by anyone with state power) used to blank every timeline and
|
||||
* get opted-in users' messages redacted within a minute.
|
||||
*/
|
||||
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
||||
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
||||
? maxLifetime
|
||||
: undefined;
|
||||
|
||||
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
||||
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
||||
* policy for this long after it was set, so a mistaken or malicious change can
|
||||
* be reverted before anything is permanently redacted. (Hiding expired
|
||||
* messages locally is not destructive and applies at once.)
|
||||
*/
|
||||
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
||||
|
||||
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
||||
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
||||
|
||||
/** When the room's current retention policy was set (its state event's ts). */
|
||||
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
||||
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
||||
|
||||
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
|
||||
@@ -848,14 +847,3 @@ export const guessPerfectParent = (
|
||||
|
||||
return perfectParent;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
|
||||
* set. The media gallery and activity log register filtered sets on the room
|
||||
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
|
||||
* liveEvent: true, so listeners that mean "a new event in the room" saw each
|
||||
* matching event twice (the timeline window drifted, notifications doubled).
|
||||
* The room's main and thread sets have no filter.
|
||||
*/
|
||||
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
|
||||
!!data?.timeline?.getTimelineSet()?.getFilter();
|
||||
|
||||
Reference in New Issue
Block a user