wacrm/src/components/inbox/message-actions.tsx
Arnas Donauskas 13bafa5747
fix(inbox): reply quote no longer overflows or single-lines (#165)
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):

1. ReplyQuote applied `truncate` to the preview text, which sets
   `white-space: nowrap`. Long previews became one impossibly-wide
   line instead of wrapping.

2. The flex chain from MessageBubble outward (MessageActions →
   middle-panel wrapper → page) had no `min-w-0` on its flex
   children. Default `min-width: auto` means a flex child grows to
   its content's natural width, so the long quote propagated all the
   way up to the inbox-page flex container and pushed the contact
   sidebar past the viewport.

Fix:
  - ReplyQuote preview now uses `whitespace-pre-wrap break-words`
    instead of `truncate`. The author label stays single-line (it's
    a name, ellipsis is right for it). `overflow-hidden` on the
    inner text container as belt-and-braces.
  - MessageActions' .group/actions wrapper gets `min-w-0` so the
    `max-w-[75%]` cap actually holds even with wide content.
  - inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
    MessageThread can shrink below its natural content width when
    the conversation list and contact sidebar are also flexing.

Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.

Reported and diagnosed by @tekrexllp in #165.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:28:49 +03:00

149 lines
4.8 KiB
TypeScript

"use client";
import { useState, type ReactNode } from "react";
import { CornerUpLeft, Copy, SmilePlus } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Message } from "@/types";
// WhatsApp's own quick-reaction bar starts with these six. Picking the same
// set keeps the affordance familiar without pulling in a 300KB emoji library.
const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"];
interface MessageActionsProps {
message: Message;
onReply: () => void;
onReact: (emoji: string) => void;
children: ReactNode;
}
/**
* Hover/long-press toolbar wrapper around a `<MessageBubble>`. The bubble
* itself stays a pure presenter — this component owns the action surface so
* the bubble's render path is unaffected when the toolbar isn't visible.
*/
export function MessageActions({
message,
onReply,
onReact,
children,
}: MessageActionsProps) {
// Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere.
const [touchOpen, setTouchOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const isAgent =
message.sender_type === "agent" || message.sender_type === "bot";
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setTouchOpen(true);
};
const handleCopy = async () => {
const text = message.content_text ?? "";
if (!text) {
toast.error("Nothing to copy");
return;
}
try {
await navigator.clipboard.writeText(text);
toast.success("Copied");
} catch {
toast.error("Copy failed");
}
setTouchOpen(false);
};
const handlePickEmoji = (emoji: string) => {
onReact(emoji);
setPickerOpen(false);
setTouchOpen(false);
};
const handleReply = () => {
onReply();
setTouchOpen(false);
};
// Row alignment lives here (not in MessageBubble) so the `group/actions`
// hover region matches the bubble's content width — hovering empty space
// in the row no longer reveals the toolbar.
return (
<div
className={cn(
"flex w-full",
isAgent ? "justify-end" : "justify-start",
)}
onContextMenu={handleContextMenu}
onBlur={() => setTouchOpen(false)}
>
{/* `min-w-0` lets this flex child actually respect the 75% cap.
* Default `min-width: auto` lets content (a long quote preview,
* an unbroken URL) push past the cap and shove the row past
* 100%, which used to bleed across into the contact-sidebar
* area. See issue #165. */}
<div className="group/actions relative min-w-0 max-w-[75%]">
{children}
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3",
)}
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="React"
>
<SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
className="flex w-auto flex-row gap-1 p-1.5"
sideOffset={6}
>
{QUICK_EMOJIS.map((e) => (
<button
key={e}
type="button"
onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
aria-label={`React with ${e}`}
>
{e}
</button>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Reply"
>
<CornerUpLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}