Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
177485d
tests for some goal behaviors
7w1 May 14, 2026
0e2028f
restore call intents
7w1 May 14, 2026
9fe52e3
match spec notification handling better
7w1 May 14, 2026
ec9711f
incoming call modal ui improvements
7w1 May 14, 2026
8d3a909
improve call capability detection
7w1 May 14, 2026
9e1ddb1
hardening things
7w1 May 14, 2026
0ba0b7f
more call settings
7w1 May 14, 2026
b74a840
call notif improvements
7w1 May 14, 2026
0b0af75
more tests
7w1 May 14, 2026
415ff77
changesets, lint, formatting
7w1 May 14, 2026
9afae54
separate room call buttons and fix element call not clickable
7w1 May 14, 2026
8234208
fix some ringtone things
7w1 May 14, 2026
a553d4e
copy ringtone options for ringback
7w1 May 14, 2026
59cfcd0
fix ringtone not stopping
7w1 May 14, 2026
08272fa
override element call ringback sound
7w1 May 14, 2026
f35cfad
remove old ringback suppression
7w1 May 14, 2026
329520a
lint
7w1 May 15, 2026
d49dcb0
remove some redundancies
7w1 May 15, 2026
e58fa0f
receive call declines and cleanup
7w1 May 15, 2026
e27ccae
formatting
7w1 May 15, 2026
3c1f925
make ringtone work again
7w1 May 15, 2026
f07d331
Merge branch 'dev' into refactor-calls
7w1 May 15, 2026
db80835
organize permission for calls
7w1 May 18, 2026
333fa55
remove outdated sound suppression
7w1 May 18, 2026
8cb0464
remove debug, reorganize notification things a little, add tests
7w1 May 19, 2026
e25ce4e
extract a bunch of things into their own files and some tests
7w1 May 19, 2026
30798ac
typecheck and cleanup
7w1 May 19, 2026
0009272
formatting
7w1 May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/call-signaling-notification-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Improved call signaling and notification handling to restore call state more reliably, reduce duplicate ringing, and handle expiry more safely.
5 changes: 5 additions & 0 deletions .changeset/call-start-experience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Added explicit voice/video call start options in room headers with more predictable DM/group join and start behavior.
5 changes: 5 additions & 0 deletions .changeset/custom-call-ringtones.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Added customizable call sounds in Settings with built-in ringtone choices, ringback behavior, volume control, and persistent custom ringtone import/reset.
5 changes: 5 additions & 0 deletions .changeset/incoming-call-modal-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Improved the incoming call modal with clearer caller/room context, better voice/video labeling, stronger keyboard support, and clearer decline vs ignore handling.
24 changes: 22 additions & 2 deletions src/app/components/CallEmbedProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAutoJoinCall } from '$hooks/useAutoJoinCall';
import {
Expand All @@ -12,13 +12,15 @@ import {
} from '$hooks/useCallEmbed';
import type { CallEmbed } from '$plugins/call';
import { useClientWidgetApiEvent, ElementWidgetActions } from '$plugins/call';
import { callChatAtom, callEmbedAtom } from '$state/callEmbed';
import { callChatAtom, callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { IncomingCallModal } from './IncomingCallModal';
import { toCallEmbedStartError } from '$plugins/call/callEmbedError';

function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom);

useCallMemberSoundSync(embed);
useCallThemeSync(embed);
Expand All @@ -30,6 +32,24 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallHangupEvent(embed, handleCallEnd);
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, handleCallEnd);

useEffect(() => {
const disposeOnReady = embed.onReady(() => {
setCallEmbedStartError(null);
});
const disposeOnCapabilitiesNotified = embed.onCapabilitiesNotified(() => {
setCallEmbedStartError(null);
});
const disposeOnPreparingError = embed.onPreparingError((error) => {
setCallEmbedStartError(toCallEmbedStartError(error));
});

return () => {
disposeOnReady();
disposeOnCapabilitiesNotified();
disposeOnPreparingError();
};
}, [embed, setCallEmbedStartError]);

return null;
}

Expand Down
167 changes: 167 additions & 0 deletions src/app/components/IncomingCallModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { Room } from '$types/matrix-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IncomingCallInternal } from './IncomingCallModal';

const { navigateRoomMock, sendRtcDeclineMock, webRtcSupportedMock, livekitSupportedMock } =
vi.hoisted(() => ({
navigateRoomMock: vi.fn<(roomId: string) => void>(),
sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise<void>>(),
webRtcSupportedMock: vi.fn<() => boolean>(),
livekitSupportedMock: vi.fn<() => boolean>(),
}));

vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => ({
sendRtcDecline: sendRtcDeclineMock,
getSafeUserId: () => '@me:example.org',
mxcUrlToHttp: () => undefined,
}),
}));

vi.mock('$hooks/useLivekitSupport', () => ({
useLivekitSupport: () => livekitSupportedMock(),
}));

vi.mock('$hooks/useCallEmbed', () => ({
useCallEmbed: () => undefined,
}));

vi.mock('$hooks/useScreenSize', () => ({
ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' },
useScreenSizeContext: () => 'Desktop',
}));

vi.mock('$hooks/useRoomMeta', () => ({
useRoomName: () => 'Direct Message',
}));

vi.mock('$utils/room', () => ({
getRoomAvatarUrl: () => null,
getMemberDisplayName: () => 'Alice',
}));

vi.mock('$hooks/useRoomNavigate', () => ({
useRoomNavigate: () => ({
navigateRoom: navigateRoomMock,
}),
}));

vi.mock('$utils/rtc', () => ({
webRTCSupported: () => webRtcSupportedMock(),
}));

vi.mock('./room-avatar', () => ({
RoomAvatar: ({ alt }: { alt: string }) => <div>{alt}</div>,
}));

vi.mock('./user-avatar', () => ({
UserAvatar: ({ alt }: { alt?: string }) => <div>{alt}</div>,
}));

vi.mock('@sentry/react', () => ({
addBreadcrumb: vi.fn<(...args: unknown[]) => void>(),
metrics: {
count: vi.fn<(...args: unknown[]) => void>(),
},
}));

vi.mock('$utils/debugLogger', () => ({
createDebugLogger: () => ({
info: vi.fn<(...args: unknown[]) => void>(),
warn: vi.fn<(...args: unknown[]) => void>(),
error: vi.fn<(...args: unknown[]) => void>(),
}),
}));

describe('IncomingCallInternal', () => {
const room = {
roomId: '!room:example.org',
getMember: () => ({
getMxcAvatarUrl: () => undefined,
rawDisplayName: 'Alice',
}),
currentState: {
maySendStateEvent: () => true,
},
} as unknown as Room;
const incomingCall = {
roomId: room.roomId,
notificationEventId: '$notif',
refEventId: '$ref',
senderId: '@alice:example.org',
senderTs: Date.now(),
expiresAt: Date.now() + 60_000,
notificationType: 'ring' as const,
intentKind: 'audio' as const,
isDirect: true,
};

beforeEach(() => {
navigateRoomMock.mockReset();
sendRtcDeclineMock.mockReset().mockResolvedValue(undefined);
webRtcSupportedMock.mockReset().mockReturnValue(true);
livekitSupportedMock.mockReset().mockReturnValue(true);
});

it('closes the modal when decline is pressed', async () => {
const onClose = vi.fn<() => void>();
render(<IncomingCallInternal room={room} incomingCall={incomingCall} onClose={onClose} />);

fireEvent.click(screen.getByRole('button', { name: 'Decline call' }));

await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(navigateRoomMock).not.toHaveBeenCalled();
expect(sendRtcDeclineMock).toHaveBeenCalledWith('!room:example.org', '$notif');
});

it('navigates and closes when answer is pressed', () => {
const onClose = vi.fn<() => void>();
render(<IncomingCallInternal room={room} incomingCall={incomingCall} onClose={onClose} />);

fireEvent.click(screen.getByRole('button', { name: /answer/i }));

expect(navigateRoomMock).toHaveBeenCalledWith('!room:example.org');
expect(onClose).toHaveBeenCalledTimes(1);
});

it('disables answer when WebRTC is unavailable', () => {
webRtcSupportedMock.mockReturnValue(false);
const onClose = vi.fn<() => void>();
render(<IncomingCallInternal room={room} incomingCall={incomingCall} onClose={onClose} />);

expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled();
});

it('ignores room call notifications without sending RTC decline', async () => {
const onClose = vi.fn<() => void>();
render(
<IncomingCallInternal
room={room}
incomingCall={{ ...incomingCall, isDirect: false, notificationType: 'notification' }}
onClose={onClose}
/>
);

fireEvent.click(screen.getByRole('button', { name: 'Ignore call notification' }));

await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(sendRtcDeclineMock).not.toHaveBeenCalled();
});

it('shows homeserver capability issues and blocks answer when LiveKit is unavailable', () => {
livekitSupportedMock.mockReturnValue(false);
const onClose = vi.fn<() => void>();
render(<IncomingCallInternal room={room} incomingCall={incomingCall} onClose={onClose} />);

expect(
screen.getByText(/homeserver does not expose a livekit call focus/i)
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled();
expect(screen.getByText(/homeserver call focus is unavailable/i)).toBeInTheDocument();
});
});
Loading
Loading