352 lines
17 KiB
TypeScript
352 lines
17 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { getAvatarShape } from '@/lib/avatarShape';
|
|
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
|
import { SidebarNavList } from '@/components/SidebarNavItem';
|
|
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
|
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { LinkFooter } from '@/components/LinkFooter';
|
|
import { EmojifiedText } from '@/components/CustomEmoji';
|
|
import LoginDialog from '@/components/auth/LoginDialog';
|
|
import { useOnboarding } from '@/hooks/useOnboarding';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
|
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
|
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { getSidebarItem, isItemActive } from '@/lib/sidebarItems';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useUserStatus } from '@/hooks/useUserStatus';
|
|
import { usePublishStatus } from '@/hooks/usePublishStatus';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { Input } from '@/components/ui/input';
|
|
import { resolveTheme, resolveThemeConfig } from '@/themes';
|
|
|
|
interface MobileDrawerProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { user, metadata, event: currentUserEvent } = useCurrentUser();
|
|
const currentUserAvatarShape = getAvatarShape(metadata);
|
|
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
|
const { logout } = useLoginActions();
|
|
const { otherUsers, setLogin } = useLoggedInAccounts();
|
|
const { orderedItems, hiddenItems, addToSidebar, addDividerToSidebar, removeFromSidebar, updateSidebarOrder } = useFeedSettings();
|
|
const { config } = useAppContext();
|
|
const homePage = config.homePage;
|
|
const hasUnread = useHasUnreadNotifications();
|
|
const [editing, setEditing] = useState(false);
|
|
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
|
const [accountExpanded, setAccountExpanded] = useState(false);
|
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
|
const { startSignup } = useOnboarding();
|
|
const { theme, customTheme, themes } = useTheme();
|
|
|
|
// NIP-38 status
|
|
const userStatus = useUserStatus(user?.pubkey);
|
|
const publishStatus = usePublishStatus();
|
|
const { toast } = useToast();
|
|
const [statusEditing, setStatusEditing] = useState(false);
|
|
const [statusDraft, setStatusDraft] = useState('');
|
|
|
|
/** Compute the background image style for the drawer, mirroring the body background. */
|
|
const bgStyle = useMemo<React.CSSProperties>(() => {
|
|
const resolved = resolveTheme(theme);
|
|
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
|
|
const bgUrl = activeConfig?.background?.url;
|
|
if (!bgUrl) return {};
|
|
const bgMode = activeConfig?.background?.mode ?? 'cover';
|
|
if (bgMode === 'tile') {
|
|
return { backgroundColor: 'transparent', backgroundImage: `url("${bgUrl}")`, backgroundRepeat: 'repeat', backgroundSize: 'auto' };
|
|
}
|
|
return { backgroundColor: 'transparent', backgroundImage: `url("${bgUrl}")`, backgroundSize: 'cover', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' };
|
|
}, [theme, customTheme, themes]);
|
|
|
|
const hasBgImage = Object.keys(bgStyle).length > 0;
|
|
|
|
const visibleItems = useMemo(() => {
|
|
const filtered = user ? orderedItems : orderedItems.filter((id) => !getSidebarItem(id)?.requiresAuth);
|
|
// Remove dividers that have no real items above them (at the top or right after another divider).
|
|
return filtered.filter((id, i) => {
|
|
if (id !== 'divider') return true;
|
|
const prevNonDivider = filtered.slice(0, i).some((prev) => prev !== 'divider');
|
|
return prevNonDivider;
|
|
});
|
|
}, [orderedItems, user]);
|
|
|
|
const visibleHiddenItems = useMemo(() => {
|
|
if (user) return hiddenItems;
|
|
return hiddenItems.filter((item) => !getSidebarItem(item.id)?.requiresAuth);
|
|
}, [hiddenItems, user]);
|
|
|
|
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
|
|
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
|
|
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
|
const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous');
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
|
|
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col" style={bgStyle}>
|
|
{hasBgImage && <div className="absolute inset-0 bg-background/70 pointer-events-none" />}
|
|
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
|
|
|
{user ? (
|
|
<div className="flex flex-col h-full relative">
|
|
{/* User row with caret */}
|
|
<button
|
|
onClick={() => setAccountExpanded((v) => !v)}
|
|
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
|
|
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
|
>
|
|
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
|
{displayName[0].toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<span className="font-semibold text-sm truncate">
|
|
{currentUserEvent && metadata?.name
|
|
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
|
|
: displayName}
|
|
</span>
|
|
{metadata?.nip05 && (
|
|
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
|
|
)}
|
|
</div>
|
|
{accountExpanded
|
|
? <ChevronUp className="size-4 text-muted-foreground shrink-0 mr-1" />
|
|
: <ChevronDown className="size-4 text-muted-foreground shrink-0 mr-1" />
|
|
}
|
|
</button>
|
|
|
|
{/* Expanded account actions */}
|
|
{accountExpanded && (
|
|
<div>
|
|
{/* Status editor */}
|
|
<div className="border-b border-border">
|
|
{statusEditing ? (
|
|
<div className="px-3 py-2 space-y-2">
|
|
<Input
|
|
value={statusDraft}
|
|
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
|
|
placeholder="What are you up to?"
|
|
className="h-8 text-base md:text-sm"
|
|
maxLength={80}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const text = statusDraft.trim();
|
|
publishStatus.mutateAsync({ status: text }).then(() => {
|
|
setStatusEditing(false);
|
|
setStatusDraft('');
|
|
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
|
});
|
|
} else if (e.key === 'Escape') {
|
|
setStatusEditing(false);
|
|
setStatusDraft('');
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => {
|
|
const text = statusDraft.trim();
|
|
publishStatus.mutateAsync({ status: text }).then(() => {
|
|
setStatusEditing(false);
|
|
setStatusDraft('');
|
|
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
|
});
|
|
}}
|
|
disabled={publishStatus.isPending}
|
|
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
|
>
|
|
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
|
</button>
|
|
{userStatus.status && (
|
|
<button
|
|
onClick={() => {
|
|
publishStatus.mutateAsync({ status: '' }).then(() => {
|
|
setStatusEditing(false);
|
|
setStatusDraft('');
|
|
toast({ title: 'Status cleared' });
|
|
});
|
|
}}
|
|
disabled={publishStatus.isPending}
|
|
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
|
|
className="text-xs text-muted-foreground hover:underline ml-auto"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setStatusEditing(true);
|
|
setStatusDraft(userStatus.status ?? '');
|
|
}}
|
|
className="flex items-center gap-3 w-full px-3 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
|
|
>
|
|
{userStatus.status ? (
|
|
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Set a status</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{otherUsers.map((account) => (
|
|
<button
|
|
key={account.id}
|
|
onClick={() => { setLogin(account.id); handleClose(); }}
|
|
className="flex items-center gap-3 w-full px-3 py-2 hover:bg-secondary/60 transition-colors"
|
|
>
|
|
<Avatar shape={getAvatarShape(account.metadata)} className="size-7 shrink-0">
|
|
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
|
{getDisplayName(account).charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col min-w-0">
|
|
<span className="text-sm font-medium truncate">
|
|
{account.event
|
|
? <EmojifiedText tags={account.event.tags}>{getDisplayName(account)}</EmojifiedText>
|
|
: getDisplayName(account)}
|
|
</span>
|
|
{account.metadata.nip05 && (
|
|
<VerifiedNip05Text nip05={account.metadata.nip05} pubkey={account.pubkey} className="text-xs text-muted-foreground truncate" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
|
|
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
|
>
|
|
<UserPlus className="size-5 shrink-0" />
|
|
<span>Add another account</span>
|
|
</button>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-destructive hover:bg-destructive/10 transition-colors"
|
|
>
|
|
<LogOut className="size-5 shrink-0" />
|
|
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Nav items — scrollable */}
|
|
<nav
|
|
className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1"
|
|
>
|
|
<div className="contents">
|
|
<SidebarNavList
|
|
items={visibleItems}
|
|
editing={editing}
|
|
onRemove={removeFromSidebar}
|
|
onReorder={updateSidebarOrder}
|
|
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
|
getOnClick={() => handleClose}
|
|
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
|
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
|
linkClassName="text-base"
|
|
homePage={homePage}
|
|
/>
|
|
<SidebarMoreMenu
|
|
editing={editing}
|
|
hiddenItems={visibleHiddenItems}
|
|
onDoneEditing={() => setEditing(false)}
|
|
onStartEditing={() => setEditing(true)}
|
|
onAdd={addToSidebar}
|
|
onAddDivider={addDividerToSidebar}
|
|
onNavigate={handleClose}
|
|
open={moreMenuOpen}
|
|
onOpenChange={setMoreMenuOpen}
|
|
homePage={homePage}
|
|
/>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="px-2">
|
|
<LinkFooter />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col h-full relative">
|
|
{/* Login prompt */}
|
|
<div
|
|
className="flex items-center gap-3 px-4 border-b border-border"
|
|
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
|
>
|
|
<LoginArea className="w-full flex" />
|
|
</div>
|
|
|
|
{/* Nav items — scrollable */}
|
|
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1">
|
|
<div className="contents">
|
|
<SidebarNavList
|
|
items={visibleItems}
|
|
editing={false}
|
|
onRemove={removeFromSidebar}
|
|
onReorder={updateSidebarOrder}
|
|
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
|
getOnClick={() => handleClose}
|
|
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
|
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
|
linkClassName="text-base"
|
|
homePage={homePage}
|
|
/>
|
|
<SidebarMoreMenu
|
|
editing={false}
|
|
hiddenItems={visibleHiddenItems}
|
|
onDoneEditing={() => setEditing(false)}
|
|
onStartEditing={() => setEditing(true)}
|
|
onAdd={addToSidebar}
|
|
onAddDivider={addDividerToSidebar}
|
|
onNavigate={handleClose}
|
|
open={moreMenuOpen}
|
|
onOpenChange={setMoreMenuOpen}
|
|
homePage={homePage}
|
|
/>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="px-2">
|
|
<LinkFooter />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<LoginDialog
|
|
isOpen={loginDialogOpen}
|
|
onClose={() => setLoginDialogOpen(false)}
|
|
onLogin={() => setLoginDialogOpen(false)}
|
|
onSignupClick={startSignup}
|
|
/>
|
|
</>
|
|
);
|
|
}
|