diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 3de6a5b..ce04777 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -48,6 +48,8 @@ interface ChatViewerProps { protocol: ChatProtocol; identifier: ProtocolIdentifier; customTitle?: string; + /** Optional content to render before the title (e.g., sidebar toggle on mobile) */ + headerPrefix?: React.ReactNode; } /** @@ -317,6 +319,7 @@ export function ChatViewer({ protocol, identifier, customTitle, + headerPrefix, }: ChatViewerProps) { const { addWindow } = useGrimoire(); @@ -667,9 +670,10 @@ export function ChatViewer({ return (
{/* Header with conversation info and controls */} -
+
+ {headerPrefix} diff --git a/src/components/GroupListViewer.tsx b/src/components/GroupListViewer.tsx index 085404c..79b52df 100644 --- a/src/components/GroupListViewer.tsx +++ b/src/components/GroupListViewer.tsx @@ -1,7 +1,7 @@ -import { useState, useMemo, memo, useCallback } from "react"; +import { useState, useMemo, memo, useCallback, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; import { map } from "rxjs/operators"; -import { Loader2 } from "lucide-react"; +import { Loader2, PanelLeft } from "lucide-react"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import accountManager from "@/services/accounts"; @@ -11,9 +11,29 @@ import type { NostrEvent } from "@/types/nostr"; import type { ProtocolIdentifier, GroupListIdentifier } from "@/types/chat"; import { cn } from "@/lib/utils"; import Timestamp from "./Timestamp"; -import { useEffect } from "react"; import { UserName } from "./nostr/UserName"; import { RichText } from "./nostr/RichText"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; + +const MOBILE_BREAKPOINT = 768; + +function useIsMobile() { + const [isMobile, setIsMobile] = useState(undefined); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isMobile; +} interface GroupInfo { groupId: string; @@ -103,9 +123,11 @@ const MemoizedChatViewer = memo( function MemoizedChatViewer({ groupId, relayUrl, + headerPrefix, }: { groupId: string; relayUrl: string; + headerPrefix?: React.ReactNode; }) { return ( ); }, // Custom comparison: only re-render if group actually changed + // Note: headerPrefix is intentionally excluded - it's expected to be stable or change with isMobile (prev, next) => prev.groupId === next.groupId && prev.relayUrl === next.relayUrl, ); @@ -149,16 +173,33 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { const targetIdentifier = identifier?.value.identifier || ""; // Empty string is default d-tag for kind 10009 const targetRelays = identifier?.relays; + // Mobile detection + const isMobile = useIsMobile(); + // State for selected group const [selectedGroup, setSelectedGroup] = useState<{ groupId: string; relayUrl: string; } | null>(null); - // State for sidebar width + // State for mobile sidebar sheet + const [sidebarOpen, setSidebarOpen] = useState(false); + + // State for sidebar width (desktop only) const [sidebarWidth, setSidebarWidth] = useState(280); const [isResizing, setIsResizing] = useState(false); + // Handle group selection - close sidebar on mobile + const handleGroupSelect = useCallback( + (group: { groupId: string; relayUrl: string }) => { + setSelectedGroup(group); + if (isMobile) { + setSidebarOpen(false); + } + }, + [isMobile], + ); + // Handle resize with proper cleanup const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -410,6 +451,86 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { ); } + // Group list content - reused in both mobile sheet and desktop sidebar + const groupListContent = ( +
+ {groupsWithRecency.map((group) => ( + + handleGroupSelect({ + groupId: group.groupId, + relayUrl: group.relayUrl, + }) + } + /> + ))} +
+ ); + + // Sidebar toggle button for mobile - passed to ChatViewer's headerPrefix + const sidebarToggle = isMobile ? ( + + ) : null; + + // Chat view content + const chatContent = selectedGroup ? ( + + ) : ( +
+ {isMobile ? ( + + ) : ( + "Select a group to view chat" + )} +
+ ); + + // Mobile layout: Sheet-based sidebar + if (isMobile) { + return ( +
+ {/* Mobile sheet sidebar */} + + + + Groups + +
{groupListContent}
+
+
+ + {/* Chat content - takes full height, sidebar toggle is in ChatViewer header */} +
{chatContent}
+
+ ); + } + + // Desktop layout: Resizable sidebar return (
{/* Left sidebar: Group list */} @@ -417,24 +538,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { className="flex flex-col border-r bg-background" style={{ width: sidebarWidth }} > -
- {groupsWithRecency.map((group) => ( - - setSelectedGroup({ - groupId: group.groupId, - relayUrl: group.relayUrl, - }) - } - /> - ))} -
+ {groupListContent} {/* Resize handle */} @@ -447,18 +551,7 @@ export function GroupListViewer({ identifier }: GroupListViewerProps) { /> {/* Right panel: Chat view */} -
- {selectedGroup ? ( - - ) : ( -
- Select a group to view chat -
- )} -
+
{chatContent}
); } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..6c8b6f0 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface SeparatorProps extends React.HTMLAttributes { + orientation?: "horizontal" | "vertical"; + decorative?: boolean; +} + +const Separator = React.forwardRef( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( +
+ ), +); +Separator.displayName = "Separator"; + +export { Separator }; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..3cb478b --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends + React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..1686be5 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,535 @@ +import * as React from "react"; +import { PanelLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Separator } from "@/components/ui/separator"; + +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const MOBILE_BREAKPOINT = 768; + +type SidebarState = "expanded" | "collapsed"; + +type SidebarContext = { + state: SidebarState; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + return context; +} + +function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined, + ); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref, + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "b" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + ], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref, + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); + }, +); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +