Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d7e87d26 |
@@ -18,8 +18,10 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
SyncState,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -72,7 +74,7 @@ import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { isRingableRoom } from '../utils/callRing';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -596,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
refEventId,
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
// a reload the first sync replays the ring together with the caller's
|
||||
// hang-up and our own decline. Check the room STATE (not the possibly
|
||||
// stale RTC session). The caller's membership can arrive just after the
|
||||
// ring, so an absent one gets a short grace period before giving up.
|
||||
const myUserId = mx.getSafeUserId();
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
setCallInfo(info);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
}, 4000);
|
||||
},
|
||||
[mx, directs, callEmbed, setToast],
|
||||
);
|
||||
|
||||
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
||||
// call's join and leave in one batch, and the RTC session may keep the join
|
||||
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
||||
// recompute every active session from the room's current state.
|
||||
useEffect(() => {
|
||||
const onSync = (state: SyncState, prev: SyncState | null) => {
|
||||
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
||||
mx.getRooms().forEach((r) => {
|
||||
mx.matrixRTC
|
||||
.getActiveRoomSession(r)
|
||||
?._onRTCSessionMemberUpdate()
|
||||
.catch(() => undefined);
|
||||
});
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
@@ -629,7 +670,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const { memberships } = session;
|
||||
if (memberships.some((m) => m.sender === myUserId))
|
||||
dismiss(); // answered elsewhere
|
||||
else if (memberships.length === 0) dismiss(); // caller gone
|
||||
else if (memberships.length === 0)
|
||||
dismiss(); // caller gone
|
||||
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
||||
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
||||
};
|
||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
event,
|
||||
|
||||
@@ -160,7 +160,6 @@ import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -563,8 +562,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||
const persistDraft = useCallback(() => {
|
||||
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||
if (plaintextCachesWiped()) return;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
|
||||
@@ -26,7 +26,7 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -94,13 +94,3 @@ test('clearPlaintextCaches removes the call-volume list and the settings-sync st
|
||||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||
});
|
||||
|
||||
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||||
store.clear();
|
||||
store.set('searchCacheEnabled', 'true');
|
||||
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||||
clearPlaintextCaches('@me:server');
|
||||
assert.equal(store.has('searchCacheEnabled'), false);
|
||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||
});
|
||||
|
||||
@@ -112,34 +112,7 @@ const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
|
||||
// page, and the reload's `pagehide` made RoomInput flush the open composer's
|
||||
// draft straight back to localStorage; draft writes check this and stop.
|
||||
// Module state, so it resets with the new page.
|
||||
let wiped = false;
|
||||
export const plaintextCachesWiped = (): boolean => wiped;
|
||||
|
||||
/**
|
||||
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
|
||||
* or id: the encrypted-search opt-in (the next account would otherwise start
|
||||
* indexing decrypted text without opting in) and the SDK's per-user filter key.
|
||||
*/
|
||||
const clearAccountScopedFlags = (userId?: string): void => {
|
||||
const keys = [
|
||||
'searchCacheEnabled',
|
||||
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
|
||||
];
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearPlaintextCaches = (userId?: string): void => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
clearScheduledMessages();
|
||||
clearRecentSearches();
|
||||
@@ -151,6 +124,5 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearCallSession();
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { isRingableRoom } from './callRing';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
|
||||
|
||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||
({
|
||||
@@ -27,3 +27,43 @@ test('space channels, public and restricted rooms do not ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||
});
|
||||
|
||||
const stateRoom = (
|
||||
members: Array<{ sender: string; content: Record<string, unknown> }>,
|
||||
timeline: unknown[] = [],
|
||||
): Room =>
|
||||
({
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({
|
||||
getStateEvents: () =>
|
||||
members.map((m) => ({
|
||||
getSender: () => m.sender,
|
||||
getStateKey: () => `_${m.sender}_DEV_m.call`,
|
||||
getContent: () => m.content,
|
||||
})),
|
||||
}),
|
||||
getEvents: () => timeline,
|
||||
}),
|
||||
}) as unknown as Room;
|
||||
|
||||
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
|
||||
const room = stateRoom([
|
||||
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
|
||||
{ sender: '@carol:x', content: {} },
|
||||
]);
|
||||
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
|
||||
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
|
||||
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
|
||||
});
|
||||
|
||||
test('declinedRing finds our own decline of that ring only (#312)', () => {
|
||||
const decline = (sender: string, rel: string) => ({
|
||||
getType: () => 'org.matrix.msc4310.rtc.decline',
|
||||
getSender: () => sender,
|
||||
getRelation: () => ({ event_id: rel }),
|
||||
});
|
||||
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
|
||||
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
|
||||
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
||||
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,37 @@ export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||
};
|
||||
|
||||
/** A call-membership state event that is still "in the call" (left = empty content). */
|
||||
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
|
||||
!!content &&
|
||||
(typeof content.application === 'string' ||
|
||||
(Array.isArray(content.memberships) && content.memberships.length > 0));
|
||||
|
||||
/**
|
||||
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
|
||||
* current STATE. After a reload the MatrixRTC session can be stale (a join and
|
||||
* leave replayed in one catch-up batch), but the state is right.
|
||||
*/
|
||||
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
||||
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
|
||||
(ev) =>
|
||||
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
|
||||
isActiveMembership(ev.getContent()),
|
||||
);
|
||||
|
||||
/**
|
||||
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
|
||||
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
||||
* the ring in the first sync after a restart, before any dismiss listener.
|
||||
*/
|
||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.some(
|
||||
(ev) =>
|
||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||
ev.getSender() === myUserId &&
|
||||
ev.getRelation()?.event_id === refEventId,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user