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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user