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,