diff --git a/apps/fluux/src/components/ChatView.tsx b/apps/fluux/src/components/ChatView.tsx
index 353d910b3..c40a40ac4 100644
--- a/apps/fluux/src/components/ChatView.tsx
+++ b/apps/fluux/src/components/ChatView.tsx
@@ -12,7 +12,7 @@ import { KeyChangeBanner } from './KeyChangeBanner'
import { useConnectionStore } from '@fluux/sdk/react'
import { useFileUpload, useLinkPreview, useTypeToFocus, useMessageCopy, useMode, useMessageSelection, useMessageHoverState, useDragAndDrop, useConversationDraft, useTimeFormat } from '@/hooks'
import { Upload, Loader2 } from 'lucide-react'
-import { MessageBubble, MessageList as MessageListComponent, shouldShowAvatar, buildReplyContext } from './conversation'
+import { MessageBubble, MessageList as MessageListComponent, shouldShowAvatar, ownGroupKey as computeOwnGroupKey, buildReplyContext } from './conversation'
import { FindOnPageBar } from './conversation/FindOnPageBar'
import { useFindOnPage, type FindOnPageHandle } from '@/hooks/useFindOnPage'
import { useConversationEncryptionState, type ConversationEncryptionState } from '@/hooks/useConversationEncryptionState'
@@ -705,6 +705,7 @@ export const ChatMessageList = memo(function ChatMessageList({
message={msg}
showAvatar={shouldShowAvatar(groupMessages, idx)}
isGroupEnd={idx === groupMessages.length - 1 || shouldShowAvatar(groupMessages, idx + 1)}
+ ownGroupKey={computeOwnGroupKey(groupMessages, idx)}
avatar={msg.isOutgoing ? ownAvatar ?? undefined : contactsByJid.get(msg.from)?.avatar}
ownAvatar={ownAvatar}
ownNickname={ownNickname}
@@ -785,6 +786,8 @@ interface ChatMessageBubbleProps {
message: Message
showAvatar: boolean
isGroupEnd: boolean
+ /** Own-message run key (see ownGroupKey); undefined for incoming/solo rows. */
+ ownGroupKey?: string
avatar?: string
ownAvatar?: string | null
ownNickname?: string | null
@@ -829,6 +832,7 @@ const ChatMessageBubble = memo(function ChatMessageBubble({
message,
showAvatar,
isGroupEnd,
+ ownGroupKey,
avatar,
ownAvatar,
ownNickname,
@@ -948,6 +952,7 @@ const ChatMessageBubble = memo(function ChatMessageBubble({
message={message}
showAvatar={showAvatar}
isGroupEnd={isGroupEnd}
+ ownGroupKey={ownGroupKey}
isSelected={isSelected}
hasKeyboardSelection={hasKeyboardSelection}
showToolbarForSelection={showToolbarForSelection}
diff --git a/apps/fluux/src/components/RoomView.tsx b/apps/fluux/src/components/RoomView.tsx
index d73c0c1cb..03ab13548 100644
--- a/apps/fluux/src/components/RoomView.tsx
+++ b/apps/fluux/src/components/RoomView.tsx
@@ -5,7 +5,7 @@ import { useRoomActive, usePolls, useRoomModeration, useRoomManagement, useRoomE
import { useConnectionStore, useIgnoreStore, useRoomStore } from '@fluux/sdk/react'
import { ignoreStore, roomStore, type IgnoredUser } from '@fluux/sdk/stores'
import { useMentionAutocomplete, useFileUpload, useLinkPreview, useTypeToFocus, useMessageCopy, useMode, useMessageSelection, useMessageHoverState, useDragAndDrop, useConversationDraft, useTimeFormat, useContextMenu, useWhisperCounterpartPresent, useRoomOccupantCountBelow, isSmallScreen } from '@/hooks'
-import { MessageBubble, MessageList, RoomSystemLine, shouldShowAvatar, whisperThreadPosition, whisperCounterpartPresent, resolveWhisperTarget, decideWhisperSend, decideChatStateRoute, buildReplyContext, canClosePoll, PollBanner, type WhisperThreadPosition, type WhisperTarget } from './conversation'
+import { MessageBubble, MessageList, RoomSystemLine, shouldShowAvatar, ownGroupKey as computeOwnGroupKey, whisperThreadPosition, whisperCounterpartPresent, resolveWhisperTarget, decideWhisperSend, decideChatStateRoute, buildReplyContext, canClosePoll, PollBanner, type WhisperThreadPosition, type WhisperTarget } from './conversation'
import { FindOnPageBar } from './conversation/FindOnPageBar'
import { useFindOnPage, type FindOnPageHandle } from '@/hooks/useFindOnPage'
import { Avatar } from './Avatar'
@@ -1078,6 +1078,7 @@ export const RoomMessageList = memo(function RoomMessageList({
message={msg}
showAvatar={shouldShowAvatar(groupMessages, idx)}
isGroupEnd={idx === groupMessages.length - 1 || shouldShowAvatar(groupMessages, idx + 1)}
+ ownGroupKey={computeOwnGroupKey(groupMessages, idx)}
whisperThread={whisperThreadPosition(groupMessages, idx)}
roomJid={room.jid}
myNick={room.nickname}
@@ -1174,6 +1175,8 @@ interface RoomMessageBubbleWrapperProps {
message: RoomMessage
showAvatar: boolean
isGroupEnd: boolean
+ /** Own-message run key (see ownGroupKey); undefined for incoming/solo rows. */
+ ownGroupKey?: string
whisperThread: WhisperThreadPosition | null
// Per-row resolved sender data (resolved in the list layer from cheap Map lookups).
// `occupant` is the live, reference-stable occupant record (roomStore.addOccupant
@@ -1253,6 +1256,7 @@ const RoomMessageBubbleWrapper = memo(function RoomMessageBubbleWrapper({
message,
showAvatar,
isGroupEnd,
+ ownGroupKey,
whisperThread,
roomJid,
myNick,
@@ -1491,6 +1495,7 @@ const RoomMessageBubbleWrapper = memo(function RoomMessageBubbleWrapper({
message={message}
showAvatar={showAvatar}
isGroupEnd={isGroupEnd}
+ ownGroupKey={ownGroupKey}
isSelected={isSelected}
hasKeyboardSelection={hasKeyboardSelection}
showToolbarForSelection={showToolbarForSelection}
diff --git a/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap b/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap
index 77f6240d1..2d06eff09 100644
--- a/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap
+++ b/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap
@@ -289,7 +289,7 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
({
return (
+
{/* Scrollable message container - always mounted to preserve scroll position */}
({
+
)
}
diff --git a/apps/fluux/src/components/conversation/index.ts b/apps/fluux/src/components/conversation/index.ts
index 99a2d6c14..ff37dba76 100644
--- a/apps/fluux/src/components/conversation/index.ts
+++ b/apps/fluux/src/components/conversation/index.ts
@@ -24,9 +24,11 @@ export type { RoomSystemLineProps } from './RoomSystemLine'
export { MessageBubble, buildReplyContext } from './MessageBubble'
export type { MessageBubbleProps } from './MessageBubble'
-export { groupMessagesByDate, shouldShowAvatar, whisperThreadPosition, whisperCounterpartPresent, scrollToMessage, isActionMessage, canClosePoll } from './messageGrouping'
+export { groupMessagesByDate, shouldShowAvatar, ownGroupKey, whisperThreadPosition, whisperCounterpartPresent, scrollToMessage, isActionMessage, canClosePoll } from './messageGrouping'
export type { MessageGroup, WhisperThreadPosition } from './messageGrouping'
+export { OwnGroupWidthProvider, useOwnGroupWidth } from './messageGroupWidth'
+
export { resolveWhisperTarget, whisperTargetPresent, decideWhisperSend, decideChatStateRoute } from './whisperTarget'
export type { WhisperTarget, WhisperSendDecision, ChatStateRoute } from './whisperTarget'
diff --git a/apps/fluux/src/components/conversation/messageGroupWidth.tsx b/apps/fluux/src/components/conversation/messageGroupWidth.tsx
new file mode 100644
index 000000000..2f67e4d9d
--- /dev/null
+++ b/apps/fluux/src/components/conversation/messageGroupWidth.tsx
@@ -0,0 +1,169 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useRef,
+ type ReactNode,
+} from 'react'
+import { useRemeasureOnWidthChange } from './messageWidthContext'
+
+/**
+ * Shared-width coordination for a run of consecutive OWN (outgoing) messages so
+ * their tinted backgrounds form ONE clean rectangle sized to the group's widest
+ * line — even though the virtualized timeline renders each row as a flat,
+ * independent sibling with no per-group DOM container to hang a CSS solution on.
+ *
+ * Efficiency contract (the whole reason this is a bespoke registry, not state):
+ * - Widths are applied IMPERATIVELY (`el.style.width`), NEVER through React
+ * state or props. Measurement therefore never triggers a re-render, so there
+ * is no measure → render → measure feedback loop.
+ * - Every dirty group is flushed in ONE microtask with reads strictly before
+ * writes: the browser does a single layout pass and never thrashes. The flush
+ * runs before paint, so a group snaps to its rectangle in the same frame it
+ * mounts or scrolls in — no ragged-edge flicker.
+ * - Only MULTI-row own groups register. A solo own message (the common case)
+ * keeps its plain CSS `w-fit` and pays nothing.
+ * - We widen only the background box; the text inside stays `w-fit`, so row
+ * HEIGHTS never change and the virtualizer / scroll anchoring are untouched.
+ */
+
+interface GroupEntry {
+ /** Currently-mounted members of this group, by message id. */
+ mounted: Map
+}
+
+class OwnGroupWidthRegistry {
+ private groups = new Map()
+ private dirty = new Set()
+ private scheduled = false
+
+ register(groupId: string, memberId: string, el: HTMLElement): void {
+ let g = this.groups.get(groupId)
+ if (!g) {
+ g = { mounted: new Map() }
+ this.groups.set(groupId, g)
+ }
+ g.mounted.set(memberId, el)
+ this.markDirty(groupId)
+ }
+
+ unregister(groupId: string, memberId: string): void {
+ const g = this.groups.get(groupId)
+ if (!g) return
+ // Return the leaving row to its natural CSS width so a regrouped row (or a
+ // row scrolling back in) never keeps a stale pinned width.
+ const el = g.mounted.get(memberId)
+ if (el) el.style.width = ''
+ g.mounted.delete(memberId)
+ if (g.mounted.size === 0) {
+ this.groups.delete(groupId)
+ this.dirty.delete(groupId)
+ return
+ }
+ // A removed member may have been the widest — re-fit the survivors.
+ this.markDirty(groupId)
+ }
+
+ /** Mark one group for re-measure (member added/removed, or content changed). */
+ markDirty(groupId: string): void {
+ if (!this.groups.has(groupId)) return
+ this.dirty.add(groupId)
+ this.schedule()
+ }
+
+ /** Container width changed → text rewraps → every group's max may shift. */
+ markAllDirty(): void {
+ for (const id of this.groups.keys()) this.dirty.add(id)
+ this.schedule()
+ }
+
+ private schedule(): void {
+ if (this.scheduled || this.dirty.size === 0) return
+ this.scheduled = true
+ queueMicrotask(() => this.flush())
+ }
+
+ private flush(): void {
+ this.scheduled = false
+ const entries: GroupEntry[] = []
+ for (const id of this.dirty) {
+ const g = this.groups.get(id)
+ if (g && g.mounted.size) entries.push(g)
+ }
+ this.dirty.clear()
+ if (entries.length === 0) return
+
+ // Phase 1 (write): free every member to its natural width so we can read it.
+ for (const g of entries) {
+ for (const el of g.mounted.values()) el.style.width = 'max-content'
+ }
+ // Phase 2 (read): a single forced layout for the whole batch, then the max
+ // natural width per group. `max-content` is still clamped by the box's
+ // `max-w-full`, so a long/wrapping line reads as the available width and its
+ // group becomes full-width — exactly as before.
+ const maxes = entries.map((g) => {
+ let max = 0
+ for (const el of g.mounted.values()) {
+ const w = el.offsetWidth
+ if (w > max) max = w
+ }
+ return max
+ })
+ // Phase 3 (write): pin every member to the group max. Skip a zero measure
+ // (e.g. a layout-less test environment) so the box keeps its CSS `w-fit`.
+ for (let i = 0; i < entries.length; i++) {
+ const max = maxes[i]
+ const width = max > 0 ? `${max}px` : ''
+ for (const el of entries[i].mounted.values()) el.style.width = width
+ }
+ }
+}
+
+const OwnGroupWidthContext = createContext(null)
+
+export function OwnGroupWidthProvider({ children }: { children: ReactNode }) {
+ const registryRef = useRef(null)
+ if (!registryRef.current) registryRef.current = new OwnGroupWidthRegistry()
+ const registry = registryRef.current
+
+ // Container width change → text rewraps → natural widths change → re-fit all.
+ // Reuses the list's single debounced ResizeObserver (no per-row observers).
+ useRemeasureOnWidthChange(() => registry.markAllDirty())
+
+ return {children}
+}
+
+/**
+ * Attach the returned callback ref to a grouped own-message's tint box so it
+ * shares the group's width. Pass `groupId === undefined` for solo/incoming rows
+ * (the box then keeps its CSS `w-fit`); safe with no provider (tests no-op too).
+ *
+ * `widthSignature` is any value derived from the row's width-affecting content
+ * (body, reactions, reply, attachment…). When it changes, the group re-measures;
+ * hover/selection churn leaves it untouched, so those re-renders cost nothing.
+ */
+export function useOwnGroupWidth(
+ groupId: string | undefined,
+ memberId: string,
+ widthSignature: unknown,
+): (node: HTMLDivElement | null) => void {
+ const registry = useContext(OwnGroupWidthContext)
+ const nodeRef = useRef(null)
+ const active = registry != null && groupId != null
+
+ useLayoutEffect(() => {
+ if (!active) return
+ const node = nodeRef.current
+ if (!node) return
+ registry.register(groupId, memberId, node)
+ return () => registry.unregister(groupId, memberId)
+ // widthSignature re-runs the effect on content change: the cleanup unregisters
+ // and the setup re-registers the same node, marking the group dirty so it
+ // re-fits. The transient unregister settles before the microtask flush.
+ }, [registry, active, groupId, memberId, widthSignature])
+
+ return useCallback((node: HTMLDivElement | null) => {
+ nodeRef.current = node
+ }, [])
+}
diff --git a/apps/fluux/src/components/conversation/messageGrouping.test.ts b/apps/fluux/src/components/conversation/messageGrouping.test.ts
index d1490dbc0..e5394bff1 100644
--- a/apps/fluux/src/components/conversation/messageGrouping.test.ts
+++ b/apps/fluux/src/components/conversation/messageGrouping.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { groupMessagesByDate, shouldShowAvatar, whisperThreadPosition, whisperCounterpartPresent, scrollToMessage, isActionMessage, canClosePoll } from './messageGrouping'
+import { groupMessagesByDate, shouldShowAvatar, ownGroupKey, whisperThreadPosition, whisperCounterpartPresent, scrollToMessage, isActionMessage, canClosePoll } from './messageGrouping'
import { setActiveMessageListController } from './activeMessageListController'
// Mock CSS.escape since it's not available in JSDOM
@@ -331,6 +331,61 @@ describe('shouldShowAvatar', () => {
})
})
+describe('ownGroupKey', () => {
+ const own = (id: string, min: number) => ({
+ id,
+ from: 'me',
+ isOutgoing: true,
+ timestamp: new Date(`2024-01-15T10:0${min}:00`),
+ })
+ const incoming = (id: string, min: number) => ({
+ id,
+ from: 'alice',
+ isOutgoing: false,
+ timestamp: new Date(`2024-01-15T10:0${min}:00`),
+ })
+
+ it('returns undefined for incoming messages', () => {
+ const messages = [incoming('1', 0), incoming('2', 1)]
+ expect(ownGroupKey(messages, 0)).toBeUndefined()
+ expect(ownGroupKey(messages, 1)).toBeUndefined()
+ })
+
+ it('returns undefined for a solo own message (a group of one)', () => {
+ const messages = [incoming('1', 0), own('2', 1), incoming('3', 2)]
+ expect(ownGroupKey(messages, 1)).toBeUndefined()
+ })
+
+ it('returns the group-start id for every row of a multi-message own run', () => {
+ const messages = [incoming('a', 0), own('b', 1), own('c', 2), own('d', 3)]
+ // All three own rows resolve to the run's first id.
+ expect(ownGroupKey(messages, 1)).toBe('b')
+ expect(ownGroupKey(messages, 2)).toBe('b')
+ expect(ownGroupKey(messages, 3)).toBe('b')
+ })
+
+ it('starts a fresh key after a >5min gap splits the own run', () => {
+ const messages = [
+ { id: 'b', from: 'me', isOutgoing: true, timestamp: new Date('2024-01-15T10:00:00') },
+ { id: 'c', from: 'me', isOutgoing: true, timestamp: new Date('2024-01-15T10:01:00') },
+ // >5min later — shouldShowAvatar breaks the group here, so a new run begins.
+ { id: 'd', from: 'me', isOutgoing: true, timestamp: new Date('2024-01-15T10:10:00') },
+ { id: 'e', from: 'me', isOutgoing: true, timestamp: new Date('2024-01-15T10:11:00') },
+ ]
+ expect(ownGroupKey(messages, 0)).toBe('b')
+ expect(ownGroupKey(messages, 1)).toBe('b')
+ expect(ownGroupKey(messages, 2)).toBe('d')
+ expect(ownGroupKey(messages, 3)).toBe('d')
+ })
+
+ it('does not merge an own run across an incoming message', () => {
+ const messages = [own('a', 0), incoming('b', 1), own('c', 2)]
+ // Both own rows are solo (separated by the incoming), so neither groups.
+ expect(ownGroupKey(messages, 0)).toBeUndefined()
+ expect(ownGroupKey(messages, 2)).toBeUndefined()
+ })
+})
+
describe('whisperThreadPosition', () => {
// helpers: w = whisper with a counterpart; pub = public message
const w = (id: string, whisperWith: string, from = 'alice') => ({
diff --git a/apps/fluux/src/components/conversation/messageGrouping.ts b/apps/fluux/src/components/conversation/messageGrouping.ts
index d78b510cb..d527051ed 100644
--- a/apps/fluux/src/components/conversation/messageGrouping.ts
+++ b/apps/fluux/src/components/conversation/messageGrouping.ts
@@ -45,6 +45,8 @@ interface GroupableMessage {
id: string
timestamp: Date
from: string
+ /** True for messages the local user sent — drives own-message grouping. */
+ isOutgoing?: boolean
/** Message body — used to detect /me action messages for grouping. */
body?: string
securityContext?: GroupingSecurityContext
@@ -185,6 +187,34 @@ export function shouldShowAvatar(messages: T[], inde
return timeDiff > 5 * 60 * 1000
}
+/**
+ * Stable key identifying the run of consecutive OWN messages a row belongs to,
+ * or `undefined` when the row is incoming OR is a solo own message (a group of
+ * one needs no shared-width coordination). The key is the group-start message's
+ * id, so every member of the same run resolves to the same value.
+ *
+ * A "run" is an avatar-group (same boundaries as {@link shouldShowAvatar}) whose
+ * rows are all outgoing — the exact span the own-message tint renders as one
+ * continuous surface. Used to make those rows share the widest line's width so
+ * the tint reads as a clean rectangle (see `useOwnGroupWidth`).
+ */
+export function ownGroupKey(messages: T[], index: number): string | undefined {
+ if (!messages[index]?.isOutgoing) return undefined
+
+ // Walk back to the group start (rows without an avatar continue the previous).
+ let start = index
+ while (start > 0 && !shouldShowAvatar(messages, start)) start--
+ // Walk forward to the group end (the next avatar row starts a new group).
+ let end = index
+ while (end < messages.length - 1 && !shouldShowAvatar(messages, end + 1)) end++
+
+ // The whole run must be outgoing (a sender change is an avatar break, so this
+ // holds by construction, but guard the boundaries defensively) and span > 1 row.
+ if (start === end) return undefined
+ if (!messages[start].isOutgoing || !messages[end].isOutgoing) return undefined
+ return messages[start].id
+}
+
/** Position of a message within a whisper thread (see `whisperThreadPosition`). */
export type WhisperThreadPosition = 'solo' | 'start' | 'middle' | 'end'