fix: solve small pm chat ui issues

unwrap the pill
fix the fetching images in pm not auto download
This commit is contained in:
2026-05-25 17:17:32 +02:00
parent 1259645706
commit 161f57f52e
28 changed files with 697 additions and 82 deletions
@@ -9,6 +9,7 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -136,6 +137,87 @@ describe('DirectCallService', () => {
expect(context.service.incomingCall()).toBeNull();
});
it('does not start ringing after declining while incoming ring setup is still pending', async () => {
let releaseCallLog: (() => void) | null = null;
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directMessages.recordCallStarted.mockImplementationOnce(async () => new Promise<void>((resolve) => {
releaseCallLog = resolve;
}));
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalled());
context.service.declineIncomingCall('dm-alice-bob');
releaseCallLog?.();
await Promise.resolve();
await Promise.resolve();
expect(context.service.incomingCall()).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
});
it('logs a system call-started entry when starting a direct PM call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
await context.service.startCall(bob);
expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
'dm-alice-bob',
expect.objectContaining({ userId: 'alice' }),
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
expect.any(Number)
);
});
it('logs a system call-started entry when receiving a PM ring', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
'dm-alice-bob',
expect.objectContaining({ userId: 'alice' }),
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
10
));
});
it('does not restart the call sound when a stale ring arrives after declining', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
context.service.declineIncomingCall('dm-alice-bob');
context.audio.playLoop.mockClear();
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended'));
expect(context.service.incomingCall()).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
});
it('shows a pending incoming call once the current user hydrates', async () => {
const context = createServiceContext({ currentUser: null, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await Promise.resolve();
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
context.currentUser.set(bob);
context.effectScheduler.flush();
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.audio.playLoop).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);
@@ -298,7 +380,7 @@ describe('DirectCallService', () => {
interface ServiceContextOptions {
allUsers: User[];
currentUser: User;
currentUser: User | null;
}
interface ServiceContext {
@@ -306,6 +388,7 @@ interface ServiceContext {
playLoop: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
currentUser: ReturnType<typeof signal<User | null>>;
delivery: {
sendCallEvent: ReturnType<typeof vi.fn>;
};
@@ -314,6 +397,10 @@ interface ServiceContext {
createConversation: ReturnType<typeof vi.fn>;
createGroupConversation: ReturnType<typeof vi.fn>;
openConversation: ReturnType<typeof vi.fn>;
recordCallStarted: ReturnType<typeof vi.fn>;
};
effectScheduler: {
flush: ReturnType<typeof vi.fn>;
};
router: {
navigate: ReturnType<typeof vi.fn>;
@@ -351,12 +438,13 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
})
};
const directMessages = {
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
createConversation: vi.fn(async (user: User) => createDirectConversation(currentUser() ?? bob, user)),
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
...createGroupConversation(conversationId, participants.map(participantToUser)),
title
})),
openConversation: vi.fn(async () => undefined)
openConversation: vi.fn(async () => undefined),
recordCallStarted: vi.fn(async () => undefined)
};
const delivery = {
directCallEvents$: directCallEvents.asObservable(),
@@ -366,6 +454,25 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
playLoop: vi.fn(),
stop: vi.fn()
};
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
const effectScheduler = {
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
}),
flush: vi.fn(() => {
for (const scheduledEffect of scheduledEffects) {
if (scheduledEffect.dirty) {
scheduledEffect.run();
}
}
}),
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.delete(scheduledEffect);
}),
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
})
};
const voice = {
broadcastMessage: vi.fn(),
disableVoice: vi.fn(),
@@ -391,12 +498,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
},
{
provide: EffectScheduler,
useValue: {
add: vi.fn(),
flush: vi.fn(),
remove: vi.fn(),
schedule: vi.fn()
}
useValue: effectScheduler
},
{
provide: DirectMessageService,
@@ -439,15 +541,23 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
playPendingStreams: vi.fn(),
teardownAll: vi.fn()
}
},
{
provide: ViewportService,
useValue: {
isMobile: vi.fn(() => false)
}
}
]
});
return {
audio,
currentUser,
delivery,
directCallEvents,
directMessages,
effectScheduler,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
@@ -44,6 +44,7 @@ export class DirectCallService {
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallEvents: DirectCallEventPayload[] = [];
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -85,6 +86,18 @@ export class DirectCallService {
}
});
effect(() => {
if (!this.currentUserId() || this.pendingIncomingCallEvents.length === 0) {
return;
}
const pendingEvents = this.pendingIncomingCallEvents.splice(0);
for (const payload of pendingEvents) {
void this.handleIncomingCallEvent(payload);
}
});
effect(() => {
const session = this.currentSession();
@@ -159,7 +172,7 @@ export class DirectCallService {
}
const existing = this.sessionById(conversation.id);
const session = existing ?? this.createSession({
const session = existing && existing.status !== 'ended' ? existing : this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
@@ -171,6 +184,14 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
@@ -236,6 +257,8 @@ export class DirectCallService {
}
declineIncomingCall(callId: string): void {
this.audio.stop(AppSound.Call);
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
@@ -253,8 +276,6 @@ export class DirectCallService {
status: 'ended' as const
};
this.audio.stop(AppSound.Call);
if (meId) {
this.broadcastCallEvent('leave', session);
}
@@ -385,18 +406,29 @@ export class DirectCallService {
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const currentUserIds = this.currentUserIds();
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
if (!meId) {
this.pendingIncomingCallEvents.push(payload);
return;
}
if (!this.callPayloadIncludesParticipant(payload, meId)) {
if (currentUserIds.has(payload.sender.userId)) {
return;
}
if (!this.callPayloadIncludesAnyParticipant(payload, currentUserIds)) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
if (this.isStaleRingForEndedSession(payload, existing)) {
return;
}
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
@@ -424,14 +456,22 @@ export class DirectCallService {
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
await this.directMessages.recordCallStarted(
session.conversationId,
payload.sender,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
if (this.shouldAlertIncomingCall(session)) {
const latestSession = this.sessionById(session.callId);
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (this.shouldAlertIncomingCall(session)) {
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
}
@@ -475,6 +515,14 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
await this.router.navigate(['/call', session.callId]);
@@ -711,9 +759,15 @@ export class DirectCallService {
]);
}
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
private callPayloadIncludesAnyParticipant(payload: DirectCallEventPayload, participantIds: Set<string>): boolean {
return payload.participantIds.some((participantId) => participantIds.has(participantId))
|| (payload.participants ?? []).some((participant) => participantIds.has(participant.userId));
}
private isStaleRingForEndedSession(payload: DirectCallEventPayload, existing: DirectCallSession | null): boolean {
return payload.action === 'ring'
&& existing?.status === 'ended'
&& payload.createdAt <= existing.createdAt;
}
private groupConversationTitle(session: DirectCallSession): string {
@@ -867,6 +921,10 @@ export class DirectCallService {
return session.status !== 'connected' && !this.isDoNotDisturb();
}
private shouldPlayIncomingCallAlert(session: DirectCallSession): boolean {
return this.shouldAlertIncomingCall(session) && this.incomingCall()?.callId === session.callId;
}
private isDoNotDisturb(): boolean {
return this.currentUser()?.status === 'busy';
}
@@ -923,6 +981,25 @@ export class DirectCallService {
return user ? this.userKey(user) : null;
}
private currentUserIds(): Set<string> {
const ids = new Set<string>();
const user = this.currentUser();
if (user?.id) {
ids.add(user.id);
}
if (user?.oderId) {
ids.add(user.oderId);
}
if (user?.peerId) {
ids.add(user.peerId);
}
return ids;
}
private requireCurrentUser(): User {
const user = this.currentUser();