mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
149 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|