Fix private calls

This commit is contained in:
2026-05-17 15:14:52 +02:00
parent 0f6cb3ee77
commit e769a6ee4a
71 changed files with 5821 additions and 349 deletions

View File

@@ -1,6 +1,8 @@
# Direct Message Domain
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
Direct messages provide local, offline-safe one-to-one and small-group messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
The same `PeerDeliveryService` also exposes direct-call events for the `direct-call` domain so private calls can ring through either an open peer data channel or a known signaling route without adding a second recipient lookup path.
## Structure
@@ -15,8 +17,8 @@ direct-message/
## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
3. If no data channel is connected, `PeerDeliveryService` tries the recipient's known signaling route before leaving the message queued.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
3. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
@@ -29,6 +31,14 @@ The DM view reuses the chat domain's shared message list, composer, overlays, ma
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
When a private call grows beyond two participants, the direct-call domain creates a new empty `group` conversation and points the call chat panel at it. The previous one-to-one conversation remains untouched, so private history is not copied into the group chat. Group conversations reuse the same composer, message list, attachment, GIF, markdown, link-embed, typing, mutation, and sync paths as one-to-one DMs; delivery simply fans out to every participant except the sender.
The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant.
Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM.
When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift.
## GIFs
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.

View File

@@ -1,7 +1,9 @@
import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
@@ -17,6 +19,11 @@ const bob: DirectMessageParticipant = {
username: 'bob',
displayName: 'Bob'
};
const charlie: DirectMessageParticipant = {
userId: 'charlie',
username: 'charlie',
displayName: 'Charlie'
};
describe('DirectMessageService domain flow', () => {
it('should create conversation', () => {
@@ -44,6 +51,41 @@ describe('DirectMessageService domain flow', () => {
expect(updatedConversation.messages[0].status).toBe('QUEUED');
});
it('should create empty group conversation without direct-message history', () => {
const directConversation = upsertDirectMessage(createDirectConversation(alice, bob, 10), createMessage('message-1', 'SENT'), false);
const groupConversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 30, 'Alice, Bob, Charlie');
expect(isGroupDirectConversation(groupConversation)).toBe(true);
expect(groupConversation.id).toBe('dm-group-test');
expect(groupConversation.title).toBe('Alice, Bob, Charlie');
expect(groupConversation.participants).toEqual([
'alice',
'bob',
'charlie'
]);
expect(groupConversation.messages).toEqual([]);
expect(directConversation.messages).toHaveLength(1);
});
it('should preserve group message recipient metadata', () => {
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 10);
const recipientIds = ['bob', 'charlie'];
const message = createMessage('message-1', 'QUEUED', conversation.id, recipientIds);
const updatedConversation = upsertDirectMessage(conversation, message, false);
expect(updatedConversation.messages[0].recipientId).toBe('bob');
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
});
it('should update status correctly', () => {
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
@@ -52,12 +94,18 @@ describe('DirectMessageService domain flow', () => {
});
});
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
function createMessage(
id: string,
status: DirectMessage['status'],
conversationId = getDirectConversationId('alice', 'bob'),
recipientIds = ['bob']
): DirectMessage {
return {
id,
conversationId: getDirectConversationId('alice', 'bob'),
conversationId,
senderId: 'alice',
recipientId: 'bob',
recipientId: recipientIds[0],
recipientIds,
content: 'Hello',
timestamp: 20,
status

View File

@@ -12,10 +12,13 @@ import { v4 as uuidv4 } from 'uuid';
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
@@ -24,8 +27,12 @@ import {
DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus,
DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload,
toDirectMessageParticipant
} from '../../domain/models/direct-message.model';
import type {
@@ -35,16 +42,32 @@ import type {
} from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
const DIRECT_MESSAGE_TYPING_TTL_MS = 3000;
const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000;
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';
interface DirectMessageTypingEntry {
conversationId: string;
userId: string;
displayName: string;
expiresAt: number;
}
@Injectable({ providedIn: 'root' })
export class DirectMessageService {
private readonly repository = inject(DirectMessageRepository);
private readonly offlineQueue = inject(OfflineMessageQueueService);
private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
private readonly lastSyncRequestAt = new Map<string, number>();
private loadedOwnerId: string | null = null;
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
@@ -62,6 +85,7 @@ export class DirectMessageService {
(total, conversation) => total + conversation.unreadCount,
0
));
readonly typingEntries = this.typingEntriesSignal.asReadonly();
constructor() {
effect(() => {
@@ -76,11 +100,15 @@ export class DirectMessageService {
this.delivery.peerConnected$.subscribe(() => {
void this.retryPending();
void this.requestOpenConversationSync();
});
this.delivery.networkRestored$.subscribe(() => {
void this.retryPending();
void this.requestOpenConversationSync();
});
window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS);
}
async createConversation(user: User): Promise<DirectMessageConversation> {
@@ -106,12 +134,47 @@ export class DirectMessageService {
return conversation;
}
async createGroupConversation(
participants: DirectMessageParticipant[],
title?: string,
conversationId = `dm-group-${uuidv4()}`
): Promise<DirectMessageConversation> {
const currentUser = this.requireCurrentUser();
const ownerId = this.getCurrentUserIdOrThrow();
const currentParticipant = toDirectMessageParticipant(currentUser);
const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]);
await this.loadForOwner(ownerId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
if (existingConversation) {
const mergedConversation = this.mergeConversationParticipants({
...existingConversation,
kind: 'group',
title: existingConversation.title || title
}, allParticipants);
await this.persistConversation(ownerId, mergedConversation);
this.selectedConversationIdSignal.set(mergedConversation.id);
return mergedConversation;
}
const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title);
await this.persistConversation(ownerId, conversation);
this.selectedConversationIdSignal.set(conversation.id);
return conversation;
}
async openConversation(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
await this.loadForOwner(ownerId);
this.selectedConversationIdSignal.set(conversationId);
await this.markRead(conversationId);
this.requestConversationSync(conversationId);
}
closeConversationView(conversationId?: string | null): void {
@@ -152,10 +215,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const senderId = currentUser.oderId || currentUser.id;
const recipientId = conversation.participants.find((participantId) => participantId !== senderId);
const recipientIds = this.recipientIdsFor(conversation, senderId);
const recipientId = recipientIds[0];
if (!recipientId) {
throw new Error('Direct message conversation has no recipient.');
throw new Error('Direct message conversation has no recipients.');
}
const message: DirectMessage = {
@@ -163,6 +227,7 @@ export class DirectMessageService {
conversationId,
senderId,
recipientId,
recipientIds,
content: normalizedContent,
timestamp: Date.now(),
status: 'QUEUED',
@@ -172,7 +237,7 @@ export class DirectMessageService {
};
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
await this.attemptDelivery(ownerId, message);
await this.attemptDelivery(ownerId, message, conversation);
return message;
}
@@ -249,9 +314,8 @@ export class DirectMessageService {
requestPeerAvatarSync(conversationId: string): void {
const currentUserId = this.getCurrentUserId();
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
if (peerId) {
for (const peerId of this.recipientIdsFor(conversation, currentUserId)) {
this.delivery.requestUserAvatar(peerId);
}
}
@@ -321,13 +385,51 @@ export class DirectMessageService {
for (const messageId of pendingMessageIds) {
const message = messages.find((entry) => entry.id === messageId);
const conversation = message
? this.conversationsSignal().find((entry) => entry.id === message.conversationId)
: null;
if (message) {
await this.attemptDelivery(ownerId, message);
if (message && conversation) {
await this.attemptDelivery(ownerId, message, conversation);
}
}
}
typingUsers(conversationId: string | null | undefined): string[] {
if (!conversationId) {
return [];
}
const now = Date.now();
return this.typingEntriesSignal()
.filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now)
.map((entry) => entry.displayName);
}
sendTyping(conversationId: string, isTyping = true): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-typing',
directMessageTyping: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
isTyping,
updatedAt: Date.now()
}
});
}
}
private async handlePeerEvent(event: ChatEvent): Promise<void> {
if (event.type === 'direct-message' && event.directMessage) {
await this.handleIncomingMessage(event.directMessage);
@@ -341,6 +443,21 @@ export class DirectMessageService {
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
await this.handleIncomingMutation(event.directMessageMutation);
return;
}
if (event.type === 'direct-message-typing' && event.directMessageTyping) {
this.handleIncomingTyping(event.directMessageTyping);
return;
}
if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) {
await this.handleIncomingSyncRequest(event.directMessageSyncRequest);
return;
}
if (event.type === 'direct-message-sync' && event.directMessageSync) {
await this.handleIncomingSync(event.directMessageSync);
}
}
@@ -351,8 +468,16 @@ export class DirectMessageService {
const sender = payload.sender;
const conversationId = payload.message.conversationId
|| getDirectConversationId(currentParticipant.userId, sender.userId);
const participants = this.uniqueParticipants([
currentParticipant,
sender,
...(payload.participants ?? [])
]);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
?? (payload.conversationKind === 'group' || participants.length > 2
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
const incomingMessage: DirectMessage = {
...payload.message,
conversationId,
@@ -360,7 +485,7 @@ export class DirectMessageService {
};
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread));
this.sendStatusUpdate(incomingMessage.senderId, {
conversationId,
messageId: incomingMessage.id,
@@ -384,14 +509,20 @@ export class DirectMessageService {
private isConversationVisible(conversationId: string): boolean {
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
if (!currentUrl.startsWith('/dm/')) {
if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) {
if (currentUrl.startsWith('/call/')) {
return this.selectedConversationIdSignal() === conversationId;
}
return false;
}
const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/';
try {
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId;
} catch {
return currentUrl.slice('/dm/'.length) === conversationId;
return currentUrl.slice(prefix.length) === conversationId;
}
}
@@ -402,6 +533,98 @@ export class DirectMessageService {
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
}
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId();
if (!currentUserId || payload.sender.userId === currentUserId) {
return;
}
if (!payload.isTyping) {
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
));
return;
}
const nextEntry: DirectMessageTypingEntry = {
conversationId: payload.conversationId,
userId: payload.sender.userId,
displayName: payload.sender.displayName,
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
};
this.typingEntriesSignal.update((entries) => [
...entries.filter((entry) =>
!(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId)
),
nextEntry
]);
}
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId);
if (!conversation || payload.sender.userId === ownerId) {
return;
}
this.delivery.sendViaWebRTC(payload.sender.userId, {
type: 'direct-message-sync',
directMessageSync: {
conversationId: conversation.id,
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title,
messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT),
syncedAt: Date.now()
}
});
}
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
if (payload.sender.userId === ownerId) {
return;
}
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId)
?? (payload.conversationKind === 'group' || payload.participants.length > 2
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
const participantProfiles = {
...existingConversation.participantProfiles,
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
[currentParticipant.userId]: currentParticipant
};
const syncBaseConversation: DirectMessageConversation = {
...existingConversation,
kind: payload.conversationKind ?? existingConversation.kind,
title: payload.conversationTitle ?? existingConversation.title,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
(conversation, message) => upsertDirectMessage(conversation, message, false),
syncBaseConversation
);
await this.persistConversation(ownerId, mergedConversation);
if (this.selectedConversationIdSignal() === payload.conversationId) {
await this.markRead(payload.conversationId);
}
}
private async applyAndSendMutation(
conversationId: string,
payload: DirectMessageMutationEventPayload
@@ -409,11 +632,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId);
const updatedConversation = this.applyMutation(conversation, payload);
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId);
const recipientIds = this.recipientIdsFor(conversation, ownerId);
await this.persistConversation(ownerId, updatedConversation);
if (recipientId) {
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-mutation',
directMessageMutation: payload
@@ -474,23 +697,38 @@ export class DirectMessageService {
return { ...conversation, messages };
}
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise<void> {
const currentUser = this.requireCurrentUser();
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser)
}
});
const recipientIds = this.recipientIdsFor(conversation, ownerId);
if (!sent) {
await this.offlineQueue.enqueue(ownerId, message.id);
return;
let sentCount = 0;
for (const recipientId of recipientIds) {
if (this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title
}
})) {
sentCount += 1;
}
}
await this.offlineQueue.markDelivered(ownerId, message.id);
await this.updateStatus(message.id, 'SENT');
if (sentCount < recipientIds.length) {
await this.offlineQueue.enqueue(ownerId, message.id);
}
if (sentCount > 0) {
await this.updateStatus(message.id, 'SENT');
}
if (sentCount === recipientIds.length) {
await this.offlineQueue.markDelivered(ownerId, message.id);
}
}
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
@@ -500,6 +738,52 @@ export class DirectMessageService {
});
}
private requestOpenConversationSync(): void {
const conversationId = this.selectedConversationIdSignal();
if (conversationId) {
this.requestConversationSync(conversationId);
}
}
private requestConversationSync(conversationId: string): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
const now = Date.now();
for (const recipientId of recipientIds) {
const syncKey = `${conversationId}:${recipientId}`;
if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) {
continue;
}
this.lastSyncRequestAt.set(syncKey, now);
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-sync-request',
directMessageSyncRequest: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
requestedAt: Date.now()
}
});
}
}
private purgeExpiredTypingEntries(): void {
const now = Date.now();
this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now));
}
private async loadForOwner(ownerId: string | null): Promise<void> {
if (!ownerId) {
this.loadedOwnerId = null;
@@ -512,10 +796,14 @@ export class DirectMessageService {
}
this.loadedOwnerId = ownerId;
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
const conversations = await this.repository.loadConversations(ownerId);
conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation));
this.conversationsSignal.set(conversations);
}
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
this.rememberConversationAttachmentStorage(conversation);
await this.repository.saveConversation(ownerId, conversation);
this.conversationsSignal.update((conversations) => {
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
@@ -525,6 +813,55 @@ export class DirectMessageService {
});
}
private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void {
const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`;
for (const message of conversation.messages) {
this.attachments.rememberMessageRoom(message.id, storageContainer);
}
}
private mergeConversationParticipants(
conversation: DirectMessageConversation,
participants: DirectMessageParticipant[]
): DirectMessageConversation {
const participantProfiles = {
...conversation.participantProfiles,
...Object.fromEntries(participants.map((participant) => [participant.userId, participant]))
};
return {
...conversation,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
}
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
if (!conversation || !currentUserId) {
return [];
}
return conversation.participants.filter((participantId) => participantId !== currentUserId);
}
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
return isGroupDirectConversation(conversation) ? 'group' : 'direct';
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
await this.loadForOwner(ownerId);

View File

@@ -22,7 +22,19 @@ export class PeerDeliveryService {
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
filter((event) => event.type === 'direct-message'
|| event.type === 'direct-message-status'
|| event.type === 'direct-message-mutation'
|| event.type === 'direct-message-typing'
|| event.type === 'direct-message-sync-request'
|| event.type === 'direct-message-sync')
);
readonly directCallEvents$: Observable<ChatEvent> = merge(
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-call')
);
readonly peerConnected$ = this.webrtc.onPeerConnected;
@@ -60,6 +72,10 @@ export class PeerDeliveryService {
});
}
sendCallEvent(recipientId: string, event: ChatEvent): boolean {
return this.sendViaWebRTC(recipientId, event);
}
syncOnReconnect(onReconnect: () => void): void {
this.peerConnected$.subscribe(() => onReconnect());
}
@@ -84,7 +100,15 @@ export class PeerDeliveryService {
}
private sendViaSignaling(recipientId: string, event: ChatEvent): boolean {
if (event.type !== 'direct-message' && event.type !== 'direct-message-status' && event.type !== 'direct-message-mutation') {
if (
event.type !== 'direct-message'
&& event.type !== 'direct-message-status'
&& event.type !== 'direct-message-mutation'
&& event.type !== 'direct-message-typing'
&& event.type !== 'direct-message-sync-request'
&& event.type !== 'direct-message-sync'
&& event.type !== 'direct-call'
) {
return false;
}

View File

@@ -37,6 +37,7 @@ export function createDirectConversation(
return {
id: getDirectConversationId(currentUser.userId, peer.userId),
kind: 'direct',
participants,
participantProfiles: {
[currentUser.userId]: currentUser,
@@ -48,6 +49,31 @@ export function createDirectConversation(
};
}
export function createGroupConversation(
conversationId: string,
participants: DirectMessageParticipant[],
now: number,
title?: string
): DirectMessageConversation {
const uniqueParticipants = uniqueDirectMessageParticipants(participants);
const participantIds = uniqueParticipants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
title: title || buildGroupConversationTitle(uniqueParticipants),
participants: participantIds,
participantProfiles: Object.fromEntries(uniqueParticipants.map((participant) => [participant.userId, participant])),
messages: [],
lastMessageAt: now,
unreadCount: 0
};
}
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
export function upsertDirectMessage(
conversation: DirectMessageConversation,
message: DirectMessage,
@@ -89,3 +115,26 @@ export function updateMessageStatusInConversation(
return { ...conversation, messages };
}
function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}

View File

@@ -1,17 +1,24 @@
import type { User } from '../../../../shared-kernel';
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
export type DirectMessageConversationKind = 'direct' | 'group';
export type {
DirectMessage,
DirectMessageEventPayload,
DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus,
DirectMessageStatusEventPayload
DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload
} from '../../../../shared-kernel';
export interface DirectMessageConversation {
id: string;
kind?: DirectMessageConversationKind;
title?: string;
participants: string[];
participantProfiles: Record<string, DirectMessageParticipant>;
messages: DirectMessage[];

View File

@@ -13,10 +13,25 @@
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
@if (showCallButton() && conversation()) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
[disabled]="!canCallConversation()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversation()"
>
<ng-icon
[name]="peerCallIcon()"
class="h-4 w-4"
/>
</button>
}
</header>
@if (conversation()) {
@@ -58,6 +73,15 @@
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
>
@if (typingUsers().length > 0) {
<div
data-testid="dm-typing-indicator"
class="px-4 pb-1 text-xs text-muted-foreground"
>
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
</div>
}
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showGifPicker()"
@@ -65,6 +89,7 @@
[klipySignalSource]="null"
[textareaTestId]="'dm-input'"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="composerBottomPadding.set($event + 20)"
(klipyGifPickerToggleRequested)="toggleGifPicker()"

View File

@@ -5,6 +5,7 @@ import {
effect,
HostListener,
inject,
input,
signal,
ViewChild
} from '@angular/core';
@@ -15,10 +16,13 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import {
ChatMessageComposerSubmitEvent,
ChatMessageComposerComponent,
@@ -57,9 +61,11 @@ interface DmStatusLabel {
ChatMessageListComponent,
ChatMessageOverlaysComponent,
KlipyGifPickerComponent,
NgIcon,
ThemeNodeDirective,
UserAvatarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
templateUrl: './dm-chat.component.html',
host: {
class: 'block h-full'
@@ -74,10 +80,15 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
@@ -87,15 +98,26 @@ export class DmChatComponent {
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => {
void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id);
});
readonly peerUser = computed(() => {
const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null;
});
readonly isGroupConversation = computed(() => {
const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
});
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
@@ -173,22 +195,57 @@ export class DmChatComponent {
readonly peerName = computed(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
if (conversation && this.isGroupConversation()) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
});
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
if (conversation && this.isGroupConversation()) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser();
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
});
readonly canCallConversation = computed(() => {
const conversation = this.conversation();
if (!conversation) {
return false;
}
if (this.isGroupConversation()) {
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
}
return !!this.peerUser();
});
constructor() {
effect(() => {
const conversationId = this.routeConversationId();
const conversationId = this.effectiveConversationId();
if (conversationId) {
if (!conversationId) {
this.openedConversationId = null;
return;
}
if (conversationId !== this.openedConversationId) {
this.openedConversationId = conversationId;
void this.directMessages.openConversation(conversationId);
}
});
effect(() => {
void this.routeConversationId();
void this.effectiveConversationId();
void this.klipy.refreshAvailability(null);
});
@@ -226,11 +283,28 @@ export class DmChatComponent {
this.replyTo.set(null);
if (event.pendingFiles.length > 0) {
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
}
});
}
handleTypingStarted(): void {
const conversationId = this.conversation()?.id;
if (conversationId) {
this.directMessages.sendTyping(conversationId, true);
}
}
async callConversation(): Promise<void> {
const conversation = this.conversation();
if (conversation && this.canCallConversation()) {
await this.directCalls.startConversationCall(conversation);
}
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
@@ -325,6 +399,20 @@ export class DmChatComponent {
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
@@ -391,12 +479,16 @@ export class DmChatComponent {
continue;
}
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
const urls = this.linkMetadata.extractUrls(message.content)
.filter((url) => !hasDedicatedChatEmbed(url))
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
if (urls.length === 0) {
continue;
}
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
if (metadata.length === 0) {
@@ -410,11 +502,19 @@ export class DmChatComponent {
}
}
private metadataRequestKey(messageId: string, url: string): string {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
@@ -424,6 +524,10 @@ export class DmChatComponent {
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -445,6 +549,10 @@ export class DmChatComponent {
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;
}
const currentUserId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
@@ -454,4 +562,16 @@ export class DmChatComponent {
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
}
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -29,9 +29,11 @@
[class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting"
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[attr.data-testid]="'dm-rail-item-' + item.id"
[title]="item.label"
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
(click)="openItem(item)"
(contextmenu)="openContextMenu($event, item)"
>
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (item.avatarUrl) {
@@ -58,7 +60,7 @@
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
>
<ng-icon
name="lucideUser"
[name]="iconFor(item)"
class="h-2.5 w-2.5"
/>
</span>
@@ -72,3 +74,24 @@
</div>
}
</div>
@if (contextMenu(); as menu) {
<app-context-menu
[x]="menu.x"
[y]="menu.y"
width="w-44"
(closed)="closeContextMenu()"
>
<button
type="button"
class="context-menu-item-icon-danger"
(click)="forgetContextItem()"
>
<ng-icon
[name]="forgetContextIcon(menu.item)"
class="h-4 w-4"
/>
{{ forgetContextLabel(menu.item) }}
</button>
</app-context-menu>
}

View File

@@ -12,8 +12,15 @@ import { CommonModule } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide';
import {
lucideLogOut,
lucideMessageCircle,
lucideTrash2,
lucideUser,
lucideUsers
} from '@ng-icons/lucide';
import { filter, map } from 'rxjs';
import { ContextMenuComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -30,13 +37,23 @@ interface DmRailItem {
unreadCount: number;
}
interface DmRailContextMenuState {
x: number;
y: number;
item: DmRailItem;
}
const EXIT_ANIMATION_MS = 160;
@Component({
selector: 'app-dm-rail',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
imports: [
CommonModule,
ContextMenuComponent,
NgIcon
],
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
templateUrl: './dm-rail.component.html',
styleUrl: './dm-rail.component.scss'
})
@@ -60,11 +77,29 @@ export class DmRailComponent implements OnDestroy {
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
));
readonly railItems = signal<DmRailItem[]>([]);
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>();
for (const conversation of this.directMessages.conversations()) {
if (conversation.unreadCount === 0) {
continue;
}
if (this.isGroupConversation(conversation)) {
items.set(conversation.id, {
id: conversation.id,
label: this.titleFor(conversation),
conversation,
isExiting: false,
user: null,
unreadCount: conversation.unreadCount
});
continue;
}
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) {
@@ -103,7 +138,7 @@ export class DmRailComponent implements OnDestroy {
});
}
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
});
readonly isOnDirectMessages = toSignal(
this.router.events.pipe(
@@ -140,6 +175,8 @@ export class DmRailComponent implements OnDestroy {
}
async openItem(item: DmRailItem): Promise<void> {
this.closeContextMenu();
if (item.conversation) {
await this.openConversation(item.conversation);
return;
@@ -155,6 +192,10 @@ export class DmRailComponent implements OnDestroy {
}
titleFor(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
@@ -184,6 +225,51 @@ export class DmRailComponent implements OnDestroy {
return !!item.conversation && this.isSelectedConversation(item.conversation);
}
iconFor(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideUsers' : 'lucideUser';
}
openContextMenu(event: MouseEvent, item: DmRailItem): void {
if (!item.conversation) {
return;
}
event.preventDefault();
event.stopPropagation();
this.contextMenu.set({
x: event.clientX,
y: event.clientY,
item
});
}
closeContextMenu(): void {
this.contextMenu.set(null);
}
async forgetContextItem(): Promise<void> {
const item = this.contextMenu()?.item;
if (!item?.conversation) {
return;
}
await this.directMessages.forgetConversation(item.conversation.id);
this.closeContextMenu();
if (this.isSelectedConversation(item.conversation)) {
await this.router.navigate(['/dm']);
}
}
forgetContextLabel(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
}
forgetContextIcon(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideLogOut' : 'lucideTrash2';
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
@@ -227,4 +313,20 @@ export class DmRailComponent implements OnDestroy {
this.railItems.set(nextItems);
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}

View File

@@ -0,0 +1,7 @@
<main
appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()"
>
<app-dm-chat />
</main>

View File

@@ -0,0 +1,21 @@
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { DmChatComponent } from '../dm-chat/dm-chat.component';
@Component({
selector: 'app-dm-chat-panel',
standalone: true,
imports: [
CommonModule,
ThemeNodeDirective,
DmChatComponent
],
host: { class: 'contents' },
templateUrl: './dm-chat-panel.component.html'
})
export class DmChatPanelComponent {
private readonly theme = inject(ThemeService);
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
}

View File

@@ -0,0 +1,54 @@
<div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelected()"
[class.text-foreground]="isSelected()"
[attr.aria-current]="isSelected() ? 'page' : null"
(click)="openConversation()"
(keydown.enter)="openConversation()"
(keydown.space)="openConversation()"
role="button"
tabindex="0"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerAvatarUrl()"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-medium text-foreground">{{ peerName() }}</p>
@if (conversation().unreadCount > 0) {
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
{{ formatUnreadCount(conversation().unreadCount) }}
</span>
}
</div>
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview() }}</p>
</div>
<button
type="button"
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
[disabled]="!canCall()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversationPeer($event)"
>
<ng-icon
[name]="callIcon()"
class="h-3.5 w-3.5"
/>
</button>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName()"
[title]="'Forget ' + peerName()"
(click)="forgetConversation($event)"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
</button>
</div>

View File

@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePhone,
lucidePhoneCall,
lucideTrash2
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
import { DirectCallService } from '../../../direct-call';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { Attachment } from '../../../attachment';
import type { User } from '../../../../shared-kernel';
import { DirectMessageService } from '../../application/services/direct-message.service';
@Component({
selector: 'app-dm-conversation-item',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
host: { class: 'block' },
templateUrl: './dm-conversation-item.component.html'
})
export class DmConversationItemComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
readonly conversation = input.required<DirectMessageConversation>();
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
constructor() {
effect(() => {
const conversation = this.conversation();
const peer = this.peerUser(conversation, this.users());
if (!peer?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
});
}
openConversation(): void {
void this.router.navigate(['/dm', this.conversation().id]);
}
async forgetConversation(event: Event): Promise<void> {
event.stopPropagation();
const conversation = this.conversation();
const conversations = this.directMessages.conversations();
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
await this.directMessages.forgetConversation(conversation.id);
if (this.routeConversationId() === conversation.id) {
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
}
}
async callConversationPeer(event: Event): Promise<void> {
event.stopPropagation();
await this.directCalls.startConversationCall(this.conversation());
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private resolvePeerName(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
}
private resolveLastMessagePreview(conversation: DirectMessageConversation): string {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
}
if (lastMessage.isDeleted) {
return 'Message deleted';
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
}
this.attachments.updated();
const attachments = this.attachments.getForMessage(lastMessage.id);
if (attachments.length > 0) {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
}
private conversationCallIcon(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser(conversation);
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
}
private canCallConversation(conversation: DirectMessageConversation): boolean {
if (this.isGroupConversation(conversation)) {
return conversation.participants.some((participantId) => participantId !== this.directMessages.currentUserId());
}
return !!this.peerUser(conversation);
}
private peerId(conversation: DirectMessageConversation): string | undefined {
const currentUserId = this.directMessages.currentUserId();
return conversation.participants.find((participantId) => participantId !== currentUserId);
}
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const currentUserId = this.directMessages.currentUserId();
const names = conversation.participants
.filter((participantId) => participantId !== currentUserId)
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private isKlipyGif(content: string): boolean {
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
}
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
}
}

View File

@@ -0,0 +1,46 @@
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
<header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
</div>
</header>
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
<app-dm-conversation-item
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
[conversation]="conversation"
></app-dm-conversation-item>
</div>
}
</div>
<div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls />
</div>
</section>
</aside>

View File

@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle } from '@ng-icons/lucide';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { VoiceControlsComponent } from '../../../voice-session';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmConversationItemComponent } from './dm-conversation-item.component';
@Component({
selector: 'app-dm-conversations-panel',
standalone: true,
imports: [
CommonModule,
DmConversationItemComponent,
NgIcon,
ThemeNodeDirective,
VoiceControlsComponent
],
viewProviders: [provideIcons({ lucideMessageCircle })],
host: { class: 'contents' },
templateUrl: './dm-conversations-panel.component.html'
})
export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id;
}
}

View File

@@ -2,97 +2,6 @@
class="grid h-full min-h-0 overflow-hidden bg-background"
[ngStyle]="layoutStyles()"
>
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
<header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
</div>
</header>
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
@for (conversation of directMessages.conversations(); track conversation.id) {
<div
appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelectedConversation(conversation)"
[class.text-foreground]="isSelectedConversation(conversation)"
[attr.aria-current]="isSelectedConversation(conversation) ? 'page' : null"
(click)="openConversation(conversation)"
(keydown.enter)="openConversation(conversation)"
(keydown.space)="openConversation(conversation)"
role="button"
tabindex="0"
>
<app-user-avatar
[name]="peerName(conversation)"
[avatarUrl]="peerAvatarUrl(conversation)"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-medium text-foreground">{{ peerName(conversation) }}</p>
@if (conversation.unreadCount > 0) {
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
{{ formatUnreadCount(conversation.unreadCount) }}
</span>
}
</div>
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview(conversation) }}</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName(conversation)"
[title]="'Forget ' + peerName(conversation)"
(click)="forgetConversation($event, conversation)"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
</button>
</div>
}
</div>
}
</div>
<div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls />
</div>
</section>
</aside>
<main
appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()"
>
<app-dm-chat />
</main>
<app-dm-conversations-panel />
<app-dm-chat-panel />
</div>

View File

@@ -9,50 +9,31 @@ import {
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideTrash2 } from '@ng-icons/lucide';
import { map } from 'rxjs';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
import { VoiceControlsComponent } from '../../../voice-session';
import { ThemeService } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatComponent } from '../dm-chat/dm-chat.component';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { Attachment } from '../../../attachment';
import type { User } from '../../../../shared-kernel';
import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
@Component({
selector: 'app-dm-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective,
DmChatComponent,
VoiceControlsComponent
DmChatPanelComponent,
DmConversationsPanelComponent
],
viewProviders: [provideIcons({ lucideMessageCircle, lucideTrash2 })],
templateUrl: './dm-workspace.component.html'
})
export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly theme = inject(ThemeService);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
readonly directMessages = inject(DirectMessageService);
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
constructor() {
effect(() => {
@@ -69,116 +50,9 @@ export class DmWorkspaceComponent implements OnDestroy {
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
}
});
effect(() => {
const users = this.users();
for (const conversation of this.directMessages.conversations()) {
const peer = this.peerUser(conversation, users);
if (!peer?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
}
});
}
openConversation(conversation: DirectMessageConversation): void {
void this.router.navigate(['/dm', conversation.id]);
}
ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId());
}
isSelectedConversation(conversation: DirectMessageConversation): boolean {
return this.routeConversationId() === conversation.id;
}
peerName(conversation: DirectMessageConversation): string {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}
peerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
}
lastMessagePreview(conversation: DirectMessageConversation): string {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
}
if (lastMessage.isDeleted) {
return 'Message deleted';
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
}
this.attachments.updated();
const attachments = this.attachments.getForMessage(lastMessage.id);
if (attachments.length > 0) {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
}
async forgetConversation(event: Event, conversation: DirectMessageConversation): Promise<void> {
event.stopPropagation();
const conversations = this.directMessages.conversations();
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
await this.directMessages.forgetConversation(conversation.id);
if (this.routeConversationId() === conversation.id) {
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
}
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private peerId(conversation: DirectMessageConversation): string | undefined {
const currentUserId = this.directMessages.currentUserId();
return conversation.participants.find((participantId) => participantId !== currentUserId);
}
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
const peerId = this.peerId(conversation);
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
}
private isKlipyGif(content: string): boolean {
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
}
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
}
}

View File

@@ -34,6 +34,19 @@
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'call-friend-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
(click)="callUser(user)"
>
<ng-icon
[name]="callIcon(user)"
class="h-4 w-4"
/>
</button>
<button
type="button"
[attr.data-testid]="'message-friend-' + userKey(user)"
@@ -98,6 +111,19 @@
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
>
<app-friend-button [user]="user" />
<button
type="button"
[attr.data-testid]="'call-user-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
(click)="callUser(user)"
>
<ng-icon
[name]="callIcon(user)"
class="h-4 w-4"
/>
</button>
<button
type="button"
[attr.data-testid]="'message-user-' + userKey(user)"

View File

@@ -9,11 +9,17 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideSearch } from '@ng-icons/lucide';
import {
lucideMessageCircle,
lucidePhone,
lucidePhoneCall,
lucideSearch
} from '@ng-icons/lucide';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DirectCallService } from '../../../direct-call';
import { FriendService } from '../../application/services/friend.service';
import { FriendButtonComponent } from '../friend-button/friend-button.component';
import type { User } from '../../../../shared-kernel';
@@ -27,13 +33,14 @@ import type { User } from '../../../../shared-kernel';
UserAvatarComponent,
FriendButtonComponent
],
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
viewProviders: [provideIcons({ lucideMessageCircle, lucidePhone, lucidePhoneCall, lucideSearch })],
templateUrl: './user-search-list.component.html'
})
export class UserSearchListComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
readonly directCalls = inject(DirectCallService);
readonly friends = inject(FriendService);
readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers);
@@ -93,6 +100,14 @@ export class UserSearchListComponent {
await this.router.navigate(['/dm', conversation.id]);
}
async callUser(user: User): Promise<void> {
await this.directCalls.startCall(user);
}
callIcon(user: User): string {
return this.directCalls.isCallingUser(user) ? 'lucidePhoneCall' : 'lucidePhone';
}
userKey(user: User): string {
return user.oderId || user.id;
}