feat(presence): live stream badges, honest ring delivery, cross-signal DMs
- Camera and screen-share badges are decided by `shouldShowStreamIndicator`, so a live stream is visible from outside the voice channel and clicking the badge joins the streamer's channel before focusing the stream. - `ringParticipants` reports whether a `direct-call` ring reached anyone; a call that reached nobody shows `call.errors.ringUndelivered` instead of sitting in "calling" as if it were live. - Direct messages resolve every local identity alias, so a conversation opened from a foreign roster entry lands in the same thread.
This commit is contained in:
@@ -49,7 +49,8 @@
|
||||
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again.",
|
||||
"ringUndelivered": "Could not reach anyone in this call. They may be offline or on another server."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ Direct calls coordinate private voice sessions started from people cards, direct
|
||||
## 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.
|
||||
2. The caller rings first, then joins a call-scoped voice session; joining a direct call first leaves any other joined call or server voice channel. `ringParticipants` reports the result of every `direct-call` ring sent through `PeerDeliveryService`: when no recipient could be reached over a peer data channel or a signaling route, `deliveryError` is set (`call.errors.ringUndelivered`) and the private-call view shows it next to join errors. A call that reached nobody must not sit in "calling" as if it were live.
|
||||
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 (mute, deafen, camera, screen share), screen/camera tiles, add-user control, and a narrow DM chat panel. Deafen mutes incoming audio and also mutes the local mic, matching voice-channel behavior.
|
||||
|
||||
@@ -527,6 +527,47 @@ describe('DirectCallService', () => {
|
||||
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
||||
});
|
||||
|
||||
it('rings the peer before joining local voice', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const joinCall = vi.fn(async () => undefined);
|
||||
|
||||
context.service.joinCall = joinCall;
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalled();
|
||||
expect(context.delivery.sendCallEvent.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(joinCall.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('surfaces an undelivered ring instead of looking like a live call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValue(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the delivery error once a ring reaches the peer', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValueOnce(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
|
||||
await context.service.startCall(charlie);
|
||||
|
||||
expect(context.service.deliveryError()).toBeNull();
|
||||
});
|
||||
|
||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||
import {
|
||||
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
VoiceSessionFacade,
|
||||
buildMicrophoneConstraints,
|
||||
isDeviceUnavailableError,
|
||||
isVoiceOnAnotherClient,
|
||||
loadVoiceSettingsFromStorage
|
||||
} from '../../../voice-session';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
@@ -93,6 +100,8 @@ export class DirectCallService {
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
|
||||
readonly joinError = signal<string | null>(null);
|
||||
/** User-facing reason the last outgoing ring reached nobody; null once a ring is delivered. */
|
||||
readonly deliveryError = signal<string | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
readonly mobileOverlaySession = computed(() => {
|
||||
const callId = this.mobileOverlayCallId();
|
||||
@@ -230,8 +239,8 @@ export class DirectCallService {
|
||||
session.createdAt
|
||||
);
|
||||
|
||||
this.ringParticipants(session, [peerParticipant.userId]);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.openCallView(session.callId);
|
||||
return session;
|
||||
}
|
||||
@@ -367,12 +376,7 @@ export class DirectCallService {
|
||||
let stream: MediaStream;
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
stream = await this.captureCallMicrophone();
|
||||
} catch {
|
||||
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
|
||||
return;
|
||||
@@ -419,6 +423,38 @@ export class DirectCallService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the mic for a call, honouring the saved device.
|
||||
*
|
||||
* The device is requested exactly, so a saved id that is gone or already
|
||||
* claimed rejects instead of quietly opening a different mic. That must not
|
||||
* cost the user the call, so retry once on the system default.
|
||||
*/
|
||||
private async captureCallMicrophone(): Promise<MediaStream> {
|
||||
const voiceSettings = loadVoiceSettingsFromStorage();
|
||||
const browserNoiseSuppression = !voiceSettings.noiseReduction;
|
||||
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: voiceSettings.inputDevice
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (!voiceSettings.inputDevice || !isDeviceUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
||||
const action = endForEveryone ? 'end' : 'leave';
|
||||
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
||||
@@ -451,7 +487,7 @@ export class DirectCallService {
|
||||
this.upsertSession(convertedSession);
|
||||
this.currentSession.set(convertedSession);
|
||||
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
|
||||
this.sendCallEvent(participant.userId, 'ring', convertedSession);
|
||||
this.ringParticipants(convertedSession, [participant.userId]);
|
||||
}
|
||||
|
||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
||||
@@ -611,8 +647,8 @@ export class DirectCallService {
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
this.ringParticipants(session, this.remoteParticipantIds(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;
|
||||
}
|
||||
@@ -756,10 +792,25 @@ export class DirectCallService {
|
||||
return session;
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
|
||||
/**
|
||||
* Ring every recipient and report the outcome. A call whose ring reached
|
||||
* nobody must not look live, so an undelivered ring surfaces as an error
|
||||
* instead of a session that waits forever.
|
||||
*/
|
||||
private ringParticipants(session: DirectCallSession, recipientIds: readonly string[]): void {
|
||||
const delivered = recipientIds
|
||||
.map((recipientId) => this.sendCallEvent(recipientId, 'ring', session))
|
||||
.filter((wasDelivered) => wasDelivered);
|
||||
|
||||
this.deliveryError.set(delivered.length > 0
|
||||
? null
|
||||
: this.i18n.instant('call.errors.ringUndelivered'));
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): boolean {
|
||||
const me = this.requireCurrentUser();
|
||||
|
||||
this.delivery.sendCallEvent(recipientId, {
|
||||
return this.delivery.sendCallEvent(recipientId, {
|
||||
type: 'direct-call',
|
||||
directCall: {
|
||||
action,
|
||||
|
||||
@@ -32,6 +32,14 @@ Unread counts are idempotent by message id: re-receiving or syncing a message th
|
||||
|
||||
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
|
||||
|
||||
## One human, one thread
|
||||
|
||||
A one-to-one conversation id is derived from participant ids, so an id must never be built from an actor alias. `direct-message-identity.rules.ts` owns the canonicalization: `buildDirectParticipantAliasIndex` maps every alias of a human (home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService`) to the id their threads are stored under, and `getCanonicalDirectConversationId` / `canonicalizeDirectConversationId` resolve outbound and inbound ids through it. Group ids and any id that is not a plain participant pair pass through untouched.
|
||||
|
||||
A peer who met the local user on a foreign signal server addresses them by the provisioned actor id, so the inbound `conversationId` differs from the one the local user builds from their home identity. `DirectMessageService.collapseAliasConversations` therefore merges every stored thread that resolves to the same canonical id on first touch (create, inbound message, inbound sync, call-started record) with `mergeAliasDirectConversations`, deletes the alias copies, and re-points the current selection. Message `senderId`, `recipientId`, and `recipientIds` are canonicalized as well, so acknowledgements and read receipts route to one identity. Without this the recipient kept two threads for one human — one holding the peer's messages, one empty — and clicking the peer opened the empty one (`e2e/tests/chat/cross-signal-dm-identity.spec.ts`).
|
||||
|
||||
The index can only collapse identities the client knows: all of the local user's own aliases, plus the alias ids carried on stored user entities. Two roster entries for one remote human on different signal servers still read as two people.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
|
||||
## Chat View
|
||||
|
||||
+158
-39
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
@@ -24,12 +24,20 @@ import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations,
|
||||
type DirectParticipantAliasIndex
|
||||
} from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
@@ -48,7 +56,7 @@ import type {
|
||||
Reaction,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
|
||||
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
|
||||
@@ -75,6 +83,7 @@ export class DirectMessageService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly notifications = inject(NotificationsFacade);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
@@ -128,10 +137,11 @@ export class DirectMessageService {
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const peerParticipant = toDirectMessageParticipant(user);
|
||||
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const peerParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(user));
|
||||
const conversationId = getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId);
|
||||
|
||||
if (existingConversation) {
|
||||
this.selectedConversationIdSignal.set(existingConversation.id);
|
||||
@@ -258,30 +268,32 @@ export class DirectMessageService {
|
||||
}
|
||||
|
||||
async recordCallStarted(
|
||||
conversationId: string,
|
||||
rawConversationId: string,
|
||||
caller: DirectMessageParticipant,
|
||||
participants: DirectMessageParticipant[],
|
||||
timestamp = Date.now()
|
||||
): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, rawConversationId);
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const canonicalCaller = this.canonicalParticipant(aliasIndex, caller);
|
||||
const allParticipants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
caller,
|
||||
...participants
|
||||
canonicalCaller,
|
||||
...participants.map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, canonicalCaller, allParticipants, timestamp);
|
||||
const conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
|
||||
const message = createDirectCallStartedMessage(
|
||||
conversation.id,
|
||||
caller,
|
||||
conversation.participants.filter((participantId) => participantId !== caller.userId),
|
||||
canonicalCaller,
|
||||
conversation.participants.filter((participantId) => participantId !== canonicalCaller.userId),
|
||||
timestamp
|
||||
);
|
||||
|
||||
@@ -517,23 +529,32 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const sender = payload.sender;
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const conversationId = payload.message.conversationId
|
||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
||||
? canonicalizeDirectConversationId(aliasIndex, payload.message.conversationId)
|
||||
: getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, sender.userId);
|
||||
const participants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
sender,
|
||||
...(payload.participants ?? [])
|
||||
...(payload.participants ?? []).map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || participants.length > 2
|
||||
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.message.timestamp),
|
||||
id: conversationId
|
||||
});
|
||||
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
|
||||
const incomingMessage: DirectMessage = {
|
||||
...payload.message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, payload.message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, payload.message.recipientId),
|
||||
recipientIds: payload.message.recipientIds?.map((recipientId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, recipientId)),
|
||||
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
|
||||
};
|
||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
||||
@@ -590,7 +611,10 @@ export class DirectMessageService {
|
||||
|
||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
|
||||
@@ -607,25 +631,28 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const senderId = canonicalizeDirectParticipantId(aliasIndex, payload.sender.userId);
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
|
||||
if (!conversation
|
||||
|| !directMessageConversationIncludesUser(conversation, selfUserIds)
|
||||
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
|
||||
|| !directMessageConversationIncludesUser(conversation, senderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.isTyping) {
|
||||
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
|
||||
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
|
||||
!(entry.conversationId === conversationId && entry.userId === senderId)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry: DirectMessageTypingEntry = {
|
||||
conversationId: payload.conversationId,
|
||||
userId: payload.sender.userId,
|
||||
conversationId,
|
||||
userId: senderId,
|
||||
displayName: payload.sender.displayName,
|
||||
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
|
||||
};
|
||||
@@ -641,7 +668,10 @@ export class DirectMessageService {
|
||||
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation
|
||||
@@ -668,7 +698,9 @@ export class DirectMessageService {
|
||||
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (selfUserIds.has(payload.sender.userId)) {
|
||||
@@ -679,14 +711,18 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
|
||||
?? await this.repository.getConversation(ownerId, payload.conversationId)
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const syncParticipants = payload.participants.map((participant) => this.canonicalParticipant(aliasIndex, participant));
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || payload.participants.length > 2
|
||||
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
|
||||
? createGroupConversation(conversationId, [currentParticipant, ...syncParticipants], payload.syncedAt, payload.conversationTitle)
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.syncedAt),
|
||||
id: conversationId
|
||||
});
|
||||
const participantProfiles = {
|
||||
...existingConversation.participantProfiles,
|
||||
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
|
||||
...Object.fromEntries(syncParticipants.map((participant) => [participant.userId, participant])),
|
||||
[currentParticipant.userId]: currentParticipant
|
||||
};
|
||||
const syncBaseConversation: DirectMessageConversation = {
|
||||
@@ -697,14 +733,20 @@ export class DirectMessageService {
|
||||
participantProfiles
|
||||
};
|
||||
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
|
||||
(conversation, message) => upsertDirectMessage(conversation, message, false),
|
||||
(conversation, message) => upsertDirectMessage(conversation, {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds?.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId))
|
||||
}, false),
|
||||
syncBaseConversation
|
||||
);
|
||||
|
||||
await this.persistConversation(ownerId, mergedConversation);
|
||||
|
||||
if (this.selectedConversationIdSignal() === payload.conversationId) {
|
||||
await this.markRead(payload.conversationId);
|
||||
if (this.selectedConversationIdSignal() === conversationId) {
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +954,83 @@ export class DirectMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One human can be addressed by several ids: their home id and one
|
||||
* provisioned actor id per foreign signal server. Threads are stored under
|
||||
* the home id, so every inbound id is resolved through this index first.
|
||||
*/
|
||||
private participantAliasIndex(): DirectParticipantAliasIndex {
|
||||
const currentUser = this.currentUser();
|
||||
const peerGroups = this.users().map((user) => ({
|
||||
canonicalId: user.oderId || user.id,
|
||||
aliasIds: [
|
||||
user.id,
|
||||
user.oderId,
|
||||
user.peerId
|
||||
].filter((aliasId): aliasId is string => !!aliasId)
|
||||
}));
|
||||
const selfGroups = currentUser
|
||||
? [
|
||||
{
|
||||
canonicalId: currentUser.oderId || currentUser.id,
|
||||
aliasIds: [...this.getSelfUserIds()]
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return buildDirectParticipantAliasIndex([...peerGroups, ...selfGroups]);
|
||||
}
|
||||
|
||||
private canonicalParticipant(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participant: DirectMessageParticipant
|
||||
): DirectMessageParticipant {
|
||||
const canonicalId = canonicalizeDirectParticipantId(aliasIndex, participant.userId);
|
||||
|
||||
return canonicalId === participant.userId ? participant : { ...participant, userId: canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold any thread that resolves to `canonicalId` into a single stored
|
||||
* conversation. Returns null when this human pair has no thread yet.
|
||||
*/
|
||||
private async collapseAliasConversations(
|
||||
ownerId: string,
|
||||
canonicalId: string
|
||||
): Promise<DirectMessageConversation | null> {
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const matching = this.conversationsSignal().filter((conversation) =>
|
||||
canonicalizeDirectConversationId(aliasIndex, conversation.id) === canonicalId);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matching.length === 1 && matching[0].id === canonicalId) {
|
||||
return matching[0];
|
||||
}
|
||||
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, matching);
|
||||
const staleIds = matching.map((conversation) => conversation.id).filter((id) => id !== merged.id);
|
||||
|
||||
await this.persistConversation(ownerId, merged);
|
||||
|
||||
for (const staleId of staleIds) {
|
||||
await this.repository.deleteConversation(ownerId, staleId);
|
||||
}
|
||||
|
||||
this.conversationsSignal.update((conversations) => conversations.filter((conversation) =>
|
||||
!staleIds.includes(conversation.id)));
|
||||
|
||||
if (staleIds.includes(this.selectedConversationIdSignal() ?? '')) {
|
||||
this.selectedConversationIdSignal.set(merged.id);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private mergeConversationParticipants(
|
||||
conversation: DirectMessageConversation,
|
||||
participants: DirectMessageParticipant[]
|
||||
|
||||
+111
-2
@@ -1,10 +1,21 @@
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversation,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
directMessageConversationIncludesAnyUser,
|
||||
directMessageEventIncludesAnyUser,
|
||||
isSelfDirectMessageSender
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations
|
||||
} from './direct-message-identity.rules';
|
||||
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
|
||||
import { getDirectConversationId } from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageParticipant
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
const aliceHome: DirectMessageParticipant = {
|
||||
userId: 'alice-home',
|
||||
@@ -112,3 +123,101 @@ describe('direct-message-identity.rules', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct conversation canonicalization', () => {
|
||||
const bobAliases = { canonicalId: 'bob-home', aliasIds: ['bob-home', 'bob-foreign'] };
|
||||
const aliceAliases = { canonicalId: 'alice-home', aliasIds: ['alice-home', 'alice-foreign'] };
|
||||
const aliasIndex = buildDirectParticipantAliasIndex([bobAliases, aliceAliases]);
|
||||
|
||||
it('resolves every alias of one human to the same participant id', () => {
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-foreign')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-home')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'charlie')).toBe('charlie');
|
||||
});
|
||||
|
||||
it('gives two devices of the same human one conversation id', () => {
|
||||
const fromHomeDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-home', 'alice-home');
|
||||
const fromForeignDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-foreign', 'alice-foreign');
|
||||
|
||||
expect(fromForeignDevice).toBe(fromHomeDevice);
|
||||
expect(fromHomeDevice).toBe(getDirectConversationId('bob-home', 'alice-home'));
|
||||
});
|
||||
|
||||
it('rewrites an inbound conversation id built from actor aliases', () => {
|
||||
const inboundId = getDirectConversationId('alice-foreign', 'bob-foreign');
|
||||
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, inboundId))
|
||||
.toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
});
|
||||
|
||||
it('leaves group and unparseable conversation ids untouched', () => {
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'dm-group-1234')).toBe('dm-group-1234');
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'call-42')).toBe('call-42');
|
||||
});
|
||||
|
||||
it('rewrites conversation participants and message ids onto canonical identities', () => {
|
||||
const canonical = canonicalizeDirectConversation(aliasIndex, aliasConversation());
|
||||
|
||||
expect(canonical.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(canonical.participants).toEqual(['alice-home', 'bob-home']);
|
||||
expect(Object.keys(canonical.participantProfiles).sort()).toEqual(['alice-home', 'bob-home']);
|
||||
expect(canonical.messages[0].senderId).toBe('alice-home');
|
||||
expect(canonical.messages[0].recipientId).toBe('bob-home');
|
||||
expect(canonical.messages[0].recipientIds).toEqual(['bob-home']);
|
||||
expect(canonical.messages[0].conversationId).toBe(canonical.id);
|
||||
});
|
||||
|
||||
it('merges an alias thread into the canonical thread without losing messages or unread count', () => {
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, [canonicalConversation(), aliasConversation()]);
|
||||
|
||||
expect(merged.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(merged.messages.map((message) => message.id)).toEqual(['message-home', 'message-foreign']);
|
||||
expect(merged.messages.every((message) => message.conversationId === merged.id)).toBe(true);
|
||||
expect(merged.unreadCount).toBe(3);
|
||||
expect(merged.lastMessageAt).toBe(20);
|
||||
expect(merged.participants).toEqual(['alice-home', 'bob-home']);
|
||||
});
|
||||
});
|
||||
|
||||
function canonicalConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-home', 'bob-home'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-home', 'bob-home'],
|
||||
participantProfiles: {
|
||||
'alice-home': aliceHome,
|
||||
'bob-home': bobHome
|
||||
},
|
||||
messages: [createMessage('message-home', 'alice-home', 'bob-home', 10)],
|
||||
lastMessageAt: 10,
|
||||
unreadCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
function aliasConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-foreign', 'bob-foreign'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-foreign', 'bob-foreign'],
|
||||
participantProfiles: {
|
||||
'alice-foreign': { ...aliceHome, userId: 'alice-foreign' },
|
||||
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
|
||||
},
|
||||
messages: [createMessage('message-foreign', 'alice-foreign', 'bob-foreign', 20)],
|
||||
lastMessageAt: 20,
|
||||
unreadCount: 2
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(id: string, senderId: string, recipientId: string, timestamp: number): DirectMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: getDirectConversationId(senderId, recipientId),
|
||||
senderId,
|
||||
recipientId,
|
||||
recipientIds: [recipientId],
|
||||
content: 'hello',
|
||||
timestamp,
|
||||
status: 'DELIVERED'
|
||||
};
|
||||
}
|
||||
|
||||
+192
-2
@@ -1,9 +1,29 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
|
||||
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
|
||||
import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
getDirectConversationId,
|
||||
upsertDirectMessage
|
||||
} from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
||||
|
||||
const DIRECT_CONVERSATION_ID_PREFIX = 'dm-';
|
||||
const DIRECT_CONVERSATION_ID_SEPARATOR = '--';
|
||||
|
||||
export interface DirectParticipantAliasGroup {
|
||||
canonicalId: string;
|
||||
aliasIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Maps every known alias id of a human to the one id their threads are stored under. */
|
||||
export type DirectParticipantAliasIndex = ReadonlyMap<string, string>;
|
||||
|
||||
/** Collect every id that can represent the local user in direct-message traffic. */
|
||||
export function collectDirectMessageSelfUserIds(
|
||||
user: UserIdentityFields,
|
||||
@@ -47,3 +67,173 @@ export function directMessageConversationIncludesAnyUser(
|
||||
): boolean {
|
||||
return directMessageConversationIncludesUser(conversation, userIds);
|
||||
}
|
||||
|
||||
export function buildDirectParticipantAliasIndex(
|
||||
groups: readonly DirectParticipantAliasGroup[]
|
||||
): DirectParticipantAliasIndex {
|
||||
const index = new Map<string, string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const canonicalId = group.canonicalId.trim();
|
||||
|
||||
if (!canonicalId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const aliasId of group.aliasIds) {
|
||||
const alias = aliasId?.trim();
|
||||
|
||||
if (alias) {
|
||||
index.set(alias, canonicalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function canonicalizeDirectParticipantId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participantId: string
|
||||
): string {
|
||||
const normalized = participantId?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return participantId;
|
||||
}
|
||||
|
||||
return aliasIndex.get(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
export function getCanonicalDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
firstParticipantId: string,
|
||||
secondParticipantId: string
|
||||
): string {
|
||||
return getDirectConversationId(
|
||||
canonicalizeDirectParticipantId(aliasIndex, firstParticipantId),
|
||||
canonicalizeDirectParticipantId(aliasIndex, secondParticipantId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a two-party conversation id whose ids are actor aliases onto the
|
||||
* canonical pair id. Ids that are not a plain participant pair (group threads,
|
||||
* ids carrying the separator inside a participant id) are returned unchanged.
|
||||
*/
|
||||
export function canonicalizeDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversationId: string
|
||||
): string {
|
||||
const participantIds = parseDirectConversationParticipantIds(conversationId);
|
||||
|
||||
if (!participantIds) {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
return getCanonicalDirectConversationId(aliasIndex, participantIds[0], participantIds[1]);
|
||||
}
|
||||
|
||||
export function canonicalizeDirectConversation(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversation: DirectMessageConversation
|
||||
): DirectMessageConversation {
|
||||
const canonicalId = canonicalizeDirectConversationId(aliasIndex, conversation.id);
|
||||
const participantProfiles = Object.fromEntries(
|
||||
Object.entries(conversation.participantProfiles).map(([participantId, profile]) => {
|
||||
const canonicalParticipantId = canonicalizeDirectParticipantId(aliasIndex, participantId);
|
||||
|
||||
return [
|
||||
canonicalParticipantId,
|
||||
{
|
||||
...profile,
|
||||
userId: canonicalizeDirectParticipantId(aliasIndex, profile.userId)
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
const canonicalParticipantIds = conversation.participants.map((participantId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, participantId));
|
||||
const participants = [...new Set([...canonicalParticipantIds, ...Object.keys(participantProfiles)])].sort();
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
id: canonicalId,
|
||||
participants,
|
||||
participantProfiles,
|
||||
messages: conversation.messages.map((message) => canonicalizeDirectMessage(aliasIndex, message, canonicalId))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold alias threads of the same two humans into one canonical thread. Unread
|
||||
* counts add up because each thread held messages the user has not seen yet.
|
||||
*/
|
||||
export function mergeAliasDirectConversations(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversations: readonly DirectMessageConversation[]
|
||||
): DirectMessageConversation {
|
||||
const canonicalConversations = conversations.map((conversation) =>
|
||||
canonicalizeDirectConversation(aliasIndex, conversation));
|
||||
const [first, ...rest] = canonicalConversations;
|
||||
|
||||
if (!first) {
|
||||
throw new Error('Cannot merge an empty list of direct conversations.');
|
||||
}
|
||||
|
||||
return rest.reduce((merged, conversation) => {
|
||||
const withParticipants: DirectMessageConversation = {
|
||||
...merged,
|
||||
kind: merged.kind === 'group' || conversation.kind === 'group' ? 'group' : merged.kind,
|
||||
title: merged.title ?? conversation.title,
|
||||
participants: [...new Set([...merged.participants, ...conversation.participants])].sort(),
|
||||
participantProfiles: {
|
||||
...conversation.participantProfiles,
|
||||
...merged.participantProfiles
|
||||
},
|
||||
lastMessageAt: Math.max(merged.lastMessageAt, conversation.lastMessageAt),
|
||||
unreadCount: merged.unreadCount + conversation.unreadCount
|
||||
};
|
||||
|
||||
return conversation.messages.reduce(
|
||||
(target, message) => upsertDirectMessage(target, message, false),
|
||||
withParticipants
|
||||
);
|
||||
}, first);
|
||||
}
|
||||
|
||||
function canonicalizeDirectMessage(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
message: DirectMessage,
|
||||
conversationId: string
|
||||
): DirectMessage {
|
||||
return {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds
|
||||
? [...new Set(message.recipientIds.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId)))]
|
||||
: message.recipientIds
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectConversationParticipantIds(conversationId: string): [string, string] | null {
|
||||
if (!conversationId?.startsWith(DIRECT_CONVERSATION_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = conversationId
|
||||
.slice(DIRECT_CONVERSATION_ID_PREFIX.length)
|
||||
.split(DIRECT_CONVERSATION_ID_SEPARATOR);
|
||||
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return [decodeURIComponent(segments[0]), decodeURIComponent(segments[1])];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { shouldShowStreamIndicator, type StreamIndicatorInput } from './stream-indicator.rules';
|
||||
|
||||
function buildInput(overrides: Partial<StreamIndicatorInput> = {}): StreamIndicatorInput {
|
||||
return {
|
||||
announcedState: undefined,
|
||||
hasLiveRemoteTrack: false,
|
||||
isSelf: false,
|
||||
localStreamActive: false,
|
||||
subjectInVoice: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldShowStreamIndicator', () => {
|
||||
it('shows an announced share even though the observer never joined voice', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
hasLiveRemoteTrack: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the indicator for a user who is not in a voice channel', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
subjectInVoice: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts a stop announcement over a track that has not torn down yet', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: false,
|
||||
hasLiveRemoteTrack: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a live track when the peer never announced', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: undefined,
|
||||
hasLiveRemoteTrack: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the local stream for the local user and ignores peer announcements', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: false,
|
||||
isSelf: true,
|
||||
localStreamActive: true
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the local indicator when the local user streams nothing', () => {
|
||||
const isVisible = shouldShowStreamIndicator(buildInput({
|
||||
announcedState: true,
|
||||
isSelf: true,
|
||||
localStreamActive: false
|
||||
}));
|
||||
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface StreamIndicatorInput {
|
||||
/** The observed user is connected to a voice channel. Nobody can stream from outside one. */
|
||||
subjectInVoice: boolean;
|
||||
/** The observed user is the local user, whose stream state is known locally. */
|
||||
isSelf: boolean;
|
||||
localStreamActive: boolean;
|
||||
/** What the observed user announced over its peer channel, `undefined` when it never did. */
|
||||
announcedState: boolean | undefined;
|
||||
hasLiveRemoteTrack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a remote user's live indicator belongs on screen.
|
||||
*
|
||||
* The observer's own voice state is deliberately absent from the input: gating
|
||||
* on it hid the indicator from everyone outside the channel, so a user sharing
|
||||
* alone looked idle and nobody could tell there was anything to watch. An
|
||||
* announcement arrives over the peer channel whether or not the observer joined
|
||||
* voice, so it is authoritative once present, and a live track only ever backs
|
||||
* up a peer that never announced.
|
||||
*/
|
||||
export function shouldShowStreamIndicator(input: StreamIndicatorInput): boolean {
|
||||
if (!input.subjectInVoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.isSelf) {
|
||||
return input.localStreamActive;
|
||||
}
|
||||
|
||||
if (input.announcedState !== undefined) {
|
||||
return input.announcedState;
|
||||
}
|
||||
|
||||
return input.hasLiveRemoteTrack;
|
||||
}
|
||||
@@ -133,8 +133,9 @@ export class PrivateCallComponent {
|
||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||
readonly joinError = this.calls.joinError;
|
||||
readonly deliveryError = this.calls.deliveryError;
|
||||
readonly cameraError = signal<string | null>(null);
|
||||
readonly callErrorMessage = computed(() => this.joinError() ?? this.cameraError());
|
||||
readonly callErrorMessage = computed(() => this.joinError() ?? this.deliveryError() ?? this.cameraError());
|
||||
readonly showScreenShareButton = computed(() => !this.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
readonly remoteStreamRevision = signal(0);
|
||||
readonly includeSystemAudio = signal(false);
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
<button
|
||||
(click)="viewStream(u.oderId || u.id); $event.stopPropagation()"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
data-testid="voice-user-live"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="getUserLiveIconName(u.oderId || u.id)"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -58,7 +57,8 @@ import {
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspaceService,
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
isVoiceOnAnotherClient,
|
||||
shouldShowStreamIndicator
|
||||
} from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
@@ -162,6 +162,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Stream to open once a join started from a LIVE badge finishes connecting. */
|
||||
private pendingStreamFocus: string | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
@@ -840,11 +842,25 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
this.applyPendingStreamFocus();
|
||||
}
|
||||
|
||||
private applyPendingStreamFocus(): void {
|
||||
const focusTarget = this.pendingStreamFocus;
|
||||
|
||||
this.pendingStreamFocus = null;
|
||||
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
private handleVoiceJoinFailure(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
||||
|
||||
this.pendingStreamFocus = null;
|
||||
this.voiceConnection.reportConnectionError(message);
|
||||
}
|
||||
|
||||
@@ -988,7 +1004,22 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
viewStream(userId: string) {
|
||||
const focusTarget = this.isUserSharing(userId) ? `screen:${userId}` : `camera:${userId}`;
|
||||
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
if (this.voiceSessionService.voiceSession()) {
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const streamerVoiceState = this.findKnownUser(userId)?.voiceState;
|
||||
|
||||
// Only the streamer's own channel can be joined on their behalf, and only on
|
||||
// the server being viewed, since `joinVoice` resolves the server from the view.
|
||||
if (!streamerVoiceState?.roomId || streamerVoiceState.serverId !== this.currentRoom()?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingStreamFocus = focusTarget;
|
||||
this.joinVoice(streamerVoiceState.roomId);
|
||||
}
|
||||
|
||||
canMoveVoiceUsers(): boolean {
|
||||
@@ -1109,54 +1140,27 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isUserOnCamera(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.voiceConnection.isCameraEnabled();
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getPeerKeysForUser(user, userId).some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
|
||||
return shouldShowStreamIndicator({
|
||||
announcedState: user?.cameraState?.isEnabled,
|
||||
hasLiveRemoteTrack: this.getPeerKeysForUser(user, userId)
|
||||
.some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey))),
|
||||
isSelf: this.isCurrentUserId(userId),
|
||||
localStreamActive: this.voiceConnection.isCameraEnabled(),
|
||||
subjectInVoice: this.isSubjectInVoice(userId, user)
|
||||
});
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.screenShare.isScreenSharing();
|
||||
}
|
||||
|
||||
if (user?.screenShareState?.isSharing === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream =
|
||||
this.getPeerKeysForUser(user, userId)
|
||||
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
|
||||
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
|
||||
|
||||
return this.hasActiveVideoStream(stream);
|
||||
return shouldShowStreamIndicator({
|
||||
announcedState: user?.screenShareState?.isSharing,
|
||||
hasLiveRemoteTrack: this.getPeerKeysForUser(user, userId)
|
||||
.some((peerKey) => this.hasActiveVideoStream(this.screenShare.getRemoteScreenShareStream(peerKey))),
|
||||
isSelf: this.isCurrentUserId(userId),
|
||||
localStreamActive: this.screenShare.isScreenSharing(),
|
||||
subjectInVoice: this.isSubjectInVoice(userId, user)
|
||||
});
|
||||
}
|
||||
|
||||
isUserStreaming(userId: string): boolean {
|
||||
@@ -1315,23 +1319,16 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) ?? null;
|
||||
}
|
||||
|
||||
private isUserInCurrentVoiceRoom(userId: string, user: User | null): boolean {
|
||||
const currentVoiceState = this.currentUser()?.voiceState;
|
||||
private isCurrentUserId(userId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!currentVoiceState?.isConnected || !currentVoiceState.roomId || !currentVoiceState.serverId) {
|
||||
return false;
|
||||
}
|
||||
return !!current && (current.id === userId || current.oderId === userId);
|
||||
}
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!!user?.voiceState?.isConnected &&
|
||||
user.voiceState.roomId === currentVoiceState.roomId &&
|
||||
user.voiceState.serverId === currentVoiceState.serverId
|
||||
);
|
||||
private isSubjectInVoice(userId: string, user: User | null): boolean {
|
||||
return this.isCurrentUserId(userId)
|
||||
? !!this.currentUser()?.voiceState?.isConnected
|
||||
: !!user?.voiceState?.isConnected;
|
||||
}
|
||||
|
||||
private getPeerKeysForUser(user: User | null, userId: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user