wacrm/src/components/inbox/message-actions.tsx
Arnas Donauskas 4aa329b527
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.

- globals.css: split neutrals (mode) from accent (theme); add light
  palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
  (no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
  tokens across the app so every surface flips with mode. Flow canvas
  and message-bubble timestamps made mode-aware too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:29:46 +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-border bg-popover/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-popover-foreground hover:bg-muted hover:text-foreground"
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-muted"
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-popover-foreground hover:bg-muted hover:text-foreground"
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-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}