Files
Toju/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts
Myx 232a9ea8ea
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-windows (push) Successful in 25m4s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
test: Ensure tests work after latest changes
2026-05-19 00:52:28 +02:00

936 lines
29 KiB
TypeScript

/* 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 { ViewportService } from '../../../../core/platform';
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 viewport = inject(ViewportService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
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 incomingCall = computed<DirectCallSession | null>(() => {
if (this.isDoNotDisturb()) {
return null;
}
const meId = this.currentUserId();
if (!meId) {
return null;
}
return [...this.activeSessions()]
.sort((left, right) => right.createdAt - left.createdAt)
.find((session) => session.status === 'ringing'
&& this.currentSession()?.callId !== session.callId
&& !session.participants[meId]?.joined
&& this.hasConnectedParticipant(session)) ?? null;
});
readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
if (!callId) {
return null;
}
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
});
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);
});
effect(() => {
if (this.incomingCall() && !this.isDoNotDisturb()) {
return;
}
this.audio.stop(AppSound.Call);
});
effect(() => {
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
this.mobileOverlayCallId.set(null);
}
});
}
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.openCallView(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 openCallView(callId: string): Promise<void> {
if (this.viewport.isMobile()) {
await this.openMobileCallOverlay(callId);
return;
}
await this.router.navigate(['/call', callId]);
}
async openMobileCallOverlay(callId: string): Promise<void> {
await this.openCall(callId);
this.mobileOverlayCallId.set(callId);
}
closeMobileCallOverlay(): void {
this.mobileOverlayCallId.set(null);
}
async answerIncomingCall(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
return;
}
this.audio.stop(AppSound.Call);
this.currentSession.set(session);
await this.joinCall(callId);
await this.router.navigate(['/call', callId]);
}
declineIncomingCall(callId: string): void {
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
return;
}
const meId = this.currentUserId();
const nextSession = meId
? {
...this.markParticipantJoined(session, meId, false, 'ended'),
status: 'ended' as const
}
: {
...session,
status: 'ended' as const
};
this.audio.stop(AppSound.Call);
if (meId) {
this.broadcastCallEvent('leave', session);
}
this.upsertSession(nextSession);
if (this.currentSession()?.callId === callId) {
this.currentSession.set(null);
}
}
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;
}
if (!this.callPayloadIncludesParticipant(payload, 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 (this.shouldAlertIncomingCall(session)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (this.shouldAlertIncomingCall(session)) {
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 callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
}
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 shouldAlertIncomingCall(session: DirectCallSession): boolean {
return session.status !== 'connected' && !this.isDoNotDisturb();
}
private isDoNotDisturb(): boolean {
return this.currentUser()?.status === 'busy';
}
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;
}
}