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
116 changes: 62 additions & 54 deletions apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,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] "
data-msg-chrome="header"
class="relative flex-1 min-w-0"
>
<div
class="absolute -top-7 -end-2 p-4 z-20 select-none pointer-events-none transition-all duration-200 ease-out opacity-0 translate-x-2"
Expand Down Expand Up @@ -223,40 +222,45 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
</div>
</div>
<div
class="flex items-baseline gap-2 pb-1 flex-wrap"
class="relative w-full min-w-0 touch:select-none touch:[-webkit-touch-callout:none] "
data-msg-chrome="header"
>
<div
class="cursor-pointer "
role="button"
tabindex="0"
class="flex items-baseline gap-2 pb-1 flex-wrap"
>
<div
class="cursor-pointer "
role="button"
tabindex="0"
>
<span
class="font-medium"
style="color: rgb(161, 197, 235);"
>
Alice Smith
</span>
</div>
<span
class="font-medium"
style="color: rgb(161, 197, 235);"
class="text-xs text-fluux-muted"
>
Alice Smith
14:30
</span>
</div>
<span
class="text-xs text-fluux-muted"
>
14:30
</span>
</div>
<div
class=""
>
<div
class="text-fluux-text break-words whitespace-pre-wrap leading-[1.375]"
data-msg-text="true"
dir="auto"
class=""
>
Hello there!
</div>
<div
data-testid="message-attachments"
>
Attachments
<div
class="text-fluux-text break-words whitespace-pre-wrap leading-[1.375]"
data-msg-text="true"
dir="auto"
>
Hello there!
</div>
<div
data-testid="message-attachments"
>
Attachments
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -289,8 +293,7 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
</div>
</div>
<div
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"
class="relative flex-1 min-w-0"
>
<div
class="absolute -top-7 -end-2 p-4 z-20 select-none pointer-events-none transition-all duration-200 ease-out opacity-0 translate-x-2"
Expand Down Expand Up @@ -384,40 +387,45 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
</div>
</div>
<div
class="flex items-baseline gap-2 pb-1 flex-wrap"
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
class="cursor-pointer "
role="button"
tabindex="0"
class="flex items-baseline gap-2 pb-1 flex-wrap"
>
<div
class="cursor-pointer "
role="button"
tabindex="0"
>
<span
class="font-medium"
style="color: var(--fluux-text-self);"
>
Me
</span>
</div>
<span
class="font-medium"
style="color: var(--fluux-text-self);"
class="text-xs text-fluux-muted"
>
Me
14:30
</span>
</div>
<span
class="text-xs text-fluux-muted"
>
14:30
</span>
</div>
<div
class=""
>
<div
class="text-fluux-text break-words whitespace-pre-wrap leading-[1.375]"
data-msg-text="true"
dir="auto"
class=""
>
Hi! How are you?
</div>
<div
data-testid="message-attachments"
>
Attachments
<div
class="text-fluux-text break-words whitespace-pre-wrap leading-[1.375]"
data-msg-text="true"
dir="auto"
>
Hi! How are you?
</div>
<div
data-testid="message-attachments"
>
Attachments
</div>
</div>
</div>
</div>
Expand Down
45 changes: 45 additions & 0 deletions apps/fluux/src/components/conversation/MessageBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,51 @@ describe('Own-message tint', () => {
})
})

describe('Hover toolbar anchoring', () => {
// Regression: the own-message tint hugs its content (`w-fit`), so anchoring the
// hover toolbar inside that box made the toolbar drift left/right with the
// bubble width — a short own message pulled its menu far from the row edge.
// The toolbar now lives in a full-width positioning column that also wraps the
// hugging tint box, so it pins to the row's right edge for every message
// regardless of how narrow the tint is. These guard that structure.
it('renders the toolbar OUTSIDE the hugging own-tint box (not a descendant of it)', () => {
const props = createDefaultProps({ message: createTestMessage({ isOutgoing: true }) })
const { container } = render(<MessageBubble {...props} />)

const toolbar = container.querySelector('[data-message-toolbar]')
expect(toolbar).not.toBeNull()
// If someone re-nests the toolbar inside the w-fit tint, this fails.
expect(toolbar!.closest('.message-own-tint')).toBeNull()
})

it('anchors the toolbar in a full-width (flex-1) column that also holds the tint box', () => {
const props = createDefaultProps({ message: createTestMessage({ isOutgoing: true }) })
const { container } = render(<MessageBubble {...props} />)

const column = container.querySelector('[data-message-toolbar]')!.parentElement!
// The positioning column spans the full available width and is the offset
// parent for the absolutely-positioned toolbar.
expect(column.className).toContain('flex-1')
expect(column.className).toContain('relative')
// The content-hugging tint box is a sibling of the toolbar inside that column.
const tint = column.querySelector('.message-own-tint')
expect(tint).not.toBeNull()
expect(tint!.className).toContain('w-fit')
})

it('keeps incoming content full-width (w-full) with the toolbar as a sibling', () => {
const props = createDefaultProps({ message: createTestMessage({ isOutgoing: false }) })
const { container } = render(<MessageBubble {...props} />)

const column = container.querySelector('[data-message-toolbar]')!.parentElement!
expect(column.className).toContain('flex-1')
// Incoming rows have no tint; their content fills the column.
expect(container.querySelector('.message-own-tint')).toBeNull()
const content = column.querySelector('[data-msg-chrome]')!
expect(content.className).toContain('w-full')
})
})

describe('Density spacing', () => {
it('marks group-start rows with the density spacing class', () => {
const groupStartProps = createDefaultProps({ showAvatar: true })
Expand Down
63 changes: 35 additions & 28 deletions apps/fluux/src/components/conversation/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,10 @@ export const MessageBubble = memo(function MessageBubble({
: ''
// 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'
// `max-w-full` still wraps long messages at the available width. The tint box
// sits inside a full-width positioning column (see below), so incoming rows and
// whisper cards fill that column with `w-full`; their layout is unchanged.
const contentWidthClass = ownTint ? 'w-fit max-w-full' : 'w-full'
// 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`
Expand Down Expand Up @@ -554,6 +555,36 @@ export const MessageBubble = memo(function MessageBubble({
mismatch the fill and knock the side borders 8px out of alignment with
the incoming rows — shattering the single "private with X" card. The
name header already carries the own-vs-counterpart distinction. */}
{/* Full-width positioning column. The hover toolbar is anchored here (not
inside the tint box) so it pins to the row's right edge and keeps a
stable position regardless of how narrow the own-message tint hugs its
content. Incoming rows already spanned the full width, so their toolbar
position is unchanged. */}
<div className="relative flex-1 min-w-0">
{/* Floating hover toolbar - hidden when user is composing or message is retracted */}
{!message.isRetracted && (
<MessageToolbar
onReaction={handleReaction}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
myReactions={reactionsEnabled ? myReactions : []}
canReply={canReply}
canEdit={canEdit}
canDelete={canDelete}
isHidden={hideToolbar || false}
isSelected={isSelected || false}
hasKeyboardSelection={hasKeyboardSelection || false}
showToolbarForSelection={showToolbarForSelection || false}
showAvatar={showAvatar}
showReactionPicker={showReactionPicker}
setShowReactionPicker={setShowReactionPicker}
showMoreMenu={showMoreMenu}
setShowMoreMenu={setShowMoreMenu}
isHovered={isHovered}
onToolbarMouseEnter={onMouseEnter}
/>
)}
<div
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}`}
Expand Down Expand Up @@ -581,31 +612,6 @@ export const MessageBubble = memo(function MessageBubble({
<span className="truncate"><NickSentence i18nKey="rooms.whisperThread" nick={whisperWith} /></span>
</button>
))}
{/* Floating hover toolbar - hidden when user is composing or message is retracted */}
{!message.isRetracted && (
<MessageToolbar
onReaction={handleReaction}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
myReactions={reactionsEnabled ? myReactions : []}
canReply={canReply}
canEdit={canEdit}
canDelete={canDelete}
isHidden={hideToolbar || false}
isSelected={isSelected || false}
hasKeyboardSelection={hasKeyboardSelection || false}
showToolbarForSelection={showToolbarForSelection || false}
showAvatar={showAvatar}
showReactionPicker={showReactionPicker}
setShowReactionPicker={setShowReactionPicker}
showMoreMenu={showMoreMenu}
setShowMoreMenu={setShowMoreMenu}
isHovered={isHovered}
onToolbarMouseEnter={onMouseEnter}
/>
)}

{/* Nick header - hidden for /me action messages (nick is shown inline) */}
{showAvatar && !isActionMessage(message.body) && (
<div className="flex items-baseline gap-2 pb-1 flex-wrap">
Expand Down Expand Up @@ -782,6 +788,7 @@ export const MessageBubble = memo(function MessageBubble({
</div>
)}
</div>
</div>

{/* Avatar lightbox overlay */}
{showAvatarLightbox && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// @vitest-environment node
// Pure file-parsing test — the app's default happy-dom env rewrites
// `import.meta.url` to a non-file URL, which breaks fileURLToPath; node env
// keeps it a real file:// URL.
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

// The emphasis-bottom alignment fix is pure CSS (jsdom can't compute it), so we
// guard the invariant by parsing index.css directly. Comments are stripped so a
// prose mention of a property name can never be mistaken for a declaration.
const css = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')

/** Read a single declaration value out of a named rule block. */
function decl(selector: string, prop: string): string | null {
const sel = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const block = new RegExp(`${sel}\\s*\\{([^}]*)\\}`).exec(css)
if (!block) return null
const m = new RegExp(`(?:^|;|\\s)${prop}\\s*:\\s*([^;]+?)\\s*(?:;|$)`).exec(block[1])
return m ? m[1].trim() : null
}

describe('Own-message emphasis bottom alignment', () => {
// Regression: a group-end own message must extend its tint down through the
// row's group-end padding so the emphasis bottom meets the hover-row highlight
// instead of floating a few px above it. It's done with padding-bottom (paints
// the wash lower) plus an EQUAL negative margin-bottom (so the row height — and
// the virtualizer's measurements — stay unchanged). Those values must MIRROR
// .message-group-end exactly; otherwise the emphasis over/under-shoots the
// highlight, or the negative margin no longer cancels the padding and row
// heights drift (breaking the scroll invariants).
it('mirrors .message-group-end padding on .message-own-tint-end (comfortable)', () => {
const gap = decl('.message-group-end', 'padding-bottom')
expect(gap).not.toBeNull()
expect(decl('.message-own-tint-end', 'padding-bottom')).toBe(gap)
expect(decl('.message-own-tint-end', 'margin-bottom')).toBe(`-${gap}`)
})

it('mirrors the compact-density group-end padding on .message-own-tint-end', () => {
const gap = decl('[data-density="compact"] .message-group-end', 'padding-bottom')
expect(gap).not.toBeNull()
expect(decl('[data-density="compact"] .message-own-tint-end', 'padding-bottom')).toBe(gap)
expect(decl('[data-density="compact"] .message-own-tint-end', 'margin-bottom')).toBe(`-${gap}`)
})
})
12 changes: 12 additions & 0 deletions apps/fluux/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,18 @@ input[type="range"]::-moz-range-thumb {
.message-own-tint-end {
border-bottom-left-radius: var(--fluux-radius-m);
border-bottom-right-radius: var(--fluux-radius-m);
/* Extend the group's outer bottom edge down through the row's group-end
padding so the emphasis meets the bottom of the hover-row highlight instead
of floating a few px above it. padding-bottom paints the wash lower; the
matching negative margin-bottom cancels it so the row height (and the
virtualizer's measurements) are untouched. Mirror .message-group-end's
values exactly, including the compact-density override below. */
padding-bottom: 6px;
margin-bottom: -6px;
}
[data-density="compact"] .message-own-tint-end {
padding-bottom: 3px;
margin-bottom: -3px;
}

/* Persistent highlight for search context view */
Expand Down
Loading