Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
8608364
Merge branch 'dev' of https://github.com/SableClient/Sable into refac…
7w1 Jun 27, 2026
0359b16
fix tests
7w1 Jun 27, 2026
cc49beb
remove call controls and inject stylesheet into sable call
7w1 Jun 27, 2026
26cd65c
Fix call embed button border overlay, reaction menu fonts, and slider UI
7w1 Jun 27, 2026
15a427a
Forward state events to Element Call via timeline listener to fix cal…
7w1 Jun 28, 2026
11ead89
resolve lint/typecheck/knip issues
7w1 Jun 28, 2026
4ff4285
bump sable call
7w1 Jun 28, 2026
621f7fb
lockfile
7w1 Jun 28, 2026
eb1e953
dont ring on call end
7w1 Jun 28, 2026
9333416
consolidate permissions
7w1 Jun 28, 2026
2865af2
themeing things
7w1 Jun 28, 2026
3a8d310
adjust ringtone settings a little
7w1 Jun 28, 2026
2134e19
add setting for voice room notifs
7w1 Jun 28, 2026
3d0425d
lint
7w1 Jun 28, 2026
71b984c
styles
7w1 Jun 28, 2026
dbdd83e
formatting things
7w1 Jun 28, 2026
2e5fd52
lint
7w1 Jun 28, 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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@rollup/plugin-inject": "^5.0.5",
"@rollup/plugin-wasm": "^6.2.2",
"@sableclient/sable-call-embedded": "1.1.4",
"@sableclient/sable-call-embedded": "1.1.5",
"@sentry/vite-plugin": "^5.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
Expand Down Expand Up @@ -141,4 +141,4 @@
"jsdom>undici": "^7.28.0"
}
}
}
}
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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