mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #267 from ArnasDon/feat/inbox-collapsible-contact-sidebar
feat(inbox): collapsible contact sidebar on desktop
This commit is contained in:
commit
85c2513a3a
@ -12,6 +12,10 @@ import { toast } from "sonner";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Remembers the agent's show/hide choice for the desktop contact panel
|
||||
// across reloads and sessions (device-scoped, like the theme prefs).
|
||||
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
|
||||
|
||||
export default function InboxPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@ -39,6 +43,36 @@ export default function InboxPage() {
|
||||
*/
|
||||
const [resyncToken, setResyncToken] = useState(0);
|
||||
|
||||
/**
|
||||
* Whether the desktop contact sidebar (tags / deals / notes) is shown.
|
||||
* Defaults to `true` (the historical behaviour) and is restored from
|
||||
* localStorage after mount. We deliberately do NOT read localStorage in
|
||||
* the initializer: the server renders with `true`, so reading a stored
|
||||
* `false` synchronously would produce a hydration mismatch. The effect
|
||||
* below reconciles to the stored value right after mount instead.
|
||||
*/
|
||||
const [contactPanelOpen, setContactPanelOpen] = useState(true);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY);
|
||||
if (stored !== null) setContactPanelOpen(stored === "true");
|
||||
} catch {
|
||||
// localStorage can throw in private-browsing / sandboxed contexts.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleContactPanel = useCallback(() => {
|
||||
setContactPanelOpen((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// Persistence is best-effort; ignore storage failures.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fire the deep-link auto-select exactly once per URL — subsequent
|
||||
// list refreshes (realtime, manual refetch) must not snap the user
|
||||
// back to the deep-linked conversation if they've already clicked
|
||||
@ -570,13 +604,20 @@ export default function InboxPage() {
|
||||
onBack={handleCloseConversation}
|
||||
resyncToken={resyncToken}
|
||||
onRefresh={handleManualRefresh}
|
||||
contactPanelOpen={contactPanelOpen}
|
||||
onToggleContactPanel={handleToggleContactPanel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Contact sidebar — desktop only. */}
|
||||
<div className="hidden lg:block">
|
||||
<ContactSidebar contact={activeContact} />
|
||||
</div>
|
||||
{/* Right panel: Contact sidebar — desktop only, and only when the
|
||||
agent hasn't collapsed it via the thread-header toggle (#258).
|
||||
On mobile it's always hidden (the `lg:block` below), so the
|
||||
toggle — which is itself desktop-only — never affects it. */}
|
||||
{contactPanelOpen && (
|
||||
<div className="hidden lg:block">
|
||||
<ContactSidebar contact={activeContact} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -21,6 +21,8 @@ import {
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
PanelRightOpen,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -92,6 +94,15 @@ interface MessageThreadProps {
|
||||
* working; the button is only rendered when this is provided.
|
||||
*/
|
||||
onRefresh?: () => void;
|
||||
/**
|
||||
* Desktop-only contact-panel toggle. The page owns the open/closed
|
||||
* state (it's the one that renders the sidebar), so the thread just
|
||||
* reflects it and asks the page to flip it. Both optional so existing
|
||||
* callers keep working; the toggle button only renders when
|
||||
* `onToggleContactPanel` is wired up.
|
||||
*/
|
||||
contactPanelOpen?: boolean;
|
||||
onToggleContactPanel?: () => void;
|
||||
}
|
||||
|
||||
function formatDateSeparator(dateStr: string): string {
|
||||
@ -148,6 +159,8 @@ export function MessageThread({
|
||||
onBack,
|
||||
resyncToken = 0,
|
||||
onRefresh,
|
||||
contactPanelOpen,
|
||||
onToggleContactPanel,
|
||||
}: MessageThreadProps) {
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -839,6 +852,33 @@ export function MessageThread({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Contact-panel toggle — desktop only. The contact sidebar
|
||||
eats a chunk of horizontal width that crowds the thread on
|
||||
smaller laptops; this lets agents reclaim it when they just
|
||||
want to read and reply. Hidden on mobile, where the sidebar
|
||||
never renders as a permanent panel anyway. Issue #258. */}
|
||||
{onToggleContactPanel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContactPanel}
|
||||
aria-label={
|
||||
contactPanelOpen ? "Hide contact panel" : "Show contact panel"
|
||||
}
|
||||
aria-pressed={contactPanelOpen}
|
||||
title={contactPanelOpen ? "Hide contact" : "Show contact"}
|
||||
className={cn(
|
||||
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
|
||||
contactPanelOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{contactPanelOpen ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Manual refresh — forces a refetch of the messages + the
|
||||
conversation list (the parent bumps its resyncToken). Useful
|
||||
when realtime missed an event or the agent just wants to be
|
||||
|
||||
Loading…
Reference in New Issue
Block a user