Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/fluux/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -829,6 +832,7 @@ const ChatMessageBubble = memo(function ChatMessageBubble({
message,
showAvatar,
isGroupEnd,
ownGroupKey,
avatar,
ownAvatar,
ownNickname,
Expand Down Expand Up @@ -948,6 +952,7 @@ const ChatMessageBubble = memo(function ChatMessageBubble({
message={message}
showAvatar={showAvatar}
isGroupEnd={isGroupEnd}
ownGroupKey={ownGroupKey}
isSelected={isSelected}
hasKeyboardSelection={hasKeyboardSelection}
showToolbarForSelection={showToolbarForSelection}
Expand Down
7 changes: 6 additions & 1 deletion apps/fluux/src/components/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1253,6 +1256,7 @@ const RoomMessageBubbleWrapper = memo(function RoomMessageBubbleWrapper({
message,
showAvatar,
isGroupEnd,
ownGroupKey,
whisperThread,
roomJid,
myNick,
Expand Down Expand Up @@ -1491,6 +1495,7 @@ const RoomMessageBubbleWrapper = memo(function RoomMessageBubbleWrapper({
message={message}
showAvatar={showAvatar}
isGroupEnd={isGroupEnd}
ownGroupKey={ownGroupKey}
isSelected={isSelected}
hasKeyboardSelection={hasKeyboardSelection}
showToolbarForSelection={showToolbarForSelection}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
</div>
</div>
<div
class="relative flex-1 min-w-0 touch:select-none touch:[-webkit-touch-callout:none] message-own-tint message-own-tint-start message-own-tint-end"
class="relative w-fit max-w-full min-w-0 touch:select-none touch:[-webkit-touch-callout:none] message-own-tint message-own-tint-start message-own-tint-end"
data-msg-chrome="header"
>
<div
Expand Down
30 changes: 29 additions & 1 deletion apps/fluux/src/components/conversation/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { EncryptedPlaceholder } from './EncryptedPlaceholder'
import { UnsupportedEncryptionNotice } from './UnsupportedEncryptionNotice'
import { MessageReactions } from './MessageReactions'
import { scrollToMessage, isActionMessage, type WhisperThreadPosition } from './messageGrouping'
import { useOwnGroupWidth } from './messageGroupWidth'
import { resolveDisplayTrust } from './messageTrust'
import { trustVisual } from '@/e2ee/trustVisual'
import { MessageAttachments } from '../MessageAttachments'
Expand Down Expand Up @@ -159,6 +160,14 @@ export interface MessageBubbleProps {

// Whether this message is the current find-on-page match
isCurrentMatch?: boolean

/**
* Key of the consecutive own-message run this row belongs to (from
* {@link ownGroupKey}); undefined for incoming or solo own messages. When set,
* the row's tint box shares the run's widest width so the own-message tint
* reads as one clean rectangle instead of a ragged, per-row hug.
*/
ownGroupKey?: string
}

/**
Expand Down Expand Up @@ -265,6 +274,10 @@ function arePropsEqual(prev: MessageBubbleProps, next: MessageBubbleProps): bool
// Find-on-page current match
if (prev.isCurrentMatch !== next.isCurrentMatch) return false

// Own-message run identity — changes when grouping shifts, so the row can
// (de)register from the shared-width coordinator.
if (prev.ownGroupKey !== next.ownGroupKey) return false

// All data props are equal - skip re-render
// (callback props like onReply, onEdit, etc. are intentionally ignored)
return true
Expand Down Expand Up @@ -324,6 +337,7 @@ export const MessageBubble = memo(function MessageBubble({
timeFormat,
highlightTerms,
isCurrentMatch,
ownGroupKey,
}: MessageBubbleProps) {
const { t } = useTranslation()
const [showReactionPicker, setShowReactionPickerState] = useState(false)
Expand Down Expand Up @@ -446,6 +460,19 @@ export const MessageBubble = memo(function MessageBubble({
const ownRowClass = ownTint
? `${!showAvatar ? ' message-own-cont-top' : ''}${!isGroupEnd ? ' message-own-cont-bottom' : ''}`
: ''
// Own-message tint hugs its content instead of spanning the row: `w-fit` sizes
// the filled surface to the widest line (body or the name+time header) and
// `max-w-full` still wraps long messages at the available width. Incoming rows
// and whisper cards keep `flex-1` so their layout is unchanged.
const contentWidthClass = ownTint ? 'w-fit max-w-full' : 'flex-1'
// A run of consecutive own messages shares its widest line's width so the tint
// reads as one clean rectangle rather than a ragged per-row hug. `ownGroupKey`
// is already undefined unless this is a MULTI-row own run; gate on `ownTint`
// too so a whisper-thread own message (a `flex-1` card) never gets pinned. The
// signature captures every field that changes the row's natural width so the
// group re-fits on content edits but not on hover/selection churn.
const ownGroupWidthSignature = `${message.body ?? ''}|${showAvatar ? 1 : 0}|${timeFormat}|${message.isRetracted ? 1 : 0}|${message.isEdited ? 1 : 0}|${JSON.stringify(message.reactions ?? {})}|${replyContext?.messageId ?? ''}|${message.attachment ? 1 : 0}|${message.linkPreview ? 1 : 0}|${message.poll ? 1 : 0}|${message.encryptedPayload ? 1 : 0}|${message.unsupportedEncryption?.name ?? ''}`
const ownGroupRef = useOwnGroupWidth(ownTint ? ownGroupKey : undefined, message.id, ownGroupWidthSignature)

const { canReply, canEdit, canDelete } = actions
const canCopyBody = !!message.body && !message.isRetracted && !message.encryptedPayload && !message.unsupportedEncryption
Expand Down Expand Up @@ -528,7 +555,8 @@ export const MessageBubble = memo(function MessageBubble({
the incoming rows — shattering the single "private with X" card. The
name header already carries the own-vs-counterpart distinction. */}
<div
className={`relative flex-1 min-w-0 touch:select-none touch:[-webkit-touch-callout:none] ${isSelected || showActionSheet ? 'bg-fluux-selection -my-0.5 py-0.5 -ms-2 ps-2 -me-4 pe-4 rounded-s' : ''}${inThread ? ` bg-fluux-private-soft border-x border-fluux-private-border px-2.5 py-1 ${threadStart ? 'border-t rounded-t-lg' : ''} ${threadEnd ? 'border-b rounded-b-lg' : ''}` : ''} ${ownTintClass}`}
ref={ownGroupRef}
className={`relative ${contentWidthClass} min-w-0 touch:select-none touch:[-webkit-touch-callout:none] ${isSelected || showActionSheet ? 'bg-fluux-selection -my-0.5 py-0.5 -ms-2 ps-2 -me-4 pe-4 rounded-s' : ''}${inThread ? ` bg-fluux-private-soft border-x border-fluux-private-border px-2.5 py-1 ${threadStart ? 'border-t rounded-t-lg' : ''} ${threadEnd ? 'border-b rounded-b-lg' : ''}` : ''} ${ownTintClass}`}
data-msg-chrome={showAvatar ? 'header' : 'cont'}
onTouchStart={handleContentTouchStart}
onTouchEnd={cancelLongPress}
Expand Down
3 changes: 3 additions & 0 deletions apps/fluux/src/components/conversation/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TypingIndicator } from './TypingIndicator'
import { groupMessagesByDate, shouldShowAvatar } from './messageGrouping'
import { useMessageListScroll } from './useMessageListScroll'
import { MessageWidthProvider } from './messageWidthContext'
import { OwnGroupWidthProvider } from './messageGroupWidth'
import { isFeatureEnabled } from '@/utils/featureFlags'
import { useSettingsStore } from '@/stores/settingsStore'
import type { CopyMessageMeta } from '@/utils/buildCopyText'
Expand Down Expand Up @@ -666,6 +667,7 @@ export function MessageList<T extends BaseMessage>({

return (
<MessageWidthProvider containerRef={scrollContainerRef}>
<OwnGroupWidthProvider>
<div className="relative flex-1 flex flex-col min-h-0">
{/* Scrollable message container - always mounted to preserve scroll position */}
<div
Expand Down Expand Up @@ -836,6 +838,7 @@ export function MessageList<T extends BaseMessage>({
</div>
<MessageSelectionBar count={selectionCount} onCopy={copySelected} onClear={clearSelection} />
</div>
</OwnGroupWidthProvider>
</MessageWidthProvider>
)
}
4 changes: 3 additions & 1 deletion apps/fluux/src/components/conversation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
169 changes: 169 additions & 0 deletions apps/fluux/src/components/conversation/messageGroupWidth.tsx
Original file line number Diff line number Diff line change
@@ -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<string, HTMLElement>
}

class OwnGroupWidthRegistry {
private groups = new Map<string, GroupEntry>()
private dirty = new Set<string>()
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<OwnGroupWidthRegistry | null>(null)

export function OwnGroupWidthProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<OwnGroupWidthRegistry | null>(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 <OwnGroupWidthContext.Provider value={registry}>{children}</OwnGroupWidthContext.Provider>
}

/**
* 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<HTMLDivElement | null>(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
}, [])
}
Loading
Loading