Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d7e87d26 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
d55a8ba8a5 | ||
|
|
2700aad209 |
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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