fix: Bug - Sending files and attachment issues

Hydrate playable media after disk receive, relay file-announce to sibling
devices via account_sync, bind DM attachments to pre-allocated message ids,
and improve gallery retry/cancel UX with bounded parallel auto-downloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 13:05:23 +02:00
co-authored by Cursor
parent bb0ac930ad
commit fa45052432
30 changed files with 785 additions and 71 deletions
@@ -21,11 +21,12 @@ direct-message/
## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `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.
2. `DmChatComponent.handleMessageSubmitted` pre-allocates the outgoing message id via `planDmMessageSend` (`domain/rules/dm-message-send.rules.ts`), passes it to `sendMessage(..., id)`, and binds pending files to that **same** id with `AttachmentFacade.publishAttachments`. Never attach after `sendMessage` resolves by re-discovering the message — caption-less media races the async create path the same way server rooms used to.
3. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
4. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
5. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
6. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
7. 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.
@@ -213,7 +213,12 @@ export class DirectMessageService {
}
}
async sendMessage(conversationId: string, content: string, replyToId?: string): Promise<DirectMessage> {
async sendMessage(
conversationId: string,
content: string,
replyToId?: string,
id?: string
): Promise<DirectMessage> {
const normalizedContent = content.trim();
if (!normalizedContent) {
@@ -232,7 +237,7 @@ export class DirectMessageService {
}
const message: DirectMessage = {
id: uuidv4(),
id: id ?? uuidv4(),
conversationId,
senderId,
recipientId,
@@ -0,0 +1,30 @@
import { planDmMessageSend } from './dm-message-send.rules';
function makeFile(name: string): File {
return new File(['x'], name, { type: 'image/png' });
}
describe('planDmMessageSend', () => {
it('binds attachments to the same pre-allocated message id', () => {
const generateId = () => 'dm-msg-1';
const plan = planDmMessageSend({
generateId,
content: 'hello',
pendingFiles: [makeFile('a.png'), makeFile('b.png')]
});
expect(plan.action.id).toBe('dm-msg-1');
expect(plan.attachmentBinding?.messageId).toBe('dm-msg-1');
expect(plan.attachmentBinding?.files).toHaveLength(2);
});
it('returns no attachment binding when there are no pending files', () => {
const plan = planDmMessageSend({
generateId: () => 'dm-msg-2',
content: 'text only',
pendingFiles: []
});
expect(plan.attachmentBinding).toBeNull();
});
});
@@ -0,0 +1,44 @@
/**
* Pure planning for an outgoing direct message and its attachments.
*
* Mirrors `planChatMessageSend`: the message id must be allocated before
* dispatch so pending files bind to the same bubble instead of racing the
* async create path and landing on a sibling message.
*/
export interface DmMessageSendAction {
id: string;
content: string;
replyToId?: string;
}
export interface DmMessageAttachmentBinding {
messageId: string;
files: File[];
}
export interface DmMessageSendPlan {
action: DmMessageSendAction;
attachmentBinding: DmMessageAttachmentBinding | null;
}
export interface DmMessageSendInput {
generateId: () => string;
content: string;
pendingFiles: File[];
replyToId?: string;
}
export function planDmMessageSend(input: DmMessageSendInput): DmMessageSendPlan {
const id = input.generateId();
return {
action: {
id,
content: input.content,
replyToId: input.replyToId
},
attachmentBinding: input.pendingFiles.length > 0
? { messageId: id, files: input.pendingFiles }
: null
};
}
@@ -171,6 +171,8 @@
(copyRequested)="copyImageToClipboard($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
(imageRetryRequested)="retryGalleryImage($event)"
(imageCancelRequested)="cancelGalleryImage($event)"
/>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">{{ 'dm.chat.selectPrompt' | translate }}</div>
@@ -14,6 +14,7 @@ import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ViewportService } from '../../../../core/platform';
import {
@@ -29,6 +30,7 @@ import {
} from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { planDmMessageSend } from '../../domain/rules/dm-message-send.rules';
import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
@@ -52,7 +54,11 @@ import {
type ChatMessageEmbedRemoveEvent
} from '../../../chat';
import { stepLightboxIndex } from '../../../chat/domain/rules/chat-message-lightbox.rules';
import { ChatLightboxState, ChatMessageImageLightboxEvent } from '../../../chat/feature/chat-messages/models/chat-messages.model';
import {
ChatLightboxState,
ChatMessageAttachmentEvent,
ChatMessageImageLightboxEvent
} from '../../../chat/feature/chat-messages/models/chat-messages.model';
import type {
DirectMessageStatus,
LinkMetadata,
@@ -306,13 +312,32 @@ export class DmChatComponent {
}
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
const plan = planDmMessageSend({
generateId: uuidv4,
content,
pendingFiles: event.pendingFiles,
replyToId: this.replyTo()?.id
});
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
void this.directMessages.sendMessage(
conversation.id,
plan.action.content,
plan.action.replyToId,
plan.action.id
).then(() => {
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);
if (plan.attachmentBinding) {
this.attachments.rememberMessageRoom(
plan.attachmentBinding.messageId,
`direct-message:${conversation.id}`
);
void this.attachments.publishAttachments(
plan.attachmentBinding.messageId,
plan.attachmentBinding.files,
this.currentUserId() || undefined
);
}
});
}
@@ -466,13 +491,11 @@ export class DmChatComponent {
}
openImageGallery(attachments: Attachment[]): void {
const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
if (availableImages.length < 2) {
if (attachments.length < 2) {
return;
}
this.galleryAttachments.set(availableImages);
this.galleryAttachments.set(attachments);
}
closeImageGallery(): void {
@@ -491,6 +514,20 @@ export class DmChatComponent {
await this.attachmentDownload.downloadToUserLocation(attachment);
}
retryGalleryImage(event: ChatMessageAttachmentEvent): void {
const { messageId, attachment } = event;
if ((attachment.receivedBytes ?? 0) > 0 || this.attachments.hasPendingRequest(messageId, attachment.id)) {
this.attachments.cancelRequest(messageId, attachment);
}
void this.attachments.requestImageFromAnyPeer(messageId, attachment);
}
cancelGalleryImage(event: ChatMessageAttachmentEvent): void {
this.attachments.cancelRequest(event.messageId, event.attachment);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
this.closeImageContextMenu();