fix: solve small pm chat ui issues
unwrap the pill fix the fetching images in pm not auto download
This commit is contained in:
@@ -6,12 +6,13 @@ Direct calls coordinate private voice sessions started from people cards, direct
|
||||
|
||||
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`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, 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.
|
||||
3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message.
|
||||
4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored.
|
||||
5. 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.
|
||||
6. 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.
|
||||
7. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
|
||||
8. 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.
|
||||
9. 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.
|
||||
|
||||
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user