mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix(mountain-view): keep the same top bar on every app
Calendar, contacts, files and settings still drew the left navigation rail under Mountain View, so moving away from mail switched to the default frame. They now show the Mountain View top bar too, without the mail search and the menu button, which only make sense in mail.
This commit is contained in:
@@ -92,6 +92,8 @@ import {
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -119,6 +121,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||
const isNarrow = !isDesktop;
|
||||
@@ -1571,9 +1575,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
|
||||
@@ -44,6 +44,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -125,6 +127,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// One-shot intent flag: only consume the URL params on the first render that
|
||||
@@ -899,9 +903,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
export interface FilesAppProps {
|
||||
/** Path segments after `/files` - the folder path, one segment per level. */
|
||||
@@ -114,6 +116,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
// The account the files client was last initialised for (`undefined` = never).
|
||||
// Tracked so a Pro-shell account switch re-initialises instead of showing the
|
||||
@@ -573,8 +577,17 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
|
||||
@@ -30,25 +30,18 @@ import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
|
||||
import type { ContactSuggestion } from "@/lib/search-suggestions";
|
||||
|
||||
interface MvTopBarProps {
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onSearchSubmit: (query: string) => void;
|
||||
onSearchClear: () => void;
|
||||
/** Mail search, which only the mail surface puts into the bar. */
|
||||
interface MvTopBarSearch {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSubmit: (query: string) => void;
|
||||
onClear: () => void;
|
||||
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
|
||||
/** Opens the advanced-filter panel that lives under the message list. */
|
||||
onToggleFilters: () => void;
|
||||
filtersOpen: boolean;
|
||||
activeFilterCount: number;
|
||||
searchDisabled?: boolean;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
/** Opens the merged across-accounts inbox from the account popover. */
|
||||
onSelectAllInboxes?: () => void;
|
||||
allInboxesSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Receives the node under the search field that the advanced-filter panel is
|
||||
* portalled into, so the panel drops from the field itself; without this
|
||||
@@ -58,23 +51,36 @@ interface MvTopBarProps {
|
||||
onFilterAnchorChange?: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
interface MvTopBarProps {
|
||||
/**
|
||||
* The mail search field. Other surfaces leave it out and keep their own
|
||||
* search where it was; the bar then carries the brand, the app grid and the
|
||||
* account avatar only.
|
||||
*/
|
||||
search?: MvTopBarSearch;
|
||||
/** The hamburger folds the mail navigation, so only mail shows it. */
|
||||
showMenuButton?: boolean;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
/** Opens the merged across-accounts inbox from the account popover. */
|
||||
onSelectAllInboxes?: () => void;
|
||||
allInboxesSelected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain View's global header: one 64px row that owns the hamburger, the
|
||||
* brand, the search field and the account cluster. It only renders on
|
||||
* desktop under `interfaceLayout === 'mountain-view'`; the default layout keeps
|
||||
* search inside the list column and navigation in the left rail, and neither
|
||||
* of those is touched here.
|
||||
* desktop under `interfaceLayout === 'mountain-view'` (see
|
||||
* useMountainViewShell), on every surface, in place of the left rail; the
|
||||
* default layout keeps search inside the list column and navigation in the
|
||||
* left rail, and neither of those is touched here.
|
||||
*/
|
||||
export function MvTopBar({
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchSubmit,
|
||||
onSearchClear,
|
||||
onSelectContact,
|
||||
onToggleFilters,
|
||||
filtersOpen,
|
||||
activeFilterCount,
|
||||
searchDisabled = false,
|
||||
search,
|
||||
showMenuButton = true,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
@@ -82,7 +88,6 @@ export function MvTopBar({
|
||||
activeAppId,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
onFilterAnchorChange,
|
||||
}: MvTopBarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const tSearch = useTranslations("advanced_search");
|
||||
@@ -96,6 +101,8 @@ export function MvTopBar({
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
|
||||
// The same gate the navigation rail puts on its "add app" button.
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled"));
|
||||
const sidebarApps = useResolvedSidebarApps();
|
||||
|
||||
const [appsOpen, setAppsOpen] = useState(false);
|
||||
@@ -147,15 +154,17 @@ export function MvTopBar({
|
||||
data-mv-topbar=""
|
||||
>
|
||||
{/* Brand block: occupies the sidebar's own column width */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label={t("mobile.toggle_menu")}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className={cn("flex items-center gap-1 shrink-0", !showMenuButton && "ps-3")}>
|
||||
{showMenuButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label={t("mobile.toggle_menu")}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/" className="flex items-center gap-2 pe-4 min-w-0" aria-label={appName}>
|
||||
{logoUrl ? <img src={logoUrl} alt="" className="w-8 h-8 object-contain" /> : null}
|
||||
<span className="text-[22px] leading-none text-foreground/80 truncate">{appName}</span>
|
||||
@@ -163,48 +172,50 @@ export function MvTopBar({
|
||||
</div>
|
||||
|
||||
{/* Search: left-anchored and capped, not centred */}
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 min-w-0 max-w-[720px]">
|
||||
<SearchBox
|
||||
value={searchQuery}
|
||||
onChange={onSearchQueryChange}
|
||||
onSubmit={onSearchSubmit}
|
||||
onClear={onSearchClear}
|
||||
onSelectContact={onSelectContact}
|
||||
disabled={searchDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFilters}
|
||||
disabled={searchDisabled}
|
||||
// Inside the field's trailing end, next to the typed query.
|
||||
className={cn(
|
||||
"absolute end-1 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
searchDisabled && "opacity-50 cursor-not-allowed",
|
||||
filtersOpen || activeFilterCount > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={tSearch("toggle_filters")}
|
||||
aria-label={tSearch("toggle_filters")}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
{!filtersOpen && activeFilterCount > 0 && (
|
||||
<span className="absolute top-1.5 end-1.5 grid place-items-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{search && (
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 min-w-0 max-w-[720px]">
|
||||
<SearchBox
|
||||
value={search.query}
|
||||
onChange={search.onQueryChange}
|
||||
onSubmit={search.onSubmit}
|
||||
onClear={search.onClear}
|
||||
onSelectContact={search.onSelectContact}
|
||||
disabled={search.disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.onToggleFilters}
|
||||
disabled={search.disabled}
|
||||
// Inside the field's trailing end, next to the typed query.
|
||||
className={cn(
|
||||
"absolute end-1 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
search.disabled && "opacity-50 cursor-not-allowed",
|
||||
search.filtersOpen || search.activeFilterCount > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={tSearch("toggle_filters")}
|
||||
aria-label={tSearch("toggle_filters")}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
{!search.filtersOpen && search.activeFilterCount > 0 && (
|
||||
<span className="absolute top-1.5 end-1.5 grid place-items-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{search.activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Anchor for the advanced-filter panel: the search field's own box,
|
||||
filter button included, so the panel shares the field's edges. It
|
||||
only marks the spot; the panel itself is drawn at the end of <body>. */}
|
||||
<div
|
||||
ref={onFilterAnchorChange}
|
||||
aria-hidden
|
||||
data-mv-filter-anchor=""
|
||||
className="pointer-events-none absolute start-0 end-0 top-full h-0"
|
||||
/>
|
||||
</div>
|
||||
{/* Anchor for the advanced-filter panel: the search field's own box,
|
||||
filter button included, so the panel shares the field's edges. It
|
||||
only marks the spot; the panel itself is drawn at the end of <body>. */}
|
||||
<div
|
||||
ref={search.onFilterAnchorChange}
|
||||
aria-hidden
|
||||
data-mv-filter-anchor=""
|
||||
className="pointer-events-none absolute start-0 end-0 top-full h-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer keeps the right cluster pinned while search stays left */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
@@ -306,7 +317,7 @@ export function MvTopBar({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{onManageApps && (
|
||||
{sidebarAppsEnabled && onManageApps && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -3857,15 +3857,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
)}
|
||||
{mvShell && !fullscreenReading && (
|
||||
<MvTopBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onSearchSubmit={handleSearch}
|
||||
onSearchClear={handleClearSearch}
|
||||
onSelectContact={handleSelectContactSuggestion}
|
||||
onToggleFilters={toggleAdvancedSearch}
|
||||
filtersOpen={isAdvancedSearchOpen}
|
||||
activeFilterCount={activeFilterCount(searchFilters)}
|
||||
searchDisabled={isScheduledView}
|
||||
search={{
|
||||
query: searchQuery,
|
||||
onQueryChange: setSearchQuery,
|
||||
onSubmit: handleSearch,
|
||||
onClear: handleClearSearch,
|
||||
onSelectContact: handleSelectContactSuggestion,
|
||||
onToggleFilters: toggleAdvancedSearch,
|
||||
filtersOpen: isAdvancedSearchOpen,
|
||||
activeFilterCount: activeFilterCount(searchFilters),
|
||||
disabled: isScheduledView,
|
||||
onFilterAnchorChange: setFilterAnchor,
|
||||
}}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
@@ -3875,7 +3878,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
allInboxesReachable ? () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox) : undefined
|
||||
}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
onFilterAnchorChange={setFilterAnchor}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
||||
@@ -103,6 +103,8 @@ import {
|
||||
} from '@/lib/settings-search';
|
||||
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type Tab = SettingsSearchTab;
|
||||
|
||||
@@ -222,6 +224,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
@@ -867,8 +871,17 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isEmbedded && (
|
||||
{!isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
/**
|
||||
* Whether a surface draws Mountain View's desktop chrome: the top bar with the
|
||||
* app grid and the account avatar, in place of the left navigation rail.
|
||||
*
|
||||
* Mail, calendar, contacts, files and settings all ask the same question, so
|
||||
* moving between them under Mountain View keeps the same frame. Phones and
|
||||
* tablets keep their own navigation, and so does a surface embedded in a Pro
|
||||
* pane, whose shell owns the chrome.
|
||||
*/
|
||||
export function useMountainViewShell(isEmbedded: boolean): boolean {
|
||||
const mountainView = useSettingsStore((s) => s.interfaceLayout === "mountain-view");
|
||||
const isDesktop = useIsDesktop();
|
||||
return mountainView && isDesktop && !isEmbedded;
|
||||
}
|
||||
Reference in New Issue
Block a user