mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: show notifications, the command palette and the account avatar in every app's phone header
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons";
|
||||
import { startOfWeek } from "date-fns";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
@@ -210,6 +211,7 @@ export function CalendarToolbar({
|
||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
<PhoneHeaderActions below="md" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: View switcher pills + calendar toggle */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContactExportBusy } from "./contact-export";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/stores/contact-store";
|
||||
@@ -352,6 +353,7 @@ export function ContactList({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<PhoneHeaderActions below="md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -333,6 +334,7 @@ export function FilesHeader({
|
||||
</Button>
|
||||
)}
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
<PhoneHeaderActions below="md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The phone header cluster (⋮ with notifications and the command palette,
|
||||
// then the account avatar) that every app shows at phone width.
|
||||
|
||||
const media = vi.hoisted(() => ({ mobile: true, desktop: false }));
|
||||
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
|
||||
useIsMobile: () => media.mobile,
|
||||
useIsDesktop: () => media.desktop,
|
||||
}));
|
||||
|
||||
const { PhoneHeaderActions, PhoneAppHeader } = await import('../phone-header-actions');
|
||||
const { ContactList } = await import('@/components/contacts/contact-list');
|
||||
const { ContextMenuItem } = await import('@/components/ui/context-menu');
|
||||
|
||||
beforeEach(() => {
|
||||
media.mobile = true;
|
||||
media.desktop = false;
|
||||
});
|
||||
|
||||
describe('PhoneHeaderActions', () => {
|
||||
it('shows the menu and the account avatar on a phone', () => {
|
||||
render(<PhoneHeaderActions />);
|
||||
expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more' }));
|
||||
// Notifications and the command palette (both "title" as keys in tests).
|
||||
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title', 'title']);
|
||||
});
|
||||
|
||||
it('opens the command palette from the menu', () => {
|
||||
const opened = vi.fn();
|
||||
window.addEventListener('bulwark:command-palette', opened);
|
||||
render(<PhoneHeaderActions />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more' }));
|
||||
fireEvent.click(screen.getAllByRole('menuitem')[1]);
|
||||
expect(opened).toHaveBeenCalled();
|
||||
window.removeEventListener('bulwark:command-palette', opened);
|
||||
});
|
||||
|
||||
it('lists the app entries first and closes the menu when one is picked', () => {
|
||||
const run = vi.fn();
|
||||
render(
|
||||
<PhoneHeaderActions
|
||||
menuItems={(close) => <ContextMenuItem label="Import" onClick={() => { close(); run(); }} />}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more' }));
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
expect(items[0]).toHaveTextContent('Import');
|
||||
fireEvent.click(items[0]);
|
||||
expect(run).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing where the rail holds these controls', () => {
|
||||
media.desktop = true;
|
||||
media.mobile = false;
|
||||
const { container } = render(<PhoneHeaderActions />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('draws nothing on a tablet for an app that keeps the rail there', () => {
|
||||
media.mobile = false;
|
||||
const { container } = render(<PhoneHeaderActions below="md" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<PhoneHeaderActions />);
|
||||
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhoneAppHeader', () => {
|
||||
it('gives a screen without a header a title and the cluster', () => {
|
||||
render(<PhoneAppHeader title="Kanban" below="md" />);
|
||||
expect(screen.getByRole('heading', { name: 'Kanban' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apps use it', () => {
|
||||
it('puts the cluster in the Contacts list header on a phone', () => {
|
||||
render(
|
||||
<ContactList
|
||||
contacts={[]}
|
||||
selectedContactId={null}
|
||||
searchQuery=""
|
||||
onSearchChange={vi.fn()}
|
||||
onSelectContact={vi.fn()}
|
||||
onCreateNew={vi.fn()}
|
||||
selectedContactIds={new Set<string>()}
|
||||
onToggleSelection={vi.fn()}
|
||||
onSelectRangeContacts={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onClearSelection={vi.fn()}
|
||||
onBulkDelete={vi.fn()}
|
||||
onBulkAddToGroup={vi.fn()}
|
||||
onBulkExport={vi.fn()}
|
||||
onEditContact={vi.fn()}
|
||||
onDeleteContact={vi.fn()}
|
||||
onAddContactToGroup={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
|
||||
import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
@@ -10,84 +9,7 @@ import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { openCommandPalette } from "@/lib/app-commands";
|
||||
import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center";
|
||||
|
||||
/** Plugin buttons in the top bar are not about any one item. */
|
||||
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* The phone header's overflow menu: the notifications (the desktop bell), the
|
||||
* command palette (no Ctrl+K on a phone) and what the desktop top bar shows
|
||||
* as buttons (plugin top-bar-actions) and has no room for here.
|
||||
*/
|
||||
function MobileHeaderMenu() {
|
||||
const t = useTranslations('top_bar');
|
||||
const tPalette = useTranslations('command_palette');
|
||||
const tNotifications = useTranslations('notification_center');
|
||||
const unread = useUnreadNotificationCount();
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
}, [open, menuRef]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn("relative h-11 w-11", open && "bg-accent")}
|
||||
aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
onKeyDown={onKeyDown}
|
||||
role="menu"
|
||||
aria-label={t('more')}
|
||||
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Bell}
|
||||
label={tNotifications('title')}
|
||||
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
|
||||
onClick={() => { close(); setShowNotifications(true); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Terminal}
|
||||
label={tPalette('title')}
|
||||
onClick={() => { close(); openCommandPalette(); }}
|
||||
/>
|
||||
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
|
||||
</div>
|
||||
)}
|
||||
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
title: string;
|
||||
@@ -246,7 +168,7 @@ export function MobileHeader({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MobileHeaderMenu />
|
||||
<PhoneHeaderMenu />
|
||||
|
||||
{/* Which account you are reading is otherwise only visible after
|
||||
opening the drawer. Last in this group so it sits on the far
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MoreVertical, Terminal, Bell } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { openCommandPalette } from "@/lib/app-commands";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
NotificationSheet,
|
||||
formatBadgeCount,
|
||||
useUnreadNotificationCount,
|
||||
} from "@/components/notifications/notification-center";
|
||||
|
||||
/** Plugin buttons in the top bar are not about any one item. */
|
||||
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
|
||||
|
||||
export interface PhoneHeaderMenuProps {
|
||||
/**
|
||||
* The app's own entries, listed above the shell's. They get the function
|
||||
* that closes the menu, to call when one is picked.
|
||||
*/
|
||||
menuItems?: (close: () => void) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone header's overflow menu: the app's own entries if it has any, the
|
||||
* notifications (the desktop bell), the command palette (no Ctrl+K on a
|
||||
* phone) and what the desktop top bar shows as buttons (plugin
|
||||
* top-bar-actions) and has no room for here.
|
||||
*/
|
||||
export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
|
||||
const t = useTranslations("top_bar");
|
||||
const tPalette = useTranslations("command_palette");
|
||||
const tNotifications = useTranslations("notification_center");
|
||||
const unread = useUnreadNotificationCount();
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open, menuRef]);
|
||||
|
||||
const appItems = menuItems?.(close);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
size="icon-lg"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(open && "bg-accent")}
|
||||
label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")}
|
||||
title=""
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MoreVertical />
|
||||
{unread > 0 && (
|
||||
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
|
||||
)}
|
||||
</IconButton>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
onKeyDown={onKeyDown}
|
||||
role="menu"
|
||||
aria-label={t("more")}
|
||||
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
|
||||
>
|
||||
{appItems && (
|
||||
<>
|
||||
{appItems}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Bell}
|
||||
label={tNotifications("title")}
|
||||
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
|
||||
onClick={() => { close(); setShowNotifications(true); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Terminal}
|
||||
label={tPalette("title")}
|
||||
onClick={() => { close(); openCommandPalette(); }}
|
||||
/>
|
||||
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
|
||||
</div>
|
||||
)}
|
||||
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps {
|
||||
/**
|
||||
* Below which width the app shows the phone shell (bottom tab bar, no
|
||||
* rail): "md" for apps that keep the rail on a tablet, "lg" for those that
|
||||
* use the phone shell up to the desktop. Above it nothing is drawn, as the
|
||||
* rail or the Mountain View top bar holds these controls there.
|
||||
*/
|
||||
below?: "md" | "lg";
|
||||
className?: string;
|
||||
/** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */
|
||||
onSelectAllInboxes?: () => void;
|
||||
allInboxesSelected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster at the end of every app's phone header (answer 12 of the
|
||||
* 2026-10-27 usability test): a ⋮ with notifications and the command palette,
|
||||
* then the account avatar. Drop it in as the last child of the header row.
|
||||
*/
|
||||
export function PhoneHeaderActions({
|
||||
below = "lg",
|
||||
className,
|
||||
menuItems,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
}: PhoneHeaderActionsProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const shown = below === "md" ? isMobile : !isDesktop;
|
||||
if (!shown) return null;
|
||||
|
||||
return (
|
||||
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
|
||||
<PhoneHeaderMenu menuItems={menuItems} />
|
||||
{/* Which account you are in is otherwise only visible in Settings.
|
||||
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
|
||||
<AccountSwitcher
|
||||
variant="header"
|
||||
onSelectAllInboxes={onSelectAllInboxes}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps {
|
||||
title: string;
|
||||
/** Controls drawn before the title, such as a back or menu button. */
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */
|
||||
export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop;
|
||||
if (!shown) return null;
|
||||
return (
|
||||
<header className="flex items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
|
||||
{leading}
|
||||
<h1 className="font-semibold text-lg truncate min-w-0" title={title}>{title}</h1>
|
||||
<PhoneHeaderActions {...actions} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { PhoneAppHeader } from "@/components/layout/phone-header-actions";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
@@ -144,6 +145,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{!isEmbedded && <PhoneAppHeader title={page?.title ?? ""} below="md" />}
|
||||
{inlineApp && <InlineAppView apps={loadedApps} activeAppId={inlineApp.id} onClose={closeInlineApp} />}
|
||||
<div className={cn("flex flex-col flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{content}
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { PhoneHeaderActions } from '@/components/layout/phone-header-actions';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
@@ -844,6 +845,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||
<PhoneHeaderActions />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-lg">{t('title')}</h1>
|
||||
</div>
|
||||
<PhoneHeaderActions />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
Reference in New Issue
Block a user