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:
@@ -85,6 +85,61 @@ test.describe('Multi-device attachment sharing', () => {
|
|||||||
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
|
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('relays file-announce metadata to a sibling device that is already online during upload', async ({
|
||||||
|
createClient
|
||||||
|
}) => {
|
||||||
|
const suffix = uniqueMultiDeviceName('attach-online');
|
||||||
|
const credentials = {
|
||||||
|
username: `online_${suffix}`,
|
||||||
|
displayName: 'Multi Device User',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const serverName = `Attachment Online Relay ${suffix}`;
|
||||||
|
const fileName = `${suffix}-relay.bin`;
|
||||||
|
const caption = `Uploaded while device B was online ${suffix}`;
|
||||||
|
const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `relay-body-${suffix}`);
|
||||||
|
const clientA = await createClient();
|
||||||
|
const clientB = await createClient();
|
||||||
|
const messagesA = new ChatMessagesPage(clientA.page);
|
||||||
|
const messagesB = new ChatMessagesPage(clientB.page);
|
||||||
|
|
||||||
|
await test.step('device A registers and creates a server', async () => {
|
||||||
|
const registerPage = new RegisterPage(clientA.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||||
|
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(clientA.page);
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Sibling online file-announce relay coverage' });
|
||||||
|
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await messagesA.waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device B logs into the same server before the upload starts', async () => {
|
||||||
|
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||||
|
await clientA.page.bringToFront();
|
||||||
|
await messagesA.waitForReady();
|
||||||
|
await clientB.page.bringToFront();
|
||||||
|
await messagesB.waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device A uploads while device B is already in the room', async () => {
|
||||||
|
await clientA.page.bringToFront();
|
||||||
|
await messagesA.attachFiles([fileAttachment]);
|
||||||
|
await messagesA.sendMessage(caption);
|
||||||
|
await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device B learns attachment metadata without a server-rail click dance', async () => {
|
||||||
|
await clientB.page.bringToFront();
|
||||||
|
await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false }))
|
||||||
|
.toBeVisible({ timeout: 90_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
test.describe('Multi-image gallery grouping', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('groups three images in one message bubble with a visible grid', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('gallery');
|
||||||
|
const client = await createClient();
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
const search = new ServerSearchPage(client.page);
|
||||||
|
const messages = new ChatMessagesPage(client.page);
|
||||||
|
const serverName = `Gallery Group ${suffix}`;
|
||||||
|
const imageNames = [
|
||||||
|
`${suffix}-one.svg`,
|
||||||
|
`${suffix}-two.svg`,
|
||||||
|
`${suffix}-three.svg`
|
||||||
|
];
|
||||||
|
const images = imageNames.map((name) => createSvgFilePayload(name));
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(`gallery_${suffix}`, 'Gallery User', 'TestPass123!');
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Multi-image gallery regression server' });
|
||||||
|
await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await messages.waitForReady();
|
||||||
|
|
||||||
|
await messages.attachFiles(images);
|
||||||
|
await messages.sendPendingAttachments();
|
||||||
|
|
||||||
|
for (const imageName of imageNames) {
|
||||||
|
await messages.expectMessageImageLoaded(imageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await messages.getMessageIdContainingImage(imageNames[0]);
|
||||||
|
|
||||||
|
expect(messageId).toBeTruthy();
|
||||||
|
|
||||||
|
const bubble = client.page.locator(`[data-message-id="${messageId}"]`);
|
||||||
|
|
||||||
|
await expect(bubble.locator('img[alt$=".svg"]')).toHaveCount(3, { timeout: 20_000 });
|
||||||
|
await expect(bubble.locator('.chat-image-grid')).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgFilePayload(name: string): ChatDropFilePayload {
|
||||||
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#4A217A"/></svg>';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mimeType: 'image/svg+xml',
|
||||||
|
base64: Buffer.from(svg, 'utf8').toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -107,14 +107,14 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
|
|||||||
|
|
||||||
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
||||||
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
||||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
|
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed **images** stay on `savedPath` until inline display hydration runs on demand; completed **audio/video** immediately resolve a playable URL via `attachmentStorage.getFileUrl(savedPath)` (Electron/Capacitor) or `ensureInlineDisplayObjectUrl` in the browser.
|
||||||
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
|
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
|
||||||
|
|
||||||
### Failure handling
|
### Failure handling
|
||||||
|
|
||||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||||
|
|
||||||
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
|
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download. Outgoing `file-announce` broadcasts are also relayed to sibling devices through `account_sync` (see `infrastructure/realtime/account-sync/account-sync.rules.ts`) so a second client of the same user learns attachment metadata even when it cannot P2P to itself.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -144,7 +144,7 @@ When the user navigates to a room, the manager watches the route and decides whi
|
|||||||
|
|
||||||
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
||||||
|
|
||||||
Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:<conversationId>`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media.
|
Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:<conversationId>`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. Auto-download work fans out with bounded concurrency (`ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY`, default 3 files at a time per watched room) so multiple pending files can progress in parallel without removing the per-file chunk-ack memory safety invariant.
|
||||||
|
|
||||||
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
|
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ export class AttachmentFacade {
|
|||||||
return this.manager.cancelRequest(...args);
|
return this.manager.cancelRequest(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasPendingRequest(
|
||||||
|
...args: Parameters<AttachmentManagerService['hasPendingRequest']>
|
||||||
|
): ReturnType<AttachmentManagerService['hasPendingRequest']> {
|
||||||
|
return this.manager.hasPendingRequest(...args);
|
||||||
|
}
|
||||||
|
|
||||||
handleFileCancel(
|
handleFileCancel(
|
||||||
...args: Parameters<AttachmentManagerService['handleFileCancel']>
|
...args: Parameters<AttachmentManagerService['handleFileCancel']>
|
||||||
): ReturnType<AttachmentManagerService['handleFileCancel']> {
|
): ReturnType<AttachmentManagerService['handleFileCancel']> {
|
||||||
|
|||||||
+22
-10
@@ -16,6 +16,7 @@ import {
|
|||||||
isDirectMessageAttachmentRoomId,
|
isDirectMessageAttachmentRoomId,
|
||||||
shouldAutoRequestWhenWatched
|
shouldAutoRequestWhenWatched
|
||||||
} from '../../domain/logic/attachment.logic';
|
} from '../../domain/logic/attachment.logic';
|
||||||
|
import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import type {
|
import type {
|
||||||
FileAnnouncePayload,
|
FileAnnouncePayload,
|
||||||
@@ -153,6 +154,10 @@ export class AttachmentManagerService {
|
|||||||
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasPendingRequest(messageId: string, attachmentId: string): boolean {
|
||||||
|
return this.transfer.hasPendingRequest(messageId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||||
const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
||||||
|
|
||||||
@@ -316,33 +321,40 @@ export class AttachmentManagerService {
|
|||||||
|
|
||||||
await this.restoreLocalAttachmentsForRoom(roomId);
|
await this.restoreLocalAttachmentsForRoom(roomId);
|
||||||
|
|
||||||
if (isDirectMessageAttachmentRoomId(roomId)) {
|
let messageIds: string[];
|
||||||
await this.requestAutoDownloadsForRuntimeRoom(roomId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.database.isReady()) {
|
if (isDirectMessageAttachmentRoomId(roomId)) {
|
||||||
|
messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId);
|
||||||
|
} else if (this.database.isReady()) {
|
||||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||||
await this.requestAutoDownloadsForMessage(message.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
messageIds = messages.map((message) => message.id);
|
||||||
|
} else {
|
||||||
|
messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.requestAutoDownloadsForRuntimeRoom(roomId);
|
await runTasksWithBoundedConcurrency(
|
||||||
|
messageIds.map((messageId) => () => this.requestAutoDownloadsForMessage(messageId)),
|
||||||
|
ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestAutoDownloadsForRuntimeRoom(roomId: string): Promise<void> {
|
private async collectMessageIdsForAttachmentsInRoom(roomId: string): Promise<string[]> {
|
||||||
|
const messageIds: string[] = [];
|
||||||
|
|
||||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
if (attachmentRoomId === roomId) {
|
if (attachmentRoomId === roomId) {
|
||||||
await this.requestAutoDownloadsForMessage(messageId);
|
messageIds.push(messageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return messageIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||||
|
|||||||
+84
-2
@@ -414,6 +414,7 @@ describe('AttachmentTransferService', () => {
|
|||||||
fileId: FILE_ID,
|
fileId: FILE_ID,
|
||||||
index: 0
|
index: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -480,6 +481,7 @@ describe('AttachmentTransferService', () => {
|
|||||||
fileId: FILE_ID,
|
fileId: FILE_ID,
|
||||||
index: 0
|
index: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||||
expect(attachment.objectUrl).toBeUndefined();
|
expect(attachment.objectUrl).toBeUndefined();
|
||||||
@@ -510,15 +512,16 @@ describe('AttachmentTransferService', () => {
|
|||||||
fileId: FILE_ID,
|
fileId: FILE_ID,
|
||||||
index: 0
|
index: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||||
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
|
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
|
it('does not hydrate image blobs after a disk-streamed download completes', async () => {
|
||||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||||
|
|
||||||
const service = createService();
|
const service = createService();
|
||||||
const attachment = registerIncomingVideo(3);
|
const attachment = registerIncomingAttachment(3);
|
||||||
|
|
||||||
service.handleFileChunk(chunkPayload(0, 1, [
|
service.handleFileChunk(chunkPayload(0, 1, [
|
||||||
1,
|
1,
|
||||||
@@ -530,9 +533,55 @@ describe('AttachmentTransferService', () => {
|
|||||||
|
|
||||||
expect(attachment.savedPath).toBeTruthy();
|
expect(attachment.savedPath).toBeTruthy();
|
||||||
expect(attachment.objectUrl).toBeUndefined();
|
expect(attachment.objectUrl).toBeUndefined();
|
||||||
|
expect(attachmentStorage.getFileUrl).not.toHaveBeenCalled();
|
||||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates playable media with a native file url after disk-streamed download completes', async () => {
|
||||||
|
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||||
|
attachmentStorage.getFileUrl.mockResolvedValue('file:///appdata/server/room/files/clip.mp4');
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingVideo(3);
|
||||||
|
|
||||||
|
service.handleFileChunk(chunkPayload(0, 1, [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(attachment.objectUrl).toBe('file:///appdata/server/room/files/clip.mp4'));
|
||||||
|
|
||||||
|
expect(attachment.available).toBe(true);
|
||||||
|
expect(attachment.savedPath).toBeTruthy();
|
||||||
|
expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith(attachment.savedPath);
|
||||||
|
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to inline blob hydration for playable media when no native file url exists', async () => {
|
||||||
|
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||||
|
attachmentStorage.getFileUrl.mockResolvedValue(null);
|
||||||
|
persistence.ensureInlineDisplayObjectUrl.mockImplementation(async (entry) => {
|
||||||
|
entry.objectUrl = 'blob:http://localhost/clip';
|
||||||
|
entry.available = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingVideo(3);
|
||||||
|
|
||||||
|
service.handleFileChunk(chunkPayload(0, 1, [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(persistence.ensureInlineDisplayObjectUrl).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(attachment.objectUrl).toBe('blob:http://localhost/clip');
|
||||||
|
expect(attachment.available).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects oversized browser downloads before requesting peers', async () => {
|
it('rejects oversized browser downloads before requesting peers', async () => {
|
||||||
attachmentStorage.canStreamToDisk.mockReturnValue(false);
|
attachmentStorage.canStreamToDisk.mockReturnValue(false);
|
||||||
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
|
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
|
||||||
@@ -727,4 +776,37 @@ describe('AttachmentTransferService', () => {
|
|||||||
expect(attachment.available).toBe(true);
|
expect(attachment.available).toBe(true);
|
||||||
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends file-cancel to pending request peers instead of only the uploader', async () => {
|
||||||
|
const mirrorPeer = 'mirror-peer';
|
||||||
|
|
||||||
|
webrtc.getConnectedPeers.mockReturnValue([mirrorPeer]);
|
||||||
|
webrtc.sendToPeer.mockClear();
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingAttachment(3_000);
|
||||||
|
|
||||||
|
attachment.uploaderPeerId = 'uploader-peer';
|
||||||
|
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
|
||||||
|
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, attachment.uploaderPeerId);
|
||||||
|
|
||||||
|
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||||
|
|
||||||
|
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
|
||||||
|
type: 'file-request'
|
||||||
|
}));
|
||||||
|
|
||||||
|
attachment.receivedBytes = 512;
|
||||||
|
service.cancelRequest(MESSAGE_ID, attachment);
|
||||||
|
|
||||||
|
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, {
|
||||||
|
type: 'file-cancel',
|
||||||
|
messageId: MESSAGE_ID,
|
||||||
|
fileId: FILE_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(attachment.receivedBytes).toBe(0);
|
||||||
|
expect(attachment.available).toBe(false);
|
||||||
|
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-6
@@ -229,6 +229,10 @@ export class AttachmentTransferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) {
|
||||||
|
this.cancelRequest(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
return this.requestFromAnyPeer(messageId, attachment);
|
return this.requestFromAnyPeer(messageId, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,22 +460,22 @@ export class AttachmentTransferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||||
const targetPeerId = attachment.uploaderPeerId;
|
|
||||||
|
|
||||||
if (!targetPeerId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||||
|
const pendingPeers = this.runtimeStore.getPendingRequestPeers(requestKey);
|
||||||
|
|
||||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||||
|
this.runtimeStore.deletePendingRequest(requestKey);
|
||||||
void this.deleteDiskReceiveAssembly(assemblyKey);
|
void this.deleteDiskReceiveAssembly(assemblyKey);
|
||||||
|
this.chunkAcks.cancelPendingForFile(messageId, attachment.id);
|
||||||
|
|
||||||
attachment.receivedBytes = 0;
|
attachment.receivedBytes = 0;
|
||||||
attachment.speedBps = 0;
|
attachment.speedBps = 0;
|
||||||
attachment.startedAtMs = undefined;
|
attachment.startedAtMs = undefined;
|
||||||
attachment.lastUpdateMs = undefined;
|
attachment.lastUpdateMs = undefined;
|
||||||
|
attachment.requestError = undefined;
|
||||||
|
|
||||||
if (attachment.objectUrl) {
|
if (attachment.objectUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -489,8 +493,21 @@ export class AttachmentTransferService {
|
|||||||
messageId,
|
messageId,
|
||||||
fileId: attachment.id
|
fileId: attachment.id
|
||||||
};
|
};
|
||||||
|
const peersToNotify = new Set<string>();
|
||||||
|
|
||||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
if (pendingPeers) {
|
||||||
|
for (const peerId of pendingPeers) {
|
||||||
|
peersToNotify.add(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.uploaderPeerId) {
|
||||||
|
peersToNotify.add(attachment.uploaderPeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const peerId of peersToNotify) {
|
||||||
|
this.webrtc.sendToPeer(peerId, fileCancelEvent);
|
||||||
|
}
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,6 +1014,23 @@ export class AttachmentTransferService {
|
|||||||
this.runtimeStore.touch();
|
this.runtimeStore.touch();
|
||||||
void this.persistence.persistAttachmentMeta(attachment);
|
void this.persistence.persistAttachmentMeta(attachment);
|
||||||
void this.announceLocalHost(attachment);
|
void this.announceLocalHost(attachment);
|
||||||
|
void this.hydratePlayableMediaAfterDiskReceive(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydratePlayableMediaAfterDiskReceive(attachment: Attachment): Promise<void> {
|
||||||
|
if (!this.isPlayableMedia(attachment) || !attachment.savedPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeUrl = await this.attachmentStorage.getFileUrl(attachment.savedPath);
|
||||||
|
|
||||||
|
if (nativeUrl) {
|
||||||
|
attachment.objectUrl = nativeUrl;
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistence.ensureInlineDisplayObjectUrl(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateDiskReceiveAssembly(
|
private async getOrCreateDiskReceiveAssembly(
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { runTasksWithBoundedConcurrency } from './attachment-autodownload-concurrency.rules';
|
||||||
|
|
||||||
|
describe('attachment-autodownload-concurrency.rules', () => {
|
||||||
|
it('runs tasks with bounded concurrency', async () => {
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
|
||||||
|
const tasks = Array.from({ length: 6 }, (_, index) => async () => {
|
||||||
|
active += 1;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
active -= 1;
|
||||||
|
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
const results = await runTasksWithBoundedConcurrency(tasks, 2);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(maxActive).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/** Default parallel attachment auto-download limit per watched room. */
|
||||||
|
export const ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
export async function runTasksWithBoundedConcurrency<T>(
|
||||||
|
tasks: readonly (() => Promise<T>)[],
|
||||||
|
concurrency: number
|
||||||
|
): Promise<T[]> {
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.max(1, Math.min(concurrency, tasks.length));
|
||||||
|
const results: T[] = new Array(tasks.length);
|
||||||
|
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
while (nextIndex < tasks.length) {
|
||||||
|
const currentIndex = nextIndex;
|
||||||
|
|
||||||
|
nextIndex += 1;
|
||||||
|
results[currentIndex] = await tasks[currentIndex]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
it
|
it
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import {
|
import { base64DecodedByteLength, decodeBase64ToUint8Array } from './attachment-blob.rules';
|
||||||
base64DecodedByteLength,
|
|
||||||
decodeBase64ToUint8Array
|
|
||||||
} from './attachment-blob.rules';
|
|
||||||
|
|
||||||
describe('attachment blob rules', () => {
|
describe('attachment blob rules', () => {
|
||||||
it('decodes base64 payloads into byte arrays', () => {
|
it('decodes base64 payloads into byte arrays', () => {
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
it
|
it
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import {
|
import { canDownloadAttachment, resolveAttachmentDiskPath } from './attachment-download.rules';
|
||||||
canDownloadAttachment,
|
|
||||||
resolveAttachmentDiskPath
|
|
||||||
} from './attachment-download.rules';
|
|
||||||
|
|
||||||
describe('attachment-download.rules', () => {
|
describe('attachment-download.rules', () => {
|
||||||
it('allows download when a completed disk-only attachment has no object URL', () => {
|
it('allows download when a completed disk-only attachment has no object URL', () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
getWatchedAttachmentRoomIdFromUrl,
|
getWatchedAttachmentRoomIdFromUrl,
|
||||||
|
isAttachmentPendingMediaHydration,
|
||||||
isDirectMessageAttachmentRoomId,
|
isDirectMessageAttachmentRoomId,
|
||||||
|
isPlayableAttachmentMedia,
|
||||||
shouldCopyUploaderMediaToAppData,
|
shouldCopyUploaderMediaToAppData,
|
||||||
shouldCopyLargeUploaderFileToAppData,
|
shouldCopyLargeUploaderFileToAppData,
|
||||||
shouldStreamAttachmentReceiveToDisk,
|
shouldStreamAttachmentReceiveToDisk,
|
||||||
@@ -83,6 +85,24 @@ describe('attachment logic', () => {
|
|||||||
}, capabilities)).toBe(true);
|
}, capabilities)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('identifies playable media pending hydration from disk paths', () => {
|
||||||
|
expect(isPlayableAttachmentMedia({ mime: 'video/mp4' })).toBe(true);
|
||||||
|
expect(isPlayableAttachmentMedia({ mime: 'image/png' })).toBe(false);
|
||||||
|
|
||||||
|
expect(isAttachmentPendingMediaHydration({
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
available: true,
|
||||||
|
savedPath: '/data/song.mp3'
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(isAttachmentPendingMediaHydration({
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'file:///data/song.mp3',
|
||||||
|
savedPath: '/data/song.mp3'
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
|
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
|
||||||
const browserCapabilities = {
|
const browserCapabilities = {
|
||||||
canStreamToDisk: false,
|
canStreamToDisk: false,
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean
|
|||||||
attachment.mime.startsWith('audio/');
|
attachment.mime.startsWith('audio/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlayableAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||||
|
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttachmentPendingMediaHydration(
|
||||||
|
attachment: Pick<
|
||||||
|
Attachment,
|
||||||
|
'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
||||||
|
>
|
||||||
|
): boolean {
|
||||||
|
if (!isPlayableAttachmentMedia(attachment) || attachment.objectUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
||||||
return attachment.isImage ||
|
return attachment.isImage ||
|
||||||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { buildChatMessageGalleryTiles } from './chat-message-image-gallery.rules';
|
||||||
|
|
||||||
|
describe('buildChatMessageGalleryTiles', () => {
|
||||||
|
it('marks displayable, hydrating, downloading, and retry tiles from attachment state', () => {
|
||||||
|
const tiles = buildChatMessageGalleryTiles([
|
||||||
|
{
|
||||||
|
id: 'displayable',
|
||||||
|
filename: 'ready.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'blob:http://localhost/ready'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrating',
|
||||||
|
filename: 'saved.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: false,
|
||||||
|
savedPath: '/appdata/saved.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'partial',
|
||||||
|
filename: 'partial.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: false,
|
||||||
|
receivedBytes: 128,
|
||||||
|
size: 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retry',
|
||||||
|
filename: 'failed.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: false,
|
||||||
|
size: 256
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tiles.map((tile) => tile.state)).toEqual([
|
||||||
|
'displayable',
|
||||||
|
'hydrating',
|
||||||
|
'downloading',
|
||||||
|
'retry'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
isAttachmentPendingInlineHydration,
|
||||||
|
isInlineDisplayableImage,
|
||||||
|
type ImageAttachmentCandidate
|
||||||
|
} from '../../../attachment/domain/logic/attachment-image.rules';
|
||||||
|
|
||||||
|
export type ChatMessageGalleryTileState = 'displayable' | 'hydrating' | 'downloading' | 'retry';
|
||||||
|
|
||||||
|
export interface ChatMessageGalleryTile<T extends ImageAttachmentCandidate = ImageAttachmentCandidate> {
|
||||||
|
attachment: T;
|
||||||
|
state: ChatMessageGalleryTileState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChatMessageGalleryTiles<T extends ImageAttachmentCandidate>(
|
||||||
|
attachments: readonly T[]
|
||||||
|
): ChatMessageGalleryTile<T>[] {
|
||||||
|
return attachments.map((attachment) => ({
|
||||||
|
attachment,
|
||||||
|
state: resolveChatMessageGalleryTileState(attachment)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChatMessageGalleryTileState<T extends ImageAttachmentCandidate>(
|
||||||
|
attachment: T
|
||||||
|
): ChatMessageGalleryTileState {
|
||||||
|
if (isInlineDisplayableImage(attachment)) {
|
||||||
|
return 'displayable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAttachmentPendingInlineHydration(attachment)) {
|
||||||
|
return 'hydrating';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0) {
|
||||||
|
return 'downloading';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'retry';
|
||||||
|
}
|
||||||
@@ -96,5 +96,7 @@
|
|||||||
(copyRequested)="copyImageToClipboard($event)"
|
(copyRequested)="copyImageToClipboard($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
|
(imageRetryRequested)="retryGalleryImage($event)"
|
||||||
|
(imageCancelRequested)="cancelGalleryImage($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
ChatMessageEmbedRemoveEvent,
|
ChatMessageEmbedRemoveEvent,
|
||||||
|
ChatMessageAttachmentEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageImageLightboxEvent,
|
ChatMessageImageLightboxEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
@@ -340,14 +341,17 @@ export class ChatMessagesComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openImageGallery(attachments: Attachment[]): void {
|
openImageGallery(attachments: Attachment[]): void {
|
||||||
const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
if (attachments.length < 2) {
|
||||||
|
|
||||||
if (availableImages.length < 2) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.attachmentsSvc.pinDisplayBlobs(availableImages);
|
const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
||||||
this.galleryAttachments.set(availableImages);
|
|
||||||
|
if (displayableImages.length > 0) {
|
||||||
|
this.attachmentsSvc.pinDisplayBlobs(displayableImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.galleryAttachments.set(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeImageGallery(): void {
|
closeImageGallery(): void {
|
||||||
@@ -372,6 +376,20 @@ export class ChatMessagesComponent {
|
|||||||
await this.attachmentDownload.downloadToUserLocation(attachment);
|
await this.attachmentDownload.downloadToUserLocation(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryGalleryImage(event: ChatMessageAttachmentEvent): void {
|
||||||
|
const { messageId, attachment } = event;
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0 || this.attachmentsSvc.hasPendingRequest(messageId, attachment.id)) {
|
||||||
|
this.attachmentsSvc.cancelRequest(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.attachmentsSvc.requestImageFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelGalleryImage(event: ChatMessageAttachmentEvent): void {
|
||||||
|
this.attachmentsSvc.cancelRequest(event.messageId, event.attachment);
|
||||||
|
}
|
||||||
|
|
||||||
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
||||||
this.closeImageContextMenu();
|
this.closeImageContextMenu();
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -205,6 +205,22 @@
|
|||||||
<span class="chat-image-grid-loading-label"
|
<span class="chat-image-grid-loading-label"
|
||||||
>{{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}%</span
|
>{{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}%</span
|
||||||
>
|
>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-image-grid-retry"
|
||||||
|
(click)="cancelAttachment(gridImage)"
|
||||||
|
>
|
||||||
|
{{ 'chat.message.cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-image-grid-retry"
|
||||||
|
(click)="retryImageRequest(gridImage)"
|
||||||
|
>
|
||||||
|
{{ 'chat.message.retry' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||||
@@ -226,7 +242,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chat-image-grid-cell chat-image-grid-overflow"
|
class="chat-image-grid-cell chat-image-grid-overflow"
|
||||||
[attr.aria-label]="viewAllImagesAriaLabel(displayableImages().length)"
|
[attr.aria-label]="viewAllImagesAriaLabel(imageAttachments().length)"
|
||||||
(click)="openImageGallery()"
|
(click)="openImageGallery()"
|
||||||
>
|
>
|
||||||
<span class="chat-image-grid-overflow-label">{{ imageOverflowLabel(cell.hiddenCount) }}</span>
|
<span class="chat-image-grid-overflow-label">{{ imageOverflowLabel(cell.hiddenCount) }}</span>
|
||||||
|
|||||||
+34
-4
@@ -49,6 +49,7 @@ import {
|
|||||||
isImageAttachment,
|
isImageAttachment,
|
||||||
isInlineDisplayableImage
|
isInlineDisplayableImage
|
||||||
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
||||||
|
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
|
||||||
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
|
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
|
||||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
@@ -268,6 +269,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly hydrateMessageImages = effect(() => {
|
private readonly hydrateMessageImages = effect(() => {
|
||||||
const messageId = this.message().id;
|
const messageId = this.message().id;
|
||||||
const images = this.imageAttachments();
|
const images = this.imageAttachments();
|
||||||
|
const mediaAttachments = this.attachmentViewModels().filter((attachment) => attachment.isVideo || attachment.isAudio);
|
||||||
|
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
const isVisible = this.isMessageVisible();
|
const isVisible = this.isMessageVisible();
|
||||||
@@ -290,11 +292,31 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
|
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const media of mediaAttachments) {
|
||||||
|
if (media.objectUrl || !isAttachmentPendingMediaHydration(media)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveAttachment = this.getLiveAttachment(media.id);
|
||||||
|
|
||||||
|
if (!liveAttachment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (images.some((image) => !isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image))) {
|
const needsAutoDownload = images.some((image) =>
|
||||||
|
!isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image)
|
||||||
|
) || mediaAttachments.some((media) =>
|
||||||
|
!media.objectUrl && !isAttachmentPendingMediaHydration(media) && (media.receivedBytes ?? 0) === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needsAutoDownload) {
|
||||||
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
|
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -811,9 +833,17 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
retryImageRequest(attachment: Attachment): void {
|
retryImageRequest(attachment: Attachment): void {
|
||||||
const liveAttachment = this.getLiveAttachment(attachment.id);
|
const liveAttachment = this.getLiveAttachment(attachment.id);
|
||||||
|
|
||||||
if (liveAttachment) {
|
if (!liveAttachment) {
|
||||||
this.attachmentsSvc.requestImageFromAnyPeer(this.message().id, liveAttachment);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = this.message().id;
|
||||||
|
|
||||||
|
if ((liveAttachment.receivedBytes ?? 0) > 0 || this.attachmentsSvc.hasPendingRequest(messageId, liveAttachment.id)) {
|
||||||
|
this.attachmentsSvc.cancelRequest(messageId, liveAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachmentsSvc.requestImageFromAnyPeer(messageId, liveAttachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
openLightbox(attachment: Attachment): void {
|
||||||
@@ -830,7 +860,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openImageGallery(): void {
|
openImageGallery(): void {
|
||||||
const images = this.displayableImages();
|
const images = this.imageAttachments();
|
||||||
|
|
||||||
if (images.length < 2) {
|
if (images.length < 2) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+50
-6
@@ -32,22 +32,66 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto p-4">
|
<div class="overflow-y-auto p-4">
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
@for (attachment of galleryAttachments(); track attachment.id) {
|
@for (tile of galleryTiles(); track tile.attachment.id) {
|
||||||
|
@switch (tile.state) {
|
||||||
|
@case ('displayable') {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group/gallery relative aspect-square overflow-hidden rounded-md bg-secondary/40"
|
class="group/gallery relative aspect-square overflow-hidden rounded-md bg-secondary/40"
|
||||||
[attr.aria-label]="openImageAriaLabel(attachment.filename)"
|
[attr.aria-label]="openImageAriaLabel(tile.attachment.filename)"
|
||||||
(click)="openGalleryImage(attachment)"
|
(click)="openGalleryImage(tile.attachment)"
|
||||||
(contextmenu)="openImageContextMenu($event, attachment)"
|
(contextmenu)="openImageContextMenu($event, tile.attachment)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
[src]="attachment.objectUrl"
|
[src]="tile.attachment.objectUrl"
|
||||||
[alt]="attachment.filename"
|
[alt]="tile.attachment.filename"
|
||||||
class="h-full w-full object-cover transition-transform duration-200 group-hover/gallery:scale-[1.02]"
|
class="h-full w-full object-cover transition-transform duration-200 group-hover/gallery:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
|
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@case ('hydrating') {
|
||||||
|
<div class="flex aspect-square items-center justify-center rounded-md border border-border bg-secondary/40">
|
||||||
|
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('downloading') {
|
||||||
|
<div class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-border bg-secondary/40 p-3 text-center">
|
||||||
|
<div class="text-xs font-medium text-primary">
|
||||||
|
{{ ((tile.attachment.receivedBytes || 0) * 100) / tile.attachment.size | number: '1.0-0' }}%
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
|
||||||
|
(click)="cancelGalleryImage(tile.attachment)"
|
||||||
|
>
|
||||||
|
{{ 'chat.message.cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
|
||||||
|
(click)="retryGalleryImage(tile.attachment)"
|
||||||
|
>
|
||||||
|
{{ 'chat.message.retry' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<div class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border bg-secondary/20 p-3 text-center">
|
||||||
|
<span class="line-clamp-2 text-xs text-muted-foreground">{{ tile.attachment.filename }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
|
||||||
|
(click)="retryGalleryImage(tile.attachment)"
|
||||||
|
>
|
||||||
|
{{ 'chat.message.retry' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+36
@@ -20,10 +20,12 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { Attachment } from '../../../../../attachment';
|
import { Attachment } from '../../../../../attachment';
|
||||||
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
|
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
|
||||||
|
import { buildChatMessageGalleryTiles, type ChatMessageGalleryTile } from '../../../../domain/rules/chat-message-image-gallery.rules';
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
||||||
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
|
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
|
||||||
import {
|
import {
|
||||||
ChatLightboxState,
|
ChatLightboxState,
|
||||||
|
ChatMessageAttachmentEvent,
|
||||||
ChatMessageImageGalleryEvent,
|
ChatMessageImageGalleryEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageImageLightboxEvent
|
ChatMessageImageLightboxEvent
|
||||||
@@ -68,6 +70,18 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
|
|||||||
readonly copyRequested = output<Attachment>();
|
readonly copyRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
readonly imageRetryRequested = output<ChatMessageAttachmentEvent>();
|
||||||
|
readonly imageCancelRequested = output<ChatMessageAttachmentEvent>();
|
||||||
|
|
||||||
|
readonly galleryTiles = computed<ChatMessageGalleryTile<Attachment>[]>(() => {
|
||||||
|
const attachments = this.galleryAttachments();
|
||||||
|
|
||||||
|
if (!attachments) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildChatMessageGalleryTiles(attachments);
|
||||||
|
});
|
||||||
|
|
||||||
readonly lightboxAttachment = computed(() => {
|
readonly lightboxAttachment = computed(() => {
|
||||||
const state = this.lightboxState();
|
const state = this.lightboxState();
|
||||||
@@ -200,6 +214,28 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryGalleryImage(attachment: Attachment): void {
|
||||||
|
if (!attachment.messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imageRetryRequested.emit({
|
||||||
|
messageId: attachment.messageId,
|
||||||
|
attachment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelGalleryImage(attachment: Attachment): void {
|
||||||
|
if (!attachment.messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imageCancelRequested.emit({
|
||||||
|
messageId: attachment.messageId,
|
||||||
|
attachment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
closeImageContextMenu(): void {
|
closeImageContextMenu(): void {
|
||||||
this.contextMenuClosed.emit();
|
this.contextMenuClosed.emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ direct-message/
|
|||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
|
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.
|
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. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
|
3. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
|
||||||
4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
|
4. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
|
||||||
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
|
5. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
|
||||||
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
|
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.
|
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();
|
const normalizedContent = content.trim();
|
||||||
|
|
||||||
if (!normalizedContent) {
|
if (!normalizedContent) {
|
||||||
@@ -232,7 +237,7 @@ export class DirectMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message: DirectMessage = {
|
const message: DirectMessage = {
|
||||||
id: uuidv4(),
|
id: id ?? uuidv4(),
|
||||||
conversationId,
|
conversationId,
|
||||||
senderId,
|
senderId,
|
||||||
recipientId,
|
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)"
|
(copyRequested)="copyImageToClipboard($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
|
(imageRetryRequested)="retryGalleryImage($event)"
|
||||||
|
(imageCancelRequested)="cancelGalleryImage($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">{{ 'dm.chat.selectPrompt' | translate }}</div>
|
<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 { Store } from '@ngrx/store';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
} from '../../../attachment';
|
} from '../../../attachment';
|
||||||
import { ThemeNodeDirective } from '../../../theme';
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||||
|
import { planDmMessageSend } from '../../domain/rules/dm-message-send.rules';
|
||||||
import { isConversationBound } from './dm-chat.rules';
|
import { isConversationBound } from './dm-chat.rules';
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
|
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
|
||||||
@@ -52,7 +54,11 @@ import {
|
|||||||
type ChatMessageEmbedRemoveEvent
|
type ChatMessageEmbedRemoveEvent
|
||||||
} from '../../../chat';
|
} from '../../../chat';
|
||||||
import { stepLightboxIndex } from '../../../chat/domain/rules/chat-message-lightbox.rules';
|
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 {
|
import type {
|
||||||
DirectMessageStatus,
|
DirectMessageStatus,
|
||||||
LinkMetadata,
|
LinkMetadata,
|
||||||
@@ -306,13 +312,32 @@ export class DmChatComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
|
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);
|
this.replyTo.set(null);
|
||||||
|
|
||||||
if (event.pendingFiles.length > 0) {
|
if (plan.attachmentBinding) {
|
||||||
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
|
this.attachments.rememberMessageRoom(
|
||||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
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 {
|
openImageGallery(attachments: Attachment[]): void {
|
||||||
const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
if (attachments.length < 2) {
|
||||||
|
|
||||||
if (availableImages.length < 2) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.galleryAttachments.set(availableImages);
|
this.galleryAttachments.set(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeImageGallery(): void {
|
closeImageGallery(): void {
|
||||||
@@ -491,6 +514,20 @@ export class DmChatComponent {
|
|||||||
await this.attachmentDownload.downloadToUserLocation(attachment);
|
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> {
|
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
||||||
this.closeImageContextMenu();
|
this.closeImageContextMenu();
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ Browsers do not reliably fire WebSocket close events during page refresh or navi
|
|||||||
|
|
||||||
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values per tab/device). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
|
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values per tab/device). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
|
||||||
|
|
||||||
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot including chunked `chat-sync-batch` history for every saved room. Each `chat-sync-batch` carries its messages' attachment metadata (`attachments` map, local paths stripped) so sibling devices learn about synced attachments without holding the bytes.
|
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**, **attachment `file-announce` metadata**) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. Relayable types live in `account-sync/account-sync.rules.ts` (`RELAYABLE_ACCOUNT_SYNC_TYPES`); `file-announce` is included so sibling devices learn about new attachment metadata without requiring a cross-user mirror host first. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot including chunked `chat-sync-batch` history for every saved room. Each `chat-sync-batch` carries its messages' attachment metadata (`attachments` map, local paths stripped) so sibling devices learn about synced attachments without holding the bytes.
|
||||||
|
|
||||||
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
|
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ describe('account-sync.rules', () => {
|
|||||||
expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(true);
|
expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(true);
|
||||||
expect(isRelayableAccountSyncEvent({ type: 'message-revision', revision: {} as never })).toBe(true);
|
expect(isRelayableAccountSyncEvent({ type: 'message-revision', revision: {} as never })).toBe(true);
|
||||||
expect(isRelayableAccountSyncEvent({ type: 'chat-sync-batch', roomId: 'r1', messages: [] })).toBe(true);
|
expect(isRelayableAccountSyncEvent({ type: 'chat-sync-batch', roomId: 'r1', messages: [] })).toBe(true);
|
||||||
|
expect(isRelayableAccountSyncEvent({
|
||||||
|
type: 'file-announce',
|
||||||
|
messageId: 'm1',
|
||||||
|
file: {
|
||||||
|
id: 'f1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 1,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true
|
||||||
|
}
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
expect(isRelayableAccountSyncEvent({ type: 'voice-state', voiceState: {} as never })).toBe(false);
|
expect(isRelayableAccountSyncEvent({ type: 'voice-state', voiceState: {} as never })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const RELAYABLE_ACCOUNT_SYNC_TYPES = new Set([
|
|||||||
'chat-message',
|
'chat-message',
|
||||||
'message-revision',
|
'message-revision',
|
||||||
'chat-sync-batch',
|
'chat-sync-batch',
|
||||||
|
'file-announce',
|
||||||
'user-avatar-summary',
|
'user-avatar-summary',
|
||||||
'user-avatar-request',
|
'user-avatar-request',
|
||||||
'user-avatar-full',
|
'user-avatar-full',
|
||||||
|
|||||||
Reference in New Issue
Block a user