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'