Fix private calls

This commit is contained in:
2026-05-17 15:14:52 +02:00
parent 0f6cb3ee77
commit e769a6ee4a
71 changed files with 5821 additions and 349 deletions

View File

@@ -0,0 +1,16 @@
# Direct Call Domain
Direct calls coordinate private voice sessions started from people cards, direct-message headers, or active-call rail icons. The domain owns call session state and call-control events; media capture, camera, screen sharing, playback, and voice activity stay in the existing voice and screen-share domains.
## Flow
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, and shows a desktop notification when permission allows. The ring stops when the recipient joins, leaves, or the call ends.
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.

View File

@@ -0,0 +1,522 @@
import {
Injector,
runInInjectionContext,
signal,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
ChatEvent,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import type { DirectMessageConversation } from '../../../direct-message';
import type { DirectCallSession } from '../../domain/models/direct-call.model';
import { DirectCallService } from './direct-call.service';
const alice = createUser('alice', 'Alice');
const bob = createUser('bob', 'Bob');
const charlie = createUser('charlie', 'Charlie');
describe('DirectCallService', () => {
it('only keeps sessions visible while a participant is joined', () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
});
it('keeps a locally left call visible only until the last peer leaves', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.service.leaveCall(session.callId);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
});
it('hides an incoming call after the last joined participant leaves before answer', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
});
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startCall(bob);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
});
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
const session = createGroupSession('dm-original-call', 'dm-group-live', [alice, bob, charlie]);
const conversation = createGroupConversation('dm-group-live', [alice, bob, charlie]);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
});
it('leaves a joined call before joining a different call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
const firstSession = createSession('connected', true);
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);
await context.service.joinCall(nextSession.callId);
expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'leave',
callId: firstSession.callId
}),
type: 'direct-call'
}));
});
it('disconnects the current voice channel before joining a call', async () => {
const voiceConnectedAlice: User = {
...alice,
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
roomId: 'voice-room-1',
serverId: 'server-1'
}
};
const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await context.service.joinCall(session.callId);
expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
expect(context.voice.disableVoice).toHaveBeenCalled();
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'voice-state',
voiceState: expect.objectContaining({
isConnected: false,
roomId: 'voice-room-1',
serverId: 'server-1'
})
}));
expect(context.voiceSession.endSession).toHaveBeenCalled();
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
]);
context.service.joinCall = vi.fn(async (callId: string) => {
const session = context.service.sessionById(callId);
if (!session) {
return;
}
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
...session,
status: 'connected',
participants: {
...session.participants,
alice: {
...session.participants.alice,
joined: true
}
}
});
});
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
});
});
interface ServiceContextOptions {
allUsers: User[];
currentUser: User;
}
interface ServiceContext {
audio: {
playLoop: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
delivery: {
sendCallEvent: ReturnType<typeof vi.fn>;
};
directCallEvents: Subject<ChatEvent>;
directMessages: {
createConversation: ReturnType<typeof vi.fn>;
createGroupConversation: ReturnType<typeof vi.fn>;
openConversation: ReturnType<typeof vi.fn>;
};
router: {
navigate: ReturnType<typeof vi.fn>;
};
service: DirectCallService;
voice: {
broadcastMessage: ReturnType<typeof vi.fn>;
disableVoice: ReturnType<typeof vi.fn>;
stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
};
voiceSession: {
endSession: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const directCallEvents = new Subject<ChatEvent>();
const router = {
navigate: vi.fn(async () => true)
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by DirectCallService test.');
})
};
const directMessages = {
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
...createGroupConversation(conversationId, participants.map(participantToUser)),
title
})),
openConversation: vi.fn(async () => undefined)
};
const delivery = {
directCallEvents$: directCallEvents.asObservable(),
sendCallEvent: vi.fn(() => true)
};
const audio = {
playLoop: vi.fn(),
stop: vi.fn()
};
const voice = {
broadcastMessage: vi.fn(),
disableVoice: vi.fn(),
ensureSignalingConnected: vi.fn(async () => true),
isDeafened: vi.fn(() => false),
isMuted: vi.fn(() => false),
setLocalStream: vi.fn(async () => undefined),
startVoiceHeartbeat: vi.fn(),
stopVoiceHeartbeat: vi.fn(),
syncOutgoingVoiceRouting: vi.fn(),
toggleMute: vi.fn()
};
const voiceSession = {
endSession: vi.fn()
};
const injector = Injector.create({
providers: [
{
provide: ChangeDetectionScheduler,
useValue: {
notify: vi.fn()
}
},
{
provide: EffectScheduler,
useValue: {
add: vi.fn(),
flush: vi.fn(),
remove: vi.fn(),
schedule: vi.fn()
}
},
{
provide: DirectMessageService,
useValue: directMessages
},
{
provide: NotificationAudioService,
useValue: audio
},
{
provide: PeerDeliveryService,
useValue: delivery
},
{
provide: Router,
useValue: router
},
{
provide: Store,
useValue: store
},
{
provide: VoiceActivityService,
useValue: {
trackLocalMic: vi.fn(),
untrackLocalMic: vi.fn()
}
},
{
provide: VoiceConnectionFacade,
useValue: voice
},
{
provide: VoiceSessionFacade,
useValue: voiceSession
},
{
provide: VoicePlaybackService,
useValue: {
playPendingStreams: vi.fn(),
teardownAll: vi.fn()
}
}
]
});
return {
audio,
delivery,
directCallEvents,
directMessages,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
voiceSession
};
}
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
return {
type: 'direct-call',
directCall: {
action,
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
sender: toParticipant(sender),
participantIds,
participants: [alice, bob].map(toParticipant)
}
};
}
function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
return {
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
initiatorId: 'alice',
participantIds: ['alice', 'bob'],
participants: {
alice: {
userId: 'alice',
profile: toParticipant(alice),
joined
},
bob: {
userId: 'bob',
profile: toParticipant(bob),
joined: false
}
},
status
};
}
function createDirectSession(
callId: string,
currentUser: User,
peer: User,
status: DirectCallSession['status'],
joined: boolean
): DirectCallSession {
const currentParticipant = toParticipant(currentUser);
const peerParticipant = toParticipant(peer);
return {
callId,
conversationId: callId,
createdAt: 10,
initiatorId: currentParticipant.userId,
participantIds: [currentParticipant.userId, peerParticipant.userId],
participants: {
[currentParticipant.userId]: {
userId: currentParticipant.userId,
profile: currentParticipant,
joined
},
[peerParticipant.userId]: {
userId: peerParticipant.userId,
profile: peerParticipant,
joined: false
}
},
status
};
}
function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
const participants = users.map(toParticipant);
return {
callId,
conversationId,
createdAt: 10,
initiatorId: participants[0].userId,
participantIds: participants.map((participant) => participant.userId),
participants: Object.fromEntries(participants.map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: false
}
])),
status: 'connected'
};
}
function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
const participants = [toParticipant(currentUser), toParticipant(peer)];
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: `dm-${participantIds.join('-')}`,
kind: 'direct',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
unreadCount: 0
};
}
function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
const participants = users.map(toParticipant);
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
title: participants.map((participant) => participant.displayName).join(', '),
unreadCount: 0
};
}
function participantToUser(participant: DirectMessageParticipant): User {
return createUser(participant.userId, participant.displayName);
}
function toParticipant(user: User): DirectMessageParticipant {
return {
userId: user.oderId || user.id,
username: user.username,
displayName: user.displayName
};
}
function createUser(id: string, displayName: string): User {
return {
id,
oderId: id,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}

View File

@@ -0,0 +1,809 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import {
DirectCallEventPayload,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' })
export class DirectCallService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly delivery = inject(PeerDeliveryService);
private readonly directMessages = inject(DirectMessageService);
private readonly audio = inject(NotificationAudioService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
constructor() {
this.delivery.directCallEvents$.subscribe((event) => {
if (event.directCall) {
void this.handleIncomingCallEvent(event.directCall);
}
});
effect(() => {
const session = this.currentSession();
if (!session || session.status === 'ended') {
return;
}
const peerIds = this.remoteParticipantIds(session);
this.voice.syncOutgoingVoiceRouting(peerIds);
});
}
sessionById(callId: string | null | undefined): DirectCallSession | null {
if (!callId) {
return null;
}
return this.sessionsSignal().find((session) => session.callId === callId) ?? null;
}
isCallingUser(user: User): boolean {
const userId = this.userKey(user);
return this.visibleActiveSessions().some((session) => session.participantIds.includes(userId));
}
isCallingConversation(conversationId: string | null | undefined): boolean {
if (!conversationId) {
return false;
}
return this.visibleActiveSessions().some((session) => session.callId === conversationId || session.conversationId === conversationId);
}
hasConnectedParticipant(session: DirectCallSession | null | undefined): boolean {
if (!session || session.status === 'ended') {
return false;
}
return Object.values(session.participants).some((participant) => participant.joined);
}
hasOngoingActivity(session: DirectCallSession | null | undefined): boolean {
return this.hasConnectedParticipant(session);
}
async startCall(user: User): Promise<DirectCallSession> {
const conversation = await this.directMessages.createConversation(user);
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const peerParticipant = toDirectMessageParticipant(user);
const participantIds = this.uniqueIds([meParticipant.userId, peerParticipant.userId]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing ?? this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants: [meParticipant, peerParticipant],
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(session);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.router.navigate(['/call', session.callId]);
return session;
}
async startConversationCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
if (this.isGroupConversation(conversation)) {
return await this.startGroupCall(conversation);
}
const meId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== meId);
if (!peerId) {
throw new Error('Direct message conversation has no recipient to call.');
}
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
return await this.startCall(peer);
}
async openCall(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (session?.conversationId) {
await this.directMessages.openConversation(session.conversationId);
}
this.currentSession.set(session);
}
async joinCall(callId: string, notifyPeers = true): Promise<void> {
const session = this.sessionById(callId);
const me = this.requireCurrentUser();
const meId = this.userKey(me);
if (!session) {
return;
}
this.leaveOtherJoinedCalls(callId);
this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call);
const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) {
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
await this.voice.setLocalStream(stream);
this.voiceActivity.trackLocalMic(meId, stream);
this.voice.startVoiceHeartbeat(session.callId, session.callId);
this.updateLocalVoiceState(session, true);
this.playback.playPendingStreams({
isConnected: true,
outputVolume: 1,
isDeafened: this.voice.isDeafened()
});
const nextSession = this.markParticipantJoined(session, meId, true, 'connected');
this.upsertSession(nextSession);
this.currentSession.set(nextSession);
if (notifyPeers) {
this.broadcastCallEvent('join', nextSession);
}
}
leaveCall(callId: string, endForEveryone = false): void {
const session = this.sessionById(callId);
if (!session) {
return;
}
this.leaveJoinedSession(session, endForEveryone);
}
leaveCurrentJoinedCall(exceptCallId?: string): void {
for (const session of this.sessionsSignal()) {
if (session.callId === exceptCallId || !this.isCurrentUserJoined(session)) {
continue;
}
this.leaveJoinedSession(session);
}
}
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
const action = endForEveryone ? 'end' : 'leave';
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
this.audio.stop(AppSound.Call);
this.broadcastCallEvent(action, nextSession);
this.stopLocalMedia(nextSession);
this.upsertSession(nextSession);
this.currentSession.set(null);
}
async inviteUser(callId: string, user: User): Promise<void> {
const session = this.sessionById(callId);
if (!session) {
return;
}
const participant = toDirectMessageParticipant(user);
const nextSession = this.createSession({
...session,
participantIds: this.uniqueIds([...session.participantIds, participant.userId]),
participants: [...Object.values(session.participants).map((entry) => entry.profile), participant],
status: session.status
});
const convertedSession = await this.convertToGroupConversationIfNeeded(this.preserveJoinedParticipants(session, nextSession));
this.upsertSession(convertedSession);
this.currentSession.set(convertedSession);
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
this.sendCallEvent(participant.userId, 'ring', convertedSession);
}
remoteParticipantIds(session: DirectCallSession): string[] {
const meId = this.currentUserId();
return session.participantIds.filter((participantId) => participantId !== meId);
}
userForParticipant(participantId: string): User | null {
const known = this.users().find((user) => user.id === participantId || user.oderId === participantId || user.peerId === participantId);
if (known) {
return known;
}
const participant = this.currentSession()?.participants[participantId]?.profile;
return participant ? participantToUser(participant) : null;
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
createdAt: payload.createdAt,
initiatorId: existing?.initiatorId ?? payload.sender.userId,
participantIds: this.uniqueIds([
...payload.participantIds,
meId,
payload.sender.userId
]),
participants,
status: this.resolveIncomingStatus(payload.action, existing?.status)
});
const preservedSession = existing ? this.preserveJoinedParticipants(existing, incomingSession) : incomingSession;
const session = this.applyIncomingParticipantState(preservedSession, payload);
this.upsertSession(session);
this.currentSession.set(this.currentSession()?.callId === session.callId ? session : this.currentSession());
this.markRemoteVoiceState(payload.sender.userId, session, payload.action === 'join');
if (payload.action === 'update') {
await this.ensureCallConversation(session);
return;
}
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
if (session.status !== 'connected') {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
return;
}
this.audio.stop(AppSound.Call);
if (payload.action === 'end') {
if (this.currentSession()?.callId === payload.callId) {
this.stopLocalMedia(session);
this.currentSession.set(null);
}
}
}
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const participantIds = this.uniqueIds([...conversation.participants, meParticipant.userId]);
const conversationParticipants = participantIds.map((participantId) => this.participantFromConversation(conversation, participantId));
const participants = this.uniqueParticipants([meParticipant, ...conversationParticipants]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing && existing.status !== 'ended'
? existing
: this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants,
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(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;
}
private async rejoinLiveSession(session: DirectCallSession): Promise<DirectCallSession> {
this.upsertSession(session);
this.currentSession.set(session);
if (!this.isCurrentUserJoined(session)) {
await this.joinCall(session.callId);
}
const nextSession = this.sessionById(session.callId) ?? session;
await this.router.navigate(['/call', nextSession.callId]);
return nextSession;
}
private leaveOtherJoinedCalls(callId: string): void {
this.leaveCurrentJoinedCall(callId);
}
private leaveCurrentVoiceTargetForCall(callId: string): void {
const user = this.currentUser();
const voiceState = user?.voiceState;
if (!voiceState?.isConnected || (voiceState.roomId === callId && voiceState.serverId === callId)) {
return;
}
const userId = user?.id;
const userKey = user ? this.userKey(user) : undefined;
this.voice.stopVoiceHeartbeat();
if (userKey) {
this.voiceActivity.untrackLocalMic(userKey);
}
this.voice.disableVoice();
this.playback.teardownAll();
this.voiceSession.endSession();
if (userId) {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
}));
}
this.voice.broadcastMessage({
type: 'voice-state',
oderId: userKey,
displayName: user?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: voiceState.roomId,
serverId: voiceState.serverId
}
});
}
private async ensureConversation(sender: DirectMessageParticipant): Promise<void> {
await this.directMessages.createConversation(participantToUser(sender));
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private participantFromConversation(conversation: DirectMessageConversation, participantId: string): DirectMessageParticipant {
const knownUser = this.userForParticipant(participantId);
const profile = conversation.participantProfiles[participantId];
if (knownUser) {
return toDirectMessageParticipant(knownUser);
}
return profile ?? {
userId: participantId,
username: participantId,
displayName: participantId
};
}
private resolveIncomingStatus(action: DirectCallEventPayload['action'], currentStatus?: DirectCallSession['status']): DirectCallSession['status'] {
if (action === 'ring') {
return currentStatus === 'connected' ? 'connected' : 'ringing';
}
if (action === 'join') {
return 'connected';
}
if (action === 'end') {
return 'ended';
}
if (action === 'update') {
return currentStatus ?? 'ringing';
}
if (action === 'leave') {
return currentStatus === 'ringing' ? 'ringing' : 'connected';
}
return currentStatus ?? 'ringing';
}
private applyIncomingParticipantState(session: DirectCallSession, payload: DirectCallEventPayload): DirectCallSession {
if (payload.action === 'ring' || payload.action === 'join') {
return this.markParticipantJoined(session, payload.sender.userId, true, payload.action === 'join' ? 'connected' : session.status);
}
if (payload.action === 'leave') {
const nextSession = this.markParticipantJoined(session, payload.sender.userId, false, session.status);
return this.hasConnectedParticipant(nextSession)
? nextSession
: {
...nextSession,
status: 'ended'
};
}
if (payload.action === 'end') {
return {
...session,
status: 'ended'
};
}
return session;
}
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
const me = this.requireCurrentUser();
this.delivery.sendCallEvent(recipientId, {
type: 'direct-call',
directCall: {
action,
callId: session.callId,
conversationId: session.conversationId,
createdAt: session.createdAt,
sender: toDirectMessageParticipant(me),
participantIds: session.participantIds,
participants: Object.values(session.participants).map((participant) => participant.profile)
}
});
}
private broadcastCallEvent(action: DirectCallEventPayload['action'], session: DirectCallSession, excludedParticipantIds: string[] = []): void {
const excluded = new Set(excludedParticipantIds);
for (const participantId of this.remoteParticipantIds(session)) {
if (excluded.has(participantId)) {
continue;
}
this.sendCallEvent(participantId, action, session);
}
}
private async convertToGroupConversationIfNeeded(session: DirectCallSession): Promise<DirectCallSession> {
if (session.participantIds.length <= 2) {
return session;
}
const conversation = await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId.startsWith('dm-group-') ? session.conversationId : undefined
);
return {
...session,
conversationId: conversation.id
};
}
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return {
...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [
participant.userId,
{
...participant,
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
}
]))
};
}
private async ensureCallConversation(session: DirectCallSession): Promise<void> {
if (session.participantIds.length > 2) {
await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId
);
return;
}
const sender = Object.values(session.participants)
.map((participant) => participant.profile)
.find((participant) => participant.userId !== this.currentUserId());
if (sender) {
await this.ensureConversation(sender);
}
}
private callParticipantsFromPayload(payload: DirectCallEventPayload): DirectMessageParticipant[] {
return this.uniqueParticipants([
payload.sender,
toDirectMessageParticipant(this.requireCurrentUser()),
...(payload.participants ?? []),
...payload.participantIds
.map((participantId) => this.userForParticipant(participantId))
.filter((user): user is User => !!user)
.map((user) => toDirectMessageParticipant(user))
]);
}
private groupConversationTitle(session: DirectCallSession): string {
const names = Object.values(session.participants)
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private createSession(input: {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: DirectMessageParticipant[];
status: DirectCallSession['status'];
}): DirectCallSession {
const participants = Object.fromEntries(this.uniqueParticipants(input.participants).map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: input.status === 'connected' && participant.userId === this.currentUserId()
}
]));
return {
callId: input.callId,
conversationId: input.conversationId,
createdAt: input.createdAt,
initiatorId: input.initiatorId,
participantIds: this.uniqueIds(input.participantIds),
participants,
status: input.status
};
}
private markParticipantJoined(
session: DirectCallSession,
participantId: string,
joined: boolean,
status: DirectCallSession['status']
): DirectCallSession {
const participant = session.participants[participantId];
return {
...session,
status,
participants: {
...session.participants,
...(participant
? {
[participantId]: {
...participant,
joined
}
}
: {})
}
};
}
private upsertSession(session: DirectCallSession): void {
this.sessionsSignal.update((sessions) => [...sessions.filter((entry) => entry.callId !== session.callId), session]);
}
private markCurrentUserLeft(session: DirectCallSession, endForEveryone: boolean): DirectCallSession {
const meId = this.currentUserId();
const locallyLeftSession = meId
? this.markParticipantJoined(session, meId, false, session.status)
: session;
return {
...locallyLeftSession,
status: endForEveryone || !this.hasConnectedParticipant(locallyLeftSession) ? 'ended' as const : 'connected' as const
};
}
private findLiveSessionForParticipants(participantIds: string[], conversationId?: string | null): DirectCallSession | null {
const normalizedParticipantIds = this.uniqueIds(participantIds).sort();
return this.visibleActiveSessions().find((session) => {
if (conversationId && (session.callId === conversationId || session.conversationId === conversationId)) {
return true;
}
const sessionParticipantIds = this.uniqueIds(session.participantIds).sort();
return sessionParticipantIds.length === normalizedParticipantIds.length
&& sessionParticipantIds.every((participantId, index) => participantId === normalizedParticipantIds[index]);
}) ?? null;
}
private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId();
return !!meId && !!session.participants[meId]?.joined;
}
private stopLocalMedia(session: DirectCallSession): void {
const meId = this.currentUserId();
if (meId) {
this.voiceActivity.untrackLocalMic(meId);
}
this.voice.stopVoiceHeartbeat();
this.voice.disableVoice();
this.playback.teardownAll();
this.updateLocalVoiceState(session, false);
}
private updateLocalVoiceState(session: DirectCallSession, connected: boolean): void {
const user = this.currentUser();
if (!user?.id) {
return;
}
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: connected,
isMuted: connected ? this.voice.isMuted() : false,
isDeafened: connected ? this.voice.isDeafened() : false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: connected,
isMuted: false,
isDeafened: false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
if (typeof Notification === 'undefined') {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
return;
}
const notification = new Notification('Incoming call', {
body: `${displayName} is calling you`
});
notification.onclick = () => {
window.focus();
void this.router.navigate(['/call', callId]);
};
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private uniqueIds(ids: string[]): string[] {
return ids.filter((id, index) => !!id && ids.indexOf(id) === index);
}
private userKey(user: User): string {
return user.oderId || user.id;
}
private currentUserId(): string | null {
const user = this.currentUser();
return user ? this.userKey(user) : null;
}
private requireCurrentUser(): User {
const user = this.currentUser();
if (!user) {
throw new Error('Cannot use calls without a current user.');
}
return user;
}
}

View File

@@ -0,0 +1,37 @@
import type { DirectMessageParticipant, User } from '../../../../shared-kernel';
export type DirectCallStatus = 'calling' | 'ringing' | 'connected' | 'ended';
export interface DirectCallParticipant {
userId: string;
profile: DirectMessageParticipant;
joined: boolean;
}
export interface DirectCallSession {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: Record<string, DirectCallParticipant>;
status: DirectCallStatus;
}
export function participantToUser(participant: DirectMessageParticipant): User {
return {
id: participant.userId,
oderId: participant.userId,
username: participant.username,
displayName: participant.displayName,
description: participant.description,
avatarUrl: participant.avatarUrl,
avatarHash: participant.avatarHash,
avatarMime: participant.avatarMime,
avatarUpdatedAt: participant.avatarUpdatedAt,
profileUpdatedAt: participant.profileUpdatedAt,
status: 'online',
role: 'member',
joinedAt: Date.now()
};
}

View File

@@ -0,0 +1,2 @@
export * from './application/services/direct-call.service';
export * from './domain/models/direct-call.model';