fix: solve small pm chat ui issues
unwrap the pill fix the fetching images in pm not auto download
This commit is contained in:
@@ -23,6 +23,8 @@ direct-message/
|
||||
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.
|
||||
|
||||
Unread counts are idempotent by message id: re-receiving or syncing a message that already exists can update status/content metadata but must not increment the conversation unread count again.
|
||||
|
||||
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.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
@@ -37,6 +39,8 @@ When a private call grows beyond two participants, the direct-call domain create
|
||||
|
||||
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.
|
||||
|
||||
Starting or receiving a PM/group call records a local `system` direct-message event with `systemEvent: "call-started"`. These entries are stored with deterministic ids based on the conversation and call timestamp, do not increment unread counts, and are rendered by the shared chat list as compact timeline rows instead of editable/reactable text messages.
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createGroupConversation,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
createDirectCallStartedMessage,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
@@ -88,6 +89,36 @@ describe('DirectMessageService domain flow', () => {
|
||||
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
|
||||
});
|
||||
|
||||
it('does not increment unread when an existing message is upserted again', () => {
|
||||
const conversation = createDirectConversation(alice, bob, 10);
|
||||
const message = createMessage('message-1', 'SENT');
|
||||
const withUnreadMessage = upsertDirectMessage(conversation, message, true);
|
||||
const withDuplicateMessage = upsertDirectMessage(withUnreadMessage, { ...message, status: 'DELIVERED' }, true);
|
||||
|
||||
expect(withDuplicateMessage.messages).toHaveLength(1);
|
||||
expect(withDuplicateMessage.unreadCount).toBe(1);
|
||||
expect(withDuplicateMessage.messages[0].status).toBe('DELIVERED');
|
||||
});
|
||||
|
||||
it('does not increment unread for call-started system messages', () => {
|
||||
const conversation = createDirectConversation(alice, bob, 0);
|
||||
const message = createDirectCallStartedMessage(conversation.id, alice, ['bob'], 123);
|
||||
const withSystemMessage = upsertDirectMessage(conversation, message, true);
|
||||
|
||||
expect(withSystemMessage.unreadCount).toBe(0);
|
||||
expect(withSystemMessage.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates call-started system messages that do not read like normal text', () => {
|
||||
const message = createDirectCallStartedMessage(getDirectConversationId('alice', 'bob'), alice, ['bob'], 123);
|
||||
|
||||
expect(message.id).toBe(`dm-call-started-${getDirectConversationId('alice', 'bob')}-123`);
|
||||
expect(message.kind).toBe('system');
|
||||
expect(message.systemEvent).toBe('call-started');
|
||||
expect(message.content).toBe('Alice started a call');
|
||||
expect(message.status).toBe('DELIVERED');
|
||||
});
|
||||
|
||||
it('should update status correctly', () => {
|
||||
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
|
||||
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
advanceDirectMessageStatus,
|
||||
createDirectConversation,
|
||||
createGroupConversation,
|
||||
createDirectCallStartedMessage,
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
@@ -244,6 +245,37 @@ export class DirectMessageService {
|
||||
return message;
|
||||
}
|
||||
|
||||
async recordCallStarted(
|
||||
conversationId: string,
|
||||
caller: DirectMessageParticipant,
|
||||
participants: DirectMessageParticipant[],
|
||||
timestamp = Date.now()
|
||||
): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const allParticipants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
caller,
|
||||
...participants
|
||||
]);
|
||||
|
||||
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 conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
|
||||
const message = createDirectCallStartedMessage(
|
||||
conversation.id,
|
||||
caller,
|
||||
conversation.participants.filter((participantId) => participantId !== caller.userId),
|
||||
timestamp
|
||||
);
|
||||
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
|
||||
}
|
||||
|
||||
async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
@@ -863,6 +895,25 @@ export class DirectMessageService {
|
||||
};
|
||||
}
|
||||
|
||||
private createConversationForSystemEvent(
|
||||
conversationId: string,
|
||||
currentParticipant: DirectMessageParticipant,
|
||||
caller: DirectMessageParticipant,
|
||||
participants: DirectMessageParticipant[],
|
||||
timestamp: number
|
||||
): DirectMessageConversation {
|
||||
if (participants.length > 2) {
|
||||
return createGroupConversation(conversationId, participants, timestamp);
|
||||
}
|
||||
|
||||
const peer = participants.find((participant) => participant.userId !== currentParticipant.userId) ?? caller;
|
||||
|
||||
return {
|
||||
...createDirectConversation(currentParticipant, peer, timestamp),
|
||||
id: conversationId
|
||||
};
|
||||
}
|
||||
|
||||
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
|
||||
if (!conversation || !currentUserId) {
|
||||
return [];
|
||||
|
||||
@@ -72,6 +72,28 @@ export function createGroupConversation(
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectCallStartedMessage(
|
||||
conversationId: string,
|
||||
caller: DirectMessageParticipant,
|
||||
recipientIds: string[],
|
||||
timestamp: number
|
||||
): DirectMessage {
|
||||
return {
|
||||
id: `dm-call-started-${conversationId}-${timestamp}`,
|
||||
conversationId,
|
||||
senderId: caller.userId,
|
||||
recipientId: recipientIds[0] ?? caller.userId,
|
||||
recipientIds,
|
||||
content: `${caller.displayName || caller.username || caller.userId} started a call`,
|
||||
timestamp,
|
||||
status: 'DELIVERED',
|
||||
kind: 'system',
|
||||
systemEvent: 'call-started',
|
||||
reactions: [],
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
@@ -104,6 +126,7 @@ export function upsertDirectMessage(
|
||||
): DirectMessageConversation {
|
||||
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
|
||||
const messages = [...conversation.messages];
|
||||
const isNewMessage = existingIndex < 0;
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = messages[existingIndex];
|
||||
@@ -119,11 +142,13 @@ export function upsertDirectMessage(
|
||||
|
||||
messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp);
|
||||
|
||||
const shouldIncrementUnread = incrementUnread && isNewMessage && message.kind !== 'system';
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
messages,
|
||||
lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp),
|
||||
unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
|
||||
unreadCount: shouldIncrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,8 @@ export class DmChatComponent {
|
||||
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
kind: message.kind,
|
||||
systemEvent: message.systemEvent,
|
||||
editedAt: message.editedAt,
|
||||
reactions: message.reactions ?? [],
|
||||
isDeleted: !!message.isDeleted,
|
||||
|
||||
Reference in New Issue
Block a user