Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2322f751 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
2700aad209 |
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
JoinRule,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getStateEvents, getMemberName } 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';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
|
||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
const light: CSSProperties = {
|
||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
export const animAurora: ChatBgVariants = { dark, light };
|
||||
|
||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // nebula A
|
||||
'0 0', // nebula B
|
||||
].join(','),
|
||||
animation: `${starDrift} 90s linear infinite`,
|
||||
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||
},
|
||||
|
||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // wash A
|
||||
'0 0', // wash B
|
||||
].join(','),
|
||||
animation: `${starDrift} 100s linear infinite`,
|
||||
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
||||
}
|
||||
// For animated backgrounds, promote the element to its own compositor layer so
|
||||
// background-position keyframes don't trigger repaints on descendant elements.
|
||||
// [Gitea #337] background-position can't run on the compositor, so every
|
||||
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||
// same, a fraction of the repaints.
|
||||
if (style.animation) {
|
||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||
}
|
||||
|
||||
@@ -121,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';
|
||||
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -493,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');
|
||||
|
||||
@@ -2030,6 +2034,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
if (!senderId || senderId === mx.getUserId()) return null;
|
||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||
// channels, where starting a call is just joining the channel.
|
||||
if (!isRingableRoom(room, direct)) return null;
|
||||
const content = mEvent.getContent();
|
||||
const mention =
|
||||
content['m.mentions']?.room ||
|
||||
|
||||
@@ -68,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';
|
||||
@@ -910,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()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { isRingableRoom } from './callRing';
|
||||
|
||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||
({
|
||||
isCallRoom: () => !!opts.callRoom,
|
||||
getJoinRule: () => opts.joinRule ?? 'invite',
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
|
||||
}),
|
||||
}) as unknown as Room;
|
||||
|
||||
test('voice rooms never ring or show "missed call", even as DMs', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
|
||||
});
|
||||
|
||||
test('DMs and invite-only groups outside a space ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
|
||||
});
|
||||
|
||||
test('space channels, public and restricted rooms do not ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvents } from './room';
|
||||
|
||||
/**
|
||||
* Whether a call notification in this room may ring — and, by the same rule,
|
||||
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
|
||||
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
|
||||
* call button is shown. Persistent voice rooms, space channels, restricted and
|
||||
* public rooms never do: joining a voice channel alone is normal and isn't a
|
||||
* "call" anyone missed.
|
||||
*/
|
||||
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||
if (room.isCallRoom()) return false;
|
||||
if (isDirect) return true;
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user