feat(presence): live stream badges, honest ring delivery, cross-signal DMs

- Camera and screen-share badges are decided by `shouldShowStreamIndicator`,
  so a live stream is visible from outside the voice channel and clicking the
  badge joins the streamer's channel before focusing the stream.
- `ringParticipants` reports whether a `direct-call` ring reached anyone; a
  call that reached nobody shows `call.errors.ringUndelivered` instead of
  sitting in "calling" as if it were live.
- Direct messages resolve every local identity alias, so a conversation
  opened from a foreign roster entry lands in the same thread.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent 92c2f578e2
commit 3266581d3c
16 changed files with 1180 additions and 118 deletions
@@ -527,6 +527,47 @@ describe('DirectCallService', () => {
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
});
it('rings the peer before joining local voice', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const joinCall = vi.fn(async () => undefined);
context.service.joinCall = joinCall;
await context.service.startCall(bob);
expect(context.delivery.sendCallEvent).toHaveBeenCalled();
expect(context.delivery.sendCallEvent.mock.invocationCallOrder[0])
.toBeLessThan(joinCall.mock.invocationCallOrder[0]);
});
it('surfaces an undelivered ring instead of looking like a live call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
context.service.joinCall = vi.fn(async () => undefined);
context.delivery.sendCallEvent.mockReturnValue(false);
await context.service.startCall(bob);
expect(context.service.deliveryError()).not.toBeNull();
});
it('clears the delivery error once a ring reaches the peer', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
context.service.joinCall = vi.fn(async () => undefined);
context.delivery.sendCallEvent.mockReturnValueOnce(false);
await context.service.startCall(bob);
expect(context.service.deliveryError()).not.toBeNull();
await context.service.startCall(charlie);
expect(context.service.deliveryError()).toBeNull();
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
@@ -21,7 +21,14 @@ import {
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
import {
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
VoiceSessionFacade,
buildMicrophoneConstraints,
isDeviceUnavailableError,
isVoiceOnAnotherClient,
loadVoiceSettingsFromStorage
} from '../../../voice-session';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
@@ -93,6 +100,8 @@ export class DirectCallService {
readonly currentSession = signal<DirectCallSession | null>(null);
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
readonly joinError = signal<string | null>(null);
/** User-facing reason the last outgoing ring reached nobody; null once a ring is delivered. */
readonly deliveryError = signal<string | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
@@ -230,8 +239,8 @@ export class DirectCallService {
session.createdAt
);
this.ringParticipants(session, [peerParticipant.userId]);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
return session;
}
@@ -367,12 +376,7 @@ export class DirectCallService {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
stream = await this.captureCallMicrophone();
} catch {
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
return;
@@ -419,6 +423,38 @@ export class DirectCallService {
}
}
/**
* Capture the mic for a call, honouring the saved device.
*
* The device is requested exactly, so a saved id that is gone or already
* claimed rejects instead of quietly opening a different mic. That must not
* cost the user the call, so retry once on the system default.
*/
private async captureCallMicrophone(): Promise<MediaStream> {
const voiceSettings = loadVoiceSettingsFromStorage();
const browserNoiseSuppression = !voiceSettings.noiseReduction;
try {
return await navigator.mediaDevices.getUserMedia(
buildMicrophoneConstraints({
browserNoiseSuppression,
deviceId: voiceSettings.inputDevice
})
);
} catch (error) {
if (!voiceSettings.inputDevice || !isDeviceUnavailableError(error)) {
throw error;
}
return await navigator.mediaDevices.getUserMedia(
buildMicrophoneConstraints({
browserNoiseSuppression,
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID
})
);
}
}
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
const action = endForEveryone ? 'end' : 'leave';
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
@@ -451,7 +487,7 @@ export class DirectCallService {
this.upsertSession(convertedSession);
this.currentSession.set(convertedSession);
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
this.sendCallEvent(participant.userId, 'ring', convertedSession);
this.ringParticipants(convertedSession, [participant.userId]);
}
remoteParticipantIds(session: DirectCallSession): string[] {
@@ -611,8 +647,8 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
this.ringParticipants(session, this.remoteParticipantIds(session));
await this.joinCall(session.callId, false);
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
await this.router.navigate(['/call', session.callId]);
return this.sessionById(session.callId) ?? session;
}
@@ -756,10 +792,25 @@ export class DirectCallService {
return session;
}
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
/**
* Ring every recipient and report the outcome. A call whose ring reached
* nobody must not look live, so an undelivered ring surfaces as an error
* instead of a session that waits forever.
*/
private ringParticipants(session: DirectCallSession, recipientIds: readonly string[]): void {
const delivered = recipientIds
.map((recipientId) => this.sendCallEvent(recipientId, 'ring', session))
.filter((wasDelivered) => wasDelivered);
this.deliveryError.set(delivered.length > 0
? null
: this.i18n.instant('call.errors.ringUndelivered'));
}
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): boolean {
const me = this.requireCurrentUser();
this.delivery.sendCallEvent(recipientId, {
return this.delivery.sendCallEvent(recipientId, {
type: 'direct-call',
directCall: {
action,