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:
+8
-1
@@ -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;
|
||||
|
||||
+111
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
+46
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user