mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(inbox): collapsible contact sidebar on desktop
Add a desktop-only toggle in the message-thread header to show/hide the contact sidebar (tags, deals, notes). On smaller laptops the always-on panel crowds the message thread; agents who only want to read and reply can now reclaim that horizontal space. The page owns the open/closed state and conditionally renders the sidebar; the thread renders the toggle button and reflects the state. The choice is persisted to localStorage (device-scoped, like the theme prefs) so it survives reloads. To avoid a hydration mismatch we default to open on the server and reconcile from storage in an effect after mount. Mobile is unchanged — the sidebar is never a permanent panel there and the toggle is hidden below lg. Closes #258 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c2fee3f2c5
commit
9b32954d1a
@ -12,6 +12,10 @@ import { toast } from "sonner";
|
|||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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() {
|
export default function InboxPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -39,6 +43,36 @@ export default function InboxPage() {
|
|||||||
*/
|
*/
|
||||||
const [resyncToken, setResyncToken] = useState(0);
|
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
|
// Fire the deep-link auto-select exactly once per URL — subsequent
|
||||||
// list refreshes (realtime, manual refetch) must not snap the user
|
// list refreshes (realtime, manual refetch) must not snap the user
|
||||||
// back to the deep-linked conversation if they've already clicked
|
// back to the deep-linked conversation if they've already clicked
|
||||||
@ -570,13 +604,20 @@ export default function InboxPage() {
|
|||||||
onBack={handleCloseConversation}
|
onBack={handleCloseConversation}
|
||||||
resyncToken={resyncToken}
|
resyncToken={resyncToken}
|
||||||
onRefresh={handleManualRefresh}
|
onRefresh={handleManualRefresh}
|
||||||
|
contactPanelOpen={contactPanelOpen}
|
||||||
|
onToggleContactPanel={handleToggleContactPanel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: Contact sidebar — desktop only. */}
|
{/* 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">
|
<div className="hidden lg:block">
|
||||||
<ContactSidebar contact={activeContact} />
|
<ContactSidebar contact={activeContact} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
PanelRightOpen,
|
||||||
|
PanelRightClose,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -92,6 +94,15 @@ interface MessageThreadProps {
|
|||||||
* working; the button is only rendered when this is provided.
|
* working; the button is only rendered when this is provided.
|
||||||
*/
|
*/
|
||||||
onRefresh?: () => void;
|
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 {
|
function formatDateSeparator(dateStr: string): string {
|
||||||
@ -148,6 +159,8 @@ export function MessageThread({
|
|||||||
onBack,
|
onBack,
|
||||||
resyncToken = 0,
|
resyncToken = 0,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
contactPanelOpen,
|
||||||
|
onToggleContactPanel,
|
||||||
}: MessageThreadProps) {
|
}: MessageThreadProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -839,6 +852,33 @@ export function MessageThread({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
{/* Manual refresh — forces a refetch of the messages + the
|
||||||
conversation list (the parent bumps its resyncToken). Useful
|
conversation list (the parent bumps its resyncToken). Useful
|
||||||
when realtime missed an event or the agent just wants to be
|
when realtime missed an event or the agent just wants to be
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user