Merge pull request #49 from ArnasDon/feat/mobile-inbox-single-pane

feat(mobile): inbox single-pane flow on phone, unchanged on desktop
This commit is contained in:
Arnas Donauskas 2026-04-19 12:55:21 +03:00 committed by GitHub
commit a7dec71589
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 94 additions and 28 deletions

View File

@ -10,6 +10,7 @@ import { MessageThread } from "@/components/inbox/message-thread";
import { ContactSidebar } from "@/components/inbox/contact-sidebar"; import { ContactSidebar } from "@/components/inbox/contact-sidebar";
import { toast } from "sonner"; import { toast } from "sonner";
import { WifiOff } from "lucide-react"; import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
export default function InboxPage() { export default function InboxPage() {
const router = useRouter(); const router = useRouter();
@ -191,6 +192,19 @@ export default function InboxPage() {
[activeConversation?.id, router] [activeConversation?.id, router]
); );
// Mobile "back" — deselect the conversation so the list pane comes
// back. Also clears the ?c= param so a refresh lands on the list
// instead of re-opening the thread the user just backed out of.
const handleCloseConversation = useCallback(() => {
setActiveConversation(null);
setActiveContact(null);
setMessages([]);
// Clearing the ref lets the deep-link auto-selector fire again if
// the user later visits /inbox?c=<same-id> — desirable UX.
autoSelectedForDeepLinkRef.current = null;
router.replace("/inbox", { scroll: false });
}, [router]);
const handleMessagesLoaded = useCallback((loaded: Message[]) => { const handleMessagesLoaded = useCallback((loaded: Message[]) => {
setMessages(loaded); setMessages(loaded);
@ -224,8 +238,15 @@ export default function InboxPage() {
[activeConversation] [activeConversation]
); );
// On mobile (<lg) we show a SINGLE pane — either the list or the
// thread — rather than cramming both side-by-side. Selecting a
// conversation slides the thread in; the thread's back button pops
// it back to the list. On lg+ both panes render side-by-side as
// before, unchanged.
const hasActiveConv = !!activeConversation;
return ( return (
<div className="-m-6 flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden"> <div className="-m-4 flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden sm:-m-6">
{/* WhatsApp connection banner — in the flex column, not absolute, {/* WhatsApp connection banner — in the flex column, not absolute,
so it pushes the panels down instead of overlapping them. */} so it pushes the panels down instead of overlapping them. */}
{whatsappConnected === false && ( {whatsappConnected === false && (
@ -238,15 +259,33 @@ export default function InboxPage() {
)} )}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left panel: Conversation list */} {/* Left panel: Conversation list.
Hidden on mobile when a conversation is selected so the
thread can occupy the full width. Always visible on lg+. */}
<div
className={cn(
"flex h-full flex-1 lg:flex-none",
hasActiveConv ? "hidden lg:flex" : "flex",
)}
>
<ConversationList <ConversationList
activeConversationId={activeConversation?.id ?? null} activeConversationId={activeConversation?.id ?? null}
onSelect={handleSelectConversation} onSelect={handleSelectConversation}
conversations={conversations} conversations={conversations}
onConversationsLoaded={handleConversationsLoaded} onConversationsLoaded={handleConversationsLoaded}
/> />
</div>
{/* Center panel: Message thread */} {/* Center panel: Message thread.
Hidden on mobile when no conversation is selected so the
list can occupy the full width. Always visible on lg+
(shows its own empty-state if no thread is picked yet). */}
<div
className={cn(
"flex h-full flex-1 lg:flex",
hasActiveConv ? "flex" : "hidden lg:flex",
)}
>
<MessageThread <MessageThread
conversation={activeConversation} conversation={activeConversation}
contact={activeContact} contact={activeContact}
@ -255,9 +294,11 @@ export default function InboxPage() {
onNewMessage={handleNewMessage} onNewMessage={handleNewMessage}
onUpdateMessage={handleUpdateMessage} onUpdateMessage={handleUpdateMessage}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onBack={handleCloseConversation}
/> />
</div>
{/* Right panel: Contact sidebar (hidden on small screens) */} {/* Right panel: Contact sidebar — desktop only. */}
<div className="hidden lg:block"> <div className="hidden lg:block">
<ContactSidebar contact={activeContact} /> <ContactSidebar contact={activeContact} />
</div> </div>

View File

@ -110,7 +110,10 @@ export function ConversationList({
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter); const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
return ( return (
<div className="flex h-full w-80 flex-col border-r border-slate-800 bg-slate-900"> // w-full on mobile so the list occupies the whole viewport when it's
// the single pane showing; fixed 320px on desktop where it shares the
// row with the thread + contact sidebar.
<div className="flex h-full w-full flex-col border-r border-slate-800 bg-slate-900 lg:w-80">
{/* Search + Filter */} {/* Search + Filter */}
<div className="space-y-2 border-b border-slate-800 p-3"> <div className="space-y-2 border-b border-slate-800 p-3">
<div className="relative"> <div className="relative">

View File

@ -9,6 +9,7 @@ import {
ChevronDown, ChevronDown,
UserPlus, UserPlus,
Clock, Clock,
ArrowLeft,
} 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";
@ -32,6 +33,13 @@ interface MessageThreadProps {
onNewMessage: (message: Message) => void; onNewMessage: (message: Message) => void;
onUpdateMessage: (id: string, updates: Partial<Message>) => void; onUpdateMessage: (id: string, updates: Partial<Message>) => void;
onStatusChange: (conversationId: string, status: ConversationStatus) => void; onStatusChange: (conversationId: string, status: ConversationStatus) => void;
/**
* On mobile, the thread is shown full-screen with the conversation list
* hidden. This callback lets the page deselect the active conversation
* and reveal the list again. Rendered as a back-arrow in the header on
* mobile only.
*/
onBack?: () => void;
} }
function formatDateSeparator(dateStr: string): string { function formatDateSeparator(dateStr: string): string {
@ -72,6 +80,7 @@ export function MessageThread({
onNewMessage, onNewMessage,
onUpdateMessage, onUpdateMessage,
onStatusChange, onStatusChange,
onBack,
}: MessageThreadProps) { }: MessageThreadProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -278,20 +287,33 @@ export function MessageThread({
return ( return (
<div className="flex flex-1 flex-col bg-slate-950"> <div className="flex flex-1 flex-col bg-slate-950">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3"> <div className="flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-900 px-3 py-3 sm:px-4">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-2 sm:gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white"> {/* Back-to-list button — mobile only. Hidden on lg+ where the
conversation list is always visible next to the thread. */}
{onBack && (
<button
type="button"
onClick={onBack}
aria-label="Back to conversations"
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-300 hover:bg-slate-800 hover:text-white lg:hidden"
>
<ArrowLeft className="h-5 w-5" />
</button>
)}
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
{displayName.charAt(0).toUpperCase()} {displayName.charAt(0).toUpperCase()}
</div> </div>
<div> <div className="min-w-0">
<h2 className="text-sm font-semibold text-white">{displayName}</h2> <h2 className="truncate text-sm font-semibold text-white">{displayName}</h2>
<p className="text-xs text-slate-400">{contact.phone}</p> <p className="truncate text-xs text-slate-400">{contact.phone}</p>
</div> </div>
{/* Session timer badge */} {/* Session timer badge — hidden on the narrowest phones so
the name + back arrow keep their room. */}
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"ml-2 gap-1 border-slate-700 text-[10px]", "ml-1 hidden gap-1 border-slate-700 text-[10px] sm:inline-flex sm:ml-2",
sessionInfo.expired ? "text-red-400" : "text-emerald-400" sessionInfo.expired ? "text-red-400" : "text-emerald-400"
)} )}
> >