fix: Bug - Video attachment on android gets sent in the message bubble above with no preview image

This commit is contained in:
2026-06-11 02:44:22 +02:00
parent b1b3d93851
commit d72a027c9a
10 changed files with 315 additions and 25 deletions

View File

@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### Bind chat attachments to a pre-allocated message id, never by matching content [attachments] [chat] [mobile]
- **Trigger:** caption-less media (videos/images sent with no text) grouped onto the message bubble above and left an empty message below on Android — `ChatMessagesComponent` dispatched `sendMessage` without an id, then a `setTimeout` re-discovered the message by `entry.content === content` (always `''` for attachment-only sends) and called `publishAttachments` on it.
- **Rule:** pre-allocate the message id in the component (`planChatMessageSend` in `chat-message-send.rules.ts`), dispatch it via `MessagesActions.sendMessage({ id, ... })` (effect uses `id ?? uuidv4()`), and bind attachments to that exact id with `publishAttachments(id, files)` — never re-find the message by content/timing.
- **Why:** empty content is shared by every attachment-only message, so content matching picks the newest match and races the async create-effect; on Android the create latency exceeds the old 100 ms timer, so the file binds to a stale sibling. The race is invisible on fast desktop browsers, so the deterministic regression proof is the unit test that asserts the dispatched action id equals the attachment-binding id, not an e2e timing game (see the "don't bump E2E timeouts for sync flakes" lesson).
- **Example:** `planChatMessageSend(...).attachmentBinding.messageId === plan.action.id` enforced in `chat-message-send.rules.spec.ts`; behavioral guard in `e2e/tests/chat/attachment-only-message-grouping.spec.ts` (proves the id flows component→effect→attachment by requiring each caption-less attachment to render in its own bubble).
### Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile] ### Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile]
- **Trigger:** `AttachmentStorageService` talked only to `window.electronAPI`, so `canWriteFiles()` returned `false` on Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file". - **Trigger:** `AttachmentStorageService` talked only to `window.electronAPI`, so `canWriteFiles()` returned `false` on Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file".

View File

@@ -94,6 +94,25 @@ export class ChatMessagesPage {
}, files); }, files);
} }
/** Sends the currently-attached files with no text caption (attachment-only message). */
async sendPendingAttachments(): Promise<void> {
await this.waitForReady();
await expect(this.sendButton).toBeEnabled({ timeout: 10_000 });
await this.sendButton.click();
}
/** The message bubble that contains the rendered image with the given alt text. */
getMessageItemContainingImage(altText: string): Locator {
return this.messageItems.filter({
has: this.page.locator(`img[alt="${altText}"]`)
}).last();
}
/** Resolves the stable data-message-id of the bubble holding the given image. */
async getMessageIdContainingImage(altText: string): Promise<string | null> {
return this.getMessageItemContainingImage(altText).getAttribute('data-message-id');
}
async openGifPicker(): Promise<void> { async openGifPicker(): Promise<void> {
await this.waitForReady(); await this.waitForReady();
await this.gifButton.click(); await this.gifButton.click();

View File

@@ -0,0 +1,125 @@
import {
test,
expect,
type Client
} 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';
/**
* Regression coverage for: "Video attachment on android gets sent in the
* message bubble above with no preview image."
*
* Root cause was platform-agnostic: caption-less media was bound to a message
* re-discovered by matching `content` (always '' for attachment-only sends),
* which raced the async create-effect and grouped a second attachment onto the
* previous bubble - leaving an empty message behind. The fix pre-allocates the
* message id, dispatches it, and binds attachments to that exact id. This test
* proves each caption-less attachment lands in its own bubble and renders.
*/
test.describe('Attachment-only message grouping', () => {
test.describe.configure({ timeout: 180_000 });
test('each caption-less attachment keeps its own message bubble and preview', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const serverName = `Attachment Group ${uniqueName('srv')}`;
const introText = `Intro line ${uniqueName('intro')}`;
const firstImageName = `${uniqueName('first')}.svg`;
const secondImageName = `${uniqueName('second')}.svg`;
const firstImage = createSvgFilePayload(firstImageName);
const secondImage = createSvgFilePayload(secondImageName);
await test.step('Create a server and open its room', async () => {
await scenario.search.createServer(serverName, { description: 'Attachment grouping regression server' });
await expect(scenario.client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await scenario.messages.waitForReady();
});
await test.step('Send a normal text message first', async () => {
await scenario.messages.sendMessage(introText);
await expect(scenario.messages.getMessageItemByText(introText)).toBeVisible({ timeout: 20_000 });
});
await test.step('Send two caption-less attachments back-to-back', async () => {
// Fire them rapidly (no render wait between) to mirror the reported
// rapid-upload repro and stress the message-create vs. attach ordering.
await scenario.messages.attachFiles([firstImage]);
await scenario.messages.sendPendingAttachments();
await scenario.messages.attachFiles([secondImage]);
await scenario.messages.sendPendingAttachments();
await scenario.messages.expectMessageImageLoaded(firstImageName);
await scenario.messages.expectMessageImageLoaded(secondImageName);
});
await test.step('Each attachment lives in its own bubble (no grouping, no blank message)', async () => {
const firstMessageId = await scenario.messages.getMessageIdContainingImage(firstImageName);
const secondMessageId = await scenario.messages.getMessageIdContainingImage(secondImageName);
expect(firstMessageId).toBeTruthy();
expect(secondMessageId).toBeTruthy();
// The bug grouped both onto one bubble; distinct ids prove they did not.
expect(firstMessageId).not.toBe(secondMessageId);
// Exactly two bubbles carry an image, and neither carries both.
await expect(scenario.messages.messageItems.filter({ has: scenario.client.page.locator('img[alt$=".svg"]') }))
.toHaveCount(2, { timeout: 20_000 });
await expect(scenario.messages.getMessageItemContainingImage(firstImageName).locator('img[alt$=".svg"]'))
.toHaveCount(1);
await expect(scenario.messages.getMessageItemContainingImage(secondImageName).locator('img[alt$=".svg"]'))
.toHaveCount(1);
});
});
});
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
search: new ServerSearchPage(client.page)
};
}
function createSvgFilePayload(name: string): ChatDropFilePayload {
const markup = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${name}</text>`,
'</svg>'
].join('');
return {
name,
mimeType: 'image/svg+xml',
base64: Buffer.from(markup, 'utf8').toString('base64')
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

BIN
images/icon-new-rounded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

View File

@@ -97,6 +97,10 @@ sequenceDiagram
User->>DC: broadcastMessage(delete-message) User->>DC: broadcastMessage(delete-message)
``` ```
### Attachment binding (pre-allocated message id)
`ChatMessagesComponent.handleMessageSubmitted` pre-allocates the message id via `planChatMessageSend` (`domain/rules/chat-message-send.rules.ts`), passes it to `MessagesActions.sendMessage({ id, ... })`, and binds any pending files to that **same** id with `AttachmentFacade.publishAttachments(id, ...)`. The send effect honours a provided `id` (falling back to a fresh UUID). Never re-discover the just-sent message by matching its `content` to attach files — caption-less media all share an empty content string, so a content match is ambiguous and races the async create-effect, grouping attachments onto a sibling bubble and leaving an empty message behind.
## Message integrity ## Message integrity
Outgoing creates/edits/deletes also emit signed `message-revision` events and persist revision audit rows locally. Sync inventories include `revision` and `headHash`; merge prefers a verified higher revision over legacy timestamp comparison. See `agents-docs/features/message-integrity.md` and `MessageRevisionService`. Outgoing creates/edits/deletes also emit signed `message-revision` events and persist revision audit rows locally. Sync inventories include `revision` and `headHash`; merge prefers a verified higher revision over legacy timestamp comparison. See `agents-docs/features/message-integrity.md` and `MessageRevisionService`.

View File

@@ -0,0 +1,79 @@
import {
describe,
expect,
it
} from 'vitest';
import { planChatMessageSend } from './chat-message-send.rules';
function makeFile(name: string): File {
return new File([
new Uint8Array([
1,
2,
3
])
], name, { type: 'video/mp4' });
}
describe('planChatMessageSend', () => {
it('binds attachments to the exact id carried by the dispatched action', () => {
const video = makeFile('clip.mp4');
const plan = planChatMessageSend({
generateId: () => 'msg-123',
content: '',
pendingFiles: [video]
});
expect(plan.action.id).toBe('msg-123');
expect(plan.attachmentBinding).not.toBeNull();
// The whole point of the fix: same id for the action and the attachment binding.
expect(plan.attachmentBinding?.messageId).toBe(plan.action.id);
expect(plan.attachmentBinding?.files).toEqual([video]);
});
it('gives each caption-less media send a distinct id so attachments never group onto a sibling', () => {
const ids = ['msg-1', 'msg-2'];
let call = 0;
const generateId = () => ids[call++];
const first = planChatMessageSend({ generateId, content: '', pendingFiles: [makeFile('first.mp4')] });
const second = planChatMessageSend({ generateId, content: '', pendingFiles: [makeFile('second.mp4')] });
expect(first.action.id).toBe('msg-1');
expect(second.action.id).toBe('msg-2');
expect(first.action.id).not.toBe(second.action.id);
expect(first.attachmentBinding?.messageId).toBe('msg-1');
expect(second.attachmentBinding?.messageId).toBe('msg-2');
});
it('produces no attachment binding when there are no pending files', () => {
const plan = planChatMessageSend({
generateId: () => 'msg-text-only',
content: 'hello',
pendingFiles: [],
replyToId: 'reply-1',
channelId: 'general'
});
expect(plan.attachmentBinding).toBeNull();
expect(plan.action).toEqual({
id: 'msg-text-only',
content: 'hello',
replyToId: 'reply-1',
channelId: 'general'
});
});
it('normalizes a null channel id to undefined', () => {
const plan = planChatMessageSend({
generateId: () => 'msg-x',
content: 'hi',
pendingFiles: [],
channelId: null
});
expect(plan.action.channelId).toBeUndefined();
});
});

View File

@@ -0,0 +1,52 @@
/**
* Pure planning for an outgoing chat message and its attachments.
*
* The single invariant this module guarantees is that the id carried by the
* dispatched `Send Message` action is the *same* id used to bind any pending
* attachments. Earlier code dispatched the message without an id and then
* tried to re-discover the just-created message by matching its `content`.
* For caption-less media (content === '') that match is ambiguous and races
* the asynchronous message-create effect, so an attachment could bind to a
* sibling message - grouping a video onto the bubble above and leaving an
* empty message behind (Android-visible, but a latent bug on every platform).
*/
export interface ChatMessageSendAction {
id: string;
content: string;
replyToId?: string;
channelId?: string;
}
export interface ChatMessageAttachmentBinding {
messageId: string;
files: File[];
}
export interface ChatMessageSendPlan {
action: ChatMessageSendAction;
attachmentBinding: ChatMessageAttachmentBinding | null;
}
export interface ChatMessageSendInput {
generateId: () => string;
content: string;
pendingFiles: File[];
replyToId?: string;
channelId?: string | null;
}
export function planChatMessageSend(input: ChatMessageSendInput): ChatMessageSendPlan {
const id = input.generateId();
return {
action: {
id,
content: input.content,
replyToId: input.replyToId,
channelId: input.channelId ?? undefined
},
attachmentBinding: input.pendingFiles.length > 0
? { messageId: id, files: input.pendingFiles }
: null
};
}

View File

@@ -11,6 +11,7 @@ import {
import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared'; import { BottomSheetComponent } from '../../../../shared';
@@ -36,6 +37,7 @@ import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.co
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component'; import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules'; import { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules';
import { planChatMessageSend } from '../../domain/rules/chat-message-send.rules';
import { import {
ChatLightboxState, ChatLightboxState,
ChatMessageComposerSubmitEvent, ChatMessageComposerSubmitEvent,
@@ -117,18 +119,20 @@ export class ChatMessagesComponent {
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.messageList?.scrollToBottomAfterLocalSend(); this.messageList?.scrollToBottomAfterLocalSend();
this.store.dispatch( const plan = planChatMessageSend({
MessagesActions.sendMessage({ generateId: uuidv4,
content: event.content, content: event.content,
replyToId: this.replyTo()?.id, pendingFiles: event.pendingFiles,
channelId: this.activeChannelId() replyToId: this.replyTo()?.id,
}) channelId: this.activeChannelId()
); });
this.store.dispatch(MessagesActions.sendMessage(plan.action));
this.clearReply(); this.clearReply();
if (event.pendingFiles.length > 0) { if (plan.attachmentBinding) {
setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100); this.attachFilesToMessage(plan.attachmentBinding.messageId, plan.attachmentBinding.files);
} }
} }
@@ -493,21 +497,14 @@ export class ChatMessagesComponent {
}); });
} }
private attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void { private attachFilesToMessage(messageId: string, pendingFiles: File[]): void {
const currentUserId = this.currentUser()?.id; const currentUserId = this.currentUser()?.id;
const roomId = this.currentRoom()?.id;
if (!currentUserId) if (roomId) {
return; this.attachmentsSvc.rememberMessageRoom(messageId, roomId);
const message = [...this.channelMessages()]
.reverse()
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
return;
} }
this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined); this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined);
} }
} }

View File

@@ -50,8 +50,15 @@ export const MessagesActions = createActionGroup({
}>(), }>(),
'Load Older Messages Failure': props<{ error: string }>(), 'Load Older Messages Failure': props<{ error: string }>(),
/** Sends a new chat message to the current room and broadcasts to peers. */ /**
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(), * Sends a new chat message to the current room and broadcasts to peers.
*
* An optional `id` lets the caller pre-allocate the message id so it can
* bind attachments to the exact message it created, instead of guessing
* by matching content after the fact (which mis-attaches caption-less
* media to a sibling message - see the attachment-only media bug).
*/
'Send Message': props<{ id?: string; content: string; replyToId?: string; channelId?: string }>(),
'Send Message Success': props<{ message: Message }>(), 'Send Message Success': props<{ message: Message }>(),
'Send Message Failure': props<{ error: string }>(), 'Send Message Failure': props<{ error: string }>(),

View File

@@ -230,7 +230,7 @@ export class MessagesEffects {
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
mergeMap(([ mergeMap(([
{ content, replyToId, channelId }, { id, content, replyToId, channelId },
currentUser, currentUser,
currentRoom currentRoom
]) => { ]) => {
@@ -239,7 +239,7 @@ export class MessagesEffects {
} }
const draftMessage: Message = { const draftMessage: Message = {
id: uuidv4(), id: id ?? uuidv4(),
roomId: currentRoom.id, roomId: currentRoom.id,
channelId: channelId || 'general', channelId: channelId || 'general',
senderId: currentUser.id, senderId: currentUser.id,