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:
@@ -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.
|
||||
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user