mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../config/navConfig';
|
|
import { useT } from '../lib/i18n/I18nContext';
|
|
import { useCoreState } from '../providers/CoreStateProvider';
|
|
import { trackEvent } from '../services/analytics';
|
|
import { selectCompanionSessionActive } from '../store/companionSlice';
|
|
import { useAppSelector } from '../store/hooks';
|
|
import { selectUnreadCount } from '../store/notificationSlice';
|
|
import { isAccountsFullscreen } from '../utils/accountsFullscreen';
|
|
import { BILLING_DASHBOARD_URL } from '../utils/links';
|
|
import { isLocalSessionToken } from '../utils/localSession';
|
|
import { openUrl } from '../utils/openUrl';
|
|
import { resolveUserName } from '../utils/userName';
|
|
|
|
// ── SVG icons, keyed by tab id ────────────────────────────────────────────────
|
|
|
|
function TabIcon({ id }: { id: string }) {
|
|
switch (id) {
|
|
case 'home':
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'human':
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14c-4 0-7 2.5-7 6h14c0-3.5-3-6-7-6z"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'chat':
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'connections':
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'activity':
|
|
// Reuse the Intelligence/memory lightbulb icon for the Activity tab
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'settings':
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
);
|
|
case 'brain':
|
|
// Two symmetric lobes — reads clearly as a brain. Rendered larger and
|
|
// white inside the raised center circle.
|
|
return (
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-4.96.44 2.5 2.5 0 01-2.96-3.08 3 3 0 01-.34-5.58 2.5 2.5 0 011.32-4.24 2.5 2.5 0 011.98-3A2.5 2.5 0 019.5 2z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M14.5 2A2.5 2.5 0 0012 4.5v15a2.5 2.5 0 004.96.44 2.5 2.5 0 002.96-3.08 3 3 0 00.34-5.58 2.5 2.5 0 00-1.32-4.24 2.5 2.5 0 00-1.98-3A2.5 2.5 0 0014.5 2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const getInitials = (name: string): string => {
|
|
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
if (words.length === 0) return 'OH';
|
|
return words
|
|
.slice(0, 2)
|
|
.map(word => word[0]?.toUpperCase() ?? '')
|
|
.join('');
|
|
};
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
const BottomTabBar = () => {
|
|
const { t } = useT();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { snapshot } = useCoreState();
|
|
const token = snapshot.sessionToken;
|
|
const [revealed, setRevealed] = useState(false);
|
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
|
const profileMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
|
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
|
|
const companionActive = useAppSelector(selectCompanionSessionActive);
|
|
// `state.theme` is undefined in some test fixtures that build a minimal
|
|
// store without the theme slice; default to the historical 'hover' behavior
|
|
// so an absent theme branch can't crash the bar.
|
|
const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover');
|
|
const labelsAlwaysVisible = tabBarLabels === 'always';
|
|
|
|
const isLocalSession = isLocalSessionToken(token);
|
|
// The avatar button shows the signed-in user's initials.
|
|
const userInitials = getInitials(resolveUserName(snapshot.currentUser));
|
|
|
|
// Resolve translated labels for NAV_TABS once per render cycle.
|
|
const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]);
|
|
|
|
useEffect(() => {
|
|
if (!profileMenuOpen) return;
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (profileMenuRef.current?.contains(event.target as Node)) return;
|
|
setProfileMenuOpen(false);
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setProfileMenuOpen(false);
|
|
};
|
|
|
|
document.addEventListener('pointerdown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [profileMenuOpen]);
|
|
|
|
const hiddenPaths = ['/', '/login'];
|
|
if (
|
|
!token ||
|
|
hiddenPaths.some(path => location.pathname === path || location.pathname.startsWith(`${path}/`))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// On /accounts we want as much real estate as possible for the embedded
|
|
// webview — but *only* when a real account (WhatsApp, …) is selected.
|
|
// The Agent entry keeps the tab bar visible so chatting with the agent
|
|
// feels like a normal page. A thin hover strip along the bottom lets
|
|
// the user reveal the bar manually even in fullscreen mode.
|
|
const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId);
|
|
const collapsed = fullscreen && !revealed;
|
|
|
|
const isActive = (path: string) => {
|
|
if (path === '/chat') return location.pathname.startsWith('/chat');
|
|
if (path === '/settings/cron-jobs') return location.pathname.startsWith('/settings/cron-jobs');
|
|
if (path === '/settings/messaging') return location.pathname.startsWith('/settings/messaging');
|
|
if (path === '/settings')
|
|
return (
|
|
location.pathname === '/settings' ||
|
|
(location.pathname.startsWith('/settings/') &&
|
|
!location.pathname.startsWith('/settings/cron-jobs') &&
|
|
!location.pathname.startsWith('/settings/messaging'))
|
|
);
|
|
if (path === '/home') return location.pathname === '/home';
|
|
return location.pathname === path;
|
|
};
|
|
|
|
const activeTab = tabs.find(tab => isActive(tab.path));
|
|
|
|
const handleTabClick = (tab: (typeof tabs)[number], active: boolean) => {
|
|
if (!active) {
|
|
trackEvent('tab_bar_change', {
|
|
from_tab: activeTab?.id ?? 'unknown',
|
|
to_tab: tab.id,
|
|
from_path: location.pathname,
|
|
to_path: tab.path,
|
|
});
|
|
}
|
|
navigate(tab.path);
|
|
};
|
|
|
|
const handleAvatarMenuItemClick = (itemId: string, kind: string, target: string) => {
|
|
setProfileMenuOpen(false);
|
|
if (kind === 'openUrl') {
|
|
openUrl(target).catch(() => {});
|
|
} else {
|
|
navigate(target);
|
|
}
|
|
trackEvent('avatar_menu_item_click', { item_id: itemId });
|
|
};
|
|
|
|
// One regular pill tab. `iconOnly` renders just the glyph (no label) — used
|
|
// for the pinned Home button so it reads as a fixed icon, like the avatar.
|
|
//
|
|
// When labels are always visible (theme setting), every labelled tab is given
|
|
// the SAME fixed width so the row stays symmetric. In the default hover mode
|
|
// the label still expands on hover (no fixed width) — unchanged behaviour.
|
|
const renderTab = (tab: (typeof tabs)[number], iconOnly = false) => {
|
|
const active = isActive(tab.path);
|
|
const showBadge = tab.id === 'notifications' && unreadCount > 0;
|
|
const showCompanionDot = tab.id === 'settings' && companionActive;
|
|
const fixedWidth = !iconOnly && labelsAlwaysVisible;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
data-walkthrough={tab.walkthroughAttr}
|
|
onClick={() => handleTabClick(tab, active)}
|
|
title={tab.label}
|
|
className={`group relative flex items-center rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
|
|
iconOnly
|
|
? 'h-9 w-9 justify-center'
|
|
: fixedWidth
|
|
? 'w-32 justify-center px-2 py-2'
|
|
: 'px-2 py-2'
|
|
} ${
|
|
active
|
|
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
|
|
: 'bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-stone-300/50 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
|
|
}`}
|
|
aria-label={
|
|
tab.id === 'notifications' && unreadCount > 0
|
|
? `${tab.label} (${unreadCount} ${t('alerts.unread')})`
|
|
: tab.label
|
|
}>
|
|
<span className="relative inline-flex flex-shrink-0">
|
|
<TabIcon id={tab.id} />
|
|
{showBadge && (
|
|
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</span>
|
|
)}
|
|
{showCompanionDot && (
|
|
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
|
)}
|
|
</span>
|
|
{!iconOnly && (
|
|
<span
|
|
className={`min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
|
|
active || labelsAlwaysVisible
|
|
? `${fixedWidth ? 'truncate ' : ''}max-w-[160px] ml-2 opacity-100`
|
|
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
|
|
}`}>
|
|
{tab.label}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// The Brain — a raised circular button rising out of the center of the bar.
|
|
// The bg-colored ring fakes a notch cut into the pill's top edge. `brain-fab`
|
|
// is targeted by the reduced-motion gate in index.css to silence the glow.
|
|
const renderBrainButton = () => {
|
|
const active = isActive(BRAIN_TAB.path);
|
|
const brainTab = { ...BRAIN_TAB, label: t(BRAIN_TAB.labelKey) };
|
|
return (
|
|
<button
|
|
key="brain"
|
|
type="button"
|
|
data-walkthrough={BRAIN_TAB.walkthroughAttr}
|
|
onClick={() => handleTabClick(brainTab, active)}
|
|
aria-label={brainTab.label}
|
|
title={brainTab.label}
|
|
className={`brain-fab group relative mx-1 flex h-12 w-12 -translate-y-5 items-center justify-center rounded-full text-white shadow-soft ring-4 ring-stone-200 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer dark:ring-neutral-900 ${
|
|
active
|
|
? 'bg-primary-600 animate-glow-pulse shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
|
|
: 'bg-primary-500 hover:bg-primary-600 hover:scale-105'
|
|
}`}>
|
|
<TabIcon id="brain" />
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// Home is pinned to the far-left of the pill behind a divider — mirroring the
|
|
// avatar pinned to the far-right behind its own divider. The rest of the row
|
|
// splits evenly around the centered Brain button:
|
|
// [ home ] | assistant · connections ( 🧠 ) activity · settings | [ avatar ]
|
|
const homeTab = tabs[0];
|
|
const leftTabs = tabs.slice(1, 3);
|
|
const rightTabs = tabs.slice(3);
|
|
|
|
return (
|
|
// pointer-events-none on the full-width shell so transparent areas (e.g.
|
|
// beside the centered nav pill) do not steal clicks from sticky footers
|
|
// such as Settings SaveBar. Only the <nav> pill re-enables hits.
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-50">
|
|
{/* Hover strip — only matters when collapsed; provides a 12px bottom
|
|
edge the user can mouse into to reveal the bar again. */}
|
|
{collapsed && (
|
|
<div
|
|
className="pointer-events-auto absolute inset-x-0 bottom-0 h-3"
|
|
onMouseEnter={() => setRevealed(true)}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<div
|
|
className={`pointer-events-none flex justify-center px-4 pb-4 pt-2 transition-transform duration-300 ease-out ${
|
|
collapsed ? 'translate-y-[calc(100%+8px)]' : 'translate-y-0'
|
|
}`}
|
|
onMouseLeave={() => setRevealed(false)}
|
|
onFocus={() => setRevealed(true)}
|
|
onBlur={e => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
|
|
}}>
|
|
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
|
|
<div className="relative mr-1 border-r border-stone-300 pr-1 dark:border-neutral-700">
|
|
{renderTab(homeTab, true)}
|
|
</div>
|
|
{leftTabs.map(tab => renderTab(tab))}
|
|
{renderBrainButton()}
|
|
{rightTabs.map(tab => renderTab(tab))}
|
|
<div
|
|
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
|
|
ref={profileMenuRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setProfileMenuOpen(open => !open)}
|
|
className={`relative flex h-9 w-9 items-center justify-center rounded-sm transition-colors duration-300 cursor-pointer ${
|
|
profileMenuOpen
|
|
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
|
|
: 'bg-transparent text-stone-500 hover:bg-stone-300/50 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
|
|
}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={profileMenuOpen}
|
|
aria-label={t('nav.avatarMenu.account')}
|
|
title={t('nav.avatarMenu.account')}>
|
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-500 text-[10px] font-semibold leading-none text-white">
|
|
{userInitials}
|
|
</span>
|
|
</button>
|
|
|
|
{profileMenuOpen && (
|
|
<div
|
|
role="menu"
|
|
aria-label={t('nav.avatarMenu.account')}
|
|
className="absolute bottom-full right-0 mb-2 w-56 overflow-hidden rounded-sm border border-stone-300 bg-white shadow-soft dark:border-neutral-700 dark:bg-neutral-900">
|
|
<div className="p-1">
|
|
{AVATAR_MENU_ITEMS.filter(item => !item.cloudOnly || !isLocalSession).map(
|
|
item => {
|
|
// Billing target is resolved from the canonical constant rather than the
|
|
// data-file placeholder so it stays in sync with SettingsHome.
|
|
const target = item.id === 'billing' ? BILLING_DASHBOARD_URL : item.target;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => handleAvatarMenuItemClick(item.id, item.kind, target)}
|
|
className="flex w-full items-center rounded-sm px-2 py-2 text-left text-sm text-stone-700 transition-colors hover:bg-stone-100 dark:text-neutral-200 dark:hover:bg-neutral-800">
|
|
{t(item.labelKey)}
|
|
</button>
|
|
);
|
|
}
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BottomTabBar;
|