Fix private calls
This commit is contained in:
16
toju-app/src/app/domains/direct-call/README.md
Normal file
16
toju-app/src/app/domains/direct-call/README.md
Normal 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.
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
2
toju-app/src/app/domains/direct-call/index.ts
Normal file
2
toju-app/src/app/domains/direct-call/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './application/services/direct-call.service';
|
||||
export * from './domain/models/direct-call.model';
|
||||
Reference in New Issue
Block a user