Files
element-call-Github/src/Header.tsx
T
Timo K.andClaude Fable 5.1 b7285064fd Let the shell say where home is, and drop the router from the call
The call reached for react-router in five places to send the user
"home": on leaving without a lobby, from the lobby's recents link, from
the post-call screen, from the error page's return button and from the
header logo. Home is the standalone app's home page; the call has no
idea where that is, and a component has no such place at all — its host
decides what follows a call. Yet the component had to mount a
MemoryRouter just so those hooks would not throw.

`useLeaveToHome` is the way home as the shell supplies it: the app
provides `navigate("/")` from inside its router, the component provides
nothing, and everything that used to link to "/" now either calls it or,
when there is none, offers no way out. The logo becomes a plain logo,
the recents and "not now" links disappear, the error page's button does
too. `ClientProvider`'s logout goes the same way. The component no
longer renders a router.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:39:00 +02:00

206 lines
4.6 KiB
TypeScript

/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import {
type Ref,
type FC,
type HTMLAttributes,
type ReactNode,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Header.module.css";
import Logo from "./icons/Logo.svg?react";
import { Avatar, Size } from "./Avatar";
import { EncryptionLock } from "./room/EncryptionLock";
import { useRootSizeMatches } from "./useRootSize";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { DisconnectedBanner } from "./DisconnectedBanner";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
/**
* Whether the header should display an informational banner whenever the
* client is disconnected from the homeserver.
* @default true
*/
disconnectedBanner?: boolean;
}
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
);
};
Header.displayName = "Header";
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
hideMobile?: boolean;
}
export const LeftNav: FC<LeftNavProps> = ({
children,
className,
hideMobile,
...rest
}) => {
return (
<div
className={classNames(
styles.nav,
styles.leftNav,
{ [styles.hideMobile]: hideMobile },
className,
)}
{...rest}
>
{children}
</div>
);
};
interface RightNavProps extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
hideMobile?: boolean;
}
export const RightNav: FC<RightNavProps> = ({
children,
className,
hideMobile,
...rest
}) => {
return (
<div
className={classNames(
styles.nav,
styles.rightNav,
{ [styles.hideMobile]: hideMobile },
className,
)}
{...rest}
>
{children}
</div>
);
};
interface HeaderLogoProps {
className?: string;
}
/**
* The logo, which is also the way home — when there is a home to go to. As a
* component there is not, and it is just the logo.
*/
export const HeaderLogo: FC<HeaderLogoProps> = ({ className }) => {
const { t } = useTranslation();
const leaveToHome = useLeaveToHome();
const onClick = useCallback(() => leaveToHome?.(), [leaveToHome]);
if (leaveToHome === null)
return (
<div className={classNames(styles.headerLogo, className)}>
<Logo />
</div>
);
return (
<button
type="button"
className={classNames(styles.headerLogo, className)}
onClick={onClick}
aria-label={t("header_label")}
>
<Logo />
</button>
);
};
interface RoomHeaderInfoProps {
id: string;
name: string;
avatarUrl: string | null;
encrypted: boolean;
participantCount: number | null;
}
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
id,
name,
avatarUrl,
encrypted,
participantCount,
}) => {
const { t } = useTranslation();
const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg";
return (
<div className={styles.roomHeaderInfo} data-size={size}>
<Avatar
className={styles.roomAvatar}
id={id}
name={name}
size={size === "sm" ? Size.SM : 56}
src={avatarUrl ?? undefined}
/>
<div className={styles.nameLine}>
<Heading
type={size === "sm" ? "body" : "heading"}
size={size === "sm" ? "lg" : "md"}
weight="semibold"
data-testid="roomHeader_roomName"
>
{name}
</Heading>
<EncryptionLock encrypted={encrypted} />
</div>
{(participantCount ?? 0) > 0 && (
<div className={styles.participantsLine}>
<UserProfileIcon
width={20}
height={20}
aria-label={t("header_participants_label")}
/>
<Text
as="span"
size="sm"
weight="medium"
data-testid="roomHeader_participants_count"
>
{t("participant_count", { count: participantCount ?? 0 })}
</Text>
</div>
)}
</div>
);
};