fix: Bug - Sending files and attachment issues

Normalize attachment MIME types from filenames, hydrate playable media and
gallery tiles from disk without redundant peer requests, reset stalled partial
downloads, and improve gallery retry/hydration UX across chat and DMs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 13:30:13 +02:00
co-authored by Cursor
parent b13f71d2d3
commit 0078c320a5
14 changed files with 388 additions and 15 deletions
@@ -17,6 +17,7 @@ import {
shouldAutoRequestWhenWatched
} from '../../domain/logic/attachment.logic';
import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules';
import { shouldResetStalledAttachmentDownload } from '../../domain/logic/attachment-autodownload.rules';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import type {
FileAnnouncePayload,
@@ -379,8 +380,14 @@ export class AttachmentManagerService {
if (attachment.available)
continue;
if ((attachment.receivedBytes ?? 0) > 0)
if (shouldResetStalledAttachmentDownload(
attachment,
this.transfer.hasPendingRequest(messageId, attachment.id)
)) {
this.transfer.cancelRequest(messageId, attachment);
} else if ((attachment.receivedBytes ?? 0) > 0) {
continue;
}
if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue;
@@ -866,4 +866,115 @@ describe('AttachmentTransferService', () => {
expect(attachment.available).toBe(false);
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
});
it('normalizes generic octet-stream announces into image metadata for gallery grouping', () => {
const service = createService();
expect(service.handleFileAnnounce({
messageId: MESSAGE_ID,
fromPeerId: PEER_ID,
file: {
id: FILE_ID,
filename: 'grid.png',
size: 512,
mime: 'application/octet-stream',
isImage: false,
uploaderPeerId: PEER_ID
}
})).toBe(true);
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
expect(attachment.mime).toBe('image/png');
expect(attachment.isImage).toBe(true);
});
it('hydrates playable media from disk without re-requesting when display url is missing', async () => {
persistence.tryRestoreAttachmentFromLocal.mockImplementation(async (attachment) => {
attachment.objectUrl = 'file:///appdata/song.mp3';
attachment.available = true;
return true;
});
const service = createService();
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'song.mp3',
size: 1024,
mime: 'audio/mpeg',
isImage: false,
uploaderPeerId: PEER_ID,
available: true,
savedPath: '/appdata/song.mp3',
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(persistence.tryRestoreAttachmentFromLocal).toHaveBeenCalled();
expect(webrtc.sendToPeer).not.toHaveBeenCalled();
expect(attachment.objectUrl).toBe('file:///appdata/song.mp3');
});
it('hydrates small in-memory audio downloads with a native file url when available', async () => {
attachmentStorage.getFileUrl.mockResolvedValue('file:///appdata/song.mp3');
persistence.saveFileToDisk.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/song.mp3';
return attachment.savedPath;
});
const service = createService();
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'song.mp3',
size: 1024,
mime: 'audio/mpeg',
isImage: false,
uploaderPeerId: PEER_ID,
available: false,
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.objectUrl).toBe('file:///appdata/song.mp3'));
expect(attachment.available).toBe(true);
expect(attachment.savedPath).toBe('/appdata/song.mp3');
});
it('does not cancel hydrating gallery retries before attempting local restore', async () => {
persistence.tryRestoreAttachmentFromLocal.mockResolvedValue(true);
const service = createService();
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'grid.png',
size: 512,
mime: 'image/png',
isImage: true,
uploaderPeerId: PEER_ID,
available: false,
savedPath: '/appdata/grid.png',
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
await service.requestImageFromAnyPeer(MESSAGE_ID, attachment);
expect(persistence.tryRestoreAttachmentFromLocal).toHaveBeenCalled();
expect(webrtc.sendToPeer).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'file-cancel' }));
});
});
@@ -13,10 +13,13 @@ import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/a
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
import {
canReceiveAttachment,
needsAttachmentDisplayHydration,
shouldCopyLargeUploaderFileToAppData,
shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk
} from '../../domain/logic/attachment.logic';
import { normalizeAttachmentMeta } from '../../domain/logic/attachment-normalize.rules';
import { resolveAttachmentMime } from '../../domain/logic/attachment-mime.rules';
import { shouldServeAttachmentFromDiskPath } from '../../domain/logic/attachment-serve.rules';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
@@ -141,9 +144,11 @@ export class AttachmentTransferService {
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) {
const attachment: Attachment = { ...meta,
const attachment: Attachment = {
...normalizeAttachmentMeta(meta),
available: false,
receivedBytes: 0 };
receivedBytes: 0
};
existing.push(attachment);
newAttachments.push(attachment);
@@ -171,6 +176,16 @@ export class AttachmentTransferService {
// request makes the sender stream the file twice and corrupts byte accounting.
this.runtimeStore.setPendingRequestPeers(requestKey, new Set<string>());
if (needsAttachmentDisplayHydration(attachment)) {
const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (hydratedLocally) {
this.runtimeStore.deletePendingRequest(requestKey);
this.runtimeStore.touch();
return;
}
}
if (!attachment.available) {
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
@@ -230,6 +245,10 @@ export class AttachmentTransferService {
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
if (needsAttachmentDisplayHydration(attachment)) {
return this.requestFromAnyPeer(messageId, attachment);
}
if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) {
this.cancelRequest(messageId, attachment);
}
@@ -259,7 +278,7 @@ export class AttachmentTransferService {
messageId,
filename: file.name,
size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
mime: resolveAttachmentMime(file.name, file.type || DEFAULT_ATTACHMENT_MIME_TYPE),
isImage: resolvePublishAttachmentIsImage(file),
uploaderPeerId,
filePath: (file as LocalFileWithPath).path,
@@ -327,29 +346,40 @@ export class AttachmentTransferService {
const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown) {
alreadyKnown.filename = file.filename;
alreadyKnown.size = file.size;
alreadyKnown.mime = resolveAttachmentMime(file.filename, file.mime);
alreadyKnown.isImage = isImageAttachment({
filename: file.filename,
isImage: !!file.isImage,
mime: alreadyKnown.mime
});
alreadyKnown.uploaderPeerId = file.uploaderPeerId ?? alreadyKnown.uploaderPeerId;
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(alreadyKnown);
return false;
}
const attachment: Attachment = {
const normalizedMeta = normalizeAttachmentMeta({
id: file.id,
messageId,
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: isImageAttachment({
filename: file.filename,
isImage: !!file.isImage,
mime: file.mime
}),
uploaderPeerId: file.uploaderPeerId,
isImage: !!file.isImage,
uploaderPeerId: file.uploaderPeerId
});
const runtimeAttachment: Attachment = {
...normalizedMeta,
available: false,
receivedBytes: 0
};
list.push(attachment);
list.push(runtimeAttachment);
this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
void this.persistence.persistAttachmentMeta(runtimeAttachment);
return true;
}
@@ -793,6 +823,10 @@ export class AttachmentTransferService {
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
if (this.isPlayableMedia(attachment)) {
await this.hydratePlayableMediaAfterDiskReceive(attachment);
}
}
/**