Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The front-end uses yarn 1.22.22. Never ever use npm.

- Where possible style things using @emotion/react rather than using sx objects.

- Avoid stacking/nesting ternary (`? :`) operators (e.g. `a ? x : b ? y : z`). They're too hard for humans to read. Use an if/else-if chain (or a switch) instead. A single, non-nested ternary is fine.

- For Typescript coding style, see ./src/BloomBrowserUI/AGENTS.md

# Testing
Expand Down
15 changes: 15 additions & 0 deletions DistFiles/localization/en/Bloom.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@
<source xml:lang="en">Bloom Accessibility Checker</source>
<note>ID: AccessibilityCheck.WindowTitle</note>
</trans-unit>
<trans-unit id="AccountMenu.Account" translate="no">
<source xml:lang="en">Account</source>
<note>ID: AccountMenu.Account</note>
<note>Tooltip and accessible label for the round avatar button in the top bar of the main window.</note>
</trans-unit>
<trans-unit id="AccountMenu.SignIn" translate="no">
<source xml:lang="en">Sign in</source>
<note>ID: AccountMenu.SignIn</note>
<note>Button in the top bar of the main window. Clicking it opens the web browser to sign in to BloomLibrary.org.</note>
</trans-unit>
<trans-unit id="AccountMenu.SignOut" translate="no">
<source xml:lang="en">Sign out</source>
<note>ID: AccountMenu.SignOut</note>
<note>Menu item shown under the user's avatar in the top bar; signs the user out of BloomLibrary.org.</note>
</trans-unit>
<trans-unit id="AutoUpdateSoftwareDialog.SoftwareUpdates" sil:dynamic="true">
<source xml:lang="en">Software Updates</source>
<note>ID: AutoUpdateSoftwareDialog.SoftwareUpdates</note>
Expand Down
20 changes: 4 additions & 16 deletions src/BloomBrowserUI/publish/LibraryPublish/LibraryPublishSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../../react_components/Progress/progressBox";
import { BloomCheckbox } from "../../react_components/BloomCheckBox";
import { useL10n } from "../../react_components/l10nHooks";
import { useLoginState } from "../../react_components/useLoginState";
import { kWebSocketContext } from "./LibraryPublishScreen";
import {
useSubscribeToWebSocketForEvent,
Expand Down Expand Up @@ -60,7 +61,6 @@ interface IReadonlyBookInfo {

const kWebSocketEventId_uploadSuccessful: string = "uploadSuccessful";
const kWebSocketEventId_uploadCanceled: string = "uploadCanceled";
const kWebSocketEventId_loginSuccessful: string = "loginSuccessful";

export const LibraryPublishSteps: React.FunctionComponent = () => {
const selectedBookContext = React.useContext(SelectedBookContext);
Expand Down Expand Up @@ -128,7 +128,6 @@ export const LibraryPublishSteps: React.FunctionComponent = () => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [bookInfo, setBookInfo] = useState<IReadonlyBookInfo>();
useEffect(() => {
post("libraryPublish/checkForLoggedInUser");
getBoolean("libraryPublish/agreementsAccepted", (result) => {
setAgreedPreviously(result);
setAgreementsAccepted(result);
Expand Down Expand Up @@ -191,15 +190,7 @@ export const LibraryPublishSteps: React.FunctionComponent = () => {
else hasRenderedRef.current = true;
}, [agreementsAccepted]);

const [loggedInEmail, setLoggedInEmail] = useState<string>();

useSubscribeToWebSocketForStringMessage(
kWebSocketContext,
kWebSocketEventId_loginSuccessful,
(email) => {
setLoggedInEmail(email);
},
);
const { email: loggedInEmail, signIn, signOut } = useLoginState();

function isReadyForUpload(): boolean {
return isReadyForAgreements() && agreementsAccepted;
Expand Down Expand Up @@ -593,7 +584,7 @@ export const LibraryPublishSteps: React.FunctionComponent = () => {
isReadyForUpload() && !isPlaygroundBook
}
l10nKey="PublishTab.Upload.SignIn"
onClick={() => post("libraryPublish/login")}
onClick={signIn}
>
Sign in or sign up to BloomLibrary.org
</BloomButton>
Expand Down Expand Up @@ -622,10 +613,7 @@ export const LibraryPublishSteps: React.FunctionComponent = () => {
l10nKey="PublishTab.Upload.SignOut"
l10nComment="The %0 will be replaced with the email address of the user."
l10nParam0={loggedInEmail}
onClick={() => {
post("libraryPublish/logout");
setLoggedInEmail(undefined);
}}
onClick={signOut}
>
Sign out (%0)
</BloomButton>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as React from "react";
import { css } from "@emotion/react";
import { ArrowDropDown } from "@mui/icons-material";
import Menu from "@mui/material/Menu";
import Divider from "@mui/material/Divider";
import { BloomTooltip } from "../../BloomToolTip";
import { TopRightMenuButton, topRightMenuArrowCss } from "./TopRightMenuButton";
import { useL10n } from "../../l10nHooks";
import { LocalizableMenuItem } from "../../localizableMenuItem";
import { callOnBlur } from "../../../utils/menuCloseOnBlur";
import { BloomAvatar } from "../../bloomAvatar";
import { useLoginState } from "../../useLoginState";
import { useApiObject } from "../../../utils/bloomApi";
import { RegistrationInfo } from "../../registration/registrationTypes";

// The account control shown at the top of the workspace's upper-right corner.
// When signed out, it is a simple "Sign in" button that launches the external
// browser login. When signed in, it shows the user's avatar; clicking it opens
// a menu with the signed-in email and a "Sign out" item.
export const AccountMenu: React.FunctionComponent = () => {
const signInText = useL10n("Sign in", "AccountMenu.SignIn");
const accountText = useL10n("Account", "AccountMenu.Account");

const { email, signIn, signOut } = useLoginState();

// Used to get a display name for the avatar. Fetched once; falls back gracefully
// (BloomAvatar shows initials or a generated image) if it's empty.
const registrationInfo = useApiObject<Partial<RegistrationInfo>>(
"registration/userInfo",
{},
);
const displayName = [registrationInfo.firstName, registrationInfo.surname]
.filter((namePart) => !!namePart)
.join(" ");

const [anchorEl, setAnchorEl] = React.useState<HTMLElement>();

const onClose = React.useCallback(() => {
setAnchorEl(undefined);
}, []);

const onOpen = React.useCallback(() => {
// This button is rendered by this component, so it exists when its own onClick fires.
const button =
document.getElementById("accountMenuButton") ?? undefined;
setAnchorEl(button);
callOnBlur(onClose);
}, [onClose]);

const handleSignOut = React.useCallback(() => {
onClose();
signOut();
}, [onClose, signOut]);

if (!email) {
return (
<TopRightMenuButton
buttonId="accountMenuButton"
text={signInText}
onClick={signIn}
/>
);
}

return (
<React.Fragment>
<BloomTooltip
tip={{ l10nKey: "AccountMenu.Account", english: "Account" }}
>
<button
id="accountMenuButton"
aria-label={accountText}
onClick={onOpen}
css={css`
display: inline-flex;
align-items: center;
justify-content: end;
gap: 2px;
width: 100%;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
`}
>
<BloomAvatar
email={email}
name={displayName}
avatarSizeInt={28}
/>
<ArrowDropDown css={topRightMenuArrowCss} />
</button>
</BloomTooltip>
<Menu
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
disablePortal={false}
keepMounted={false}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
slotProps={{
paper: {
css: css`
min-width: 220px;
`,
},
}}
>
<div
css={css`
padding: 6px 16px;
color: rgba(0, 0, 0, 0.6);
font-size: 0.875rem;
`}
>
{email}
</div>
<Divider />
<LocalizableMenuItem
english="Sign out"
l10nId="AccountMenu.SignOut"
onClick={handleSignOut}
hasLeadingIconSpace={false}
/>
</Menu>
</React.Fragment>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { createTheme, ThemeProvider } from "@mui/material/styles";
import { ZoomControl } from "./ZoomControl";
import { UiLanguageMenu } from "./UiLanguageMenu";
import { HelpMenu } from "./HelpMenu";
import { AccountMenu } from "./AccountMenu";

// Every affordance in this group -- text, menu-button labels, the help icon, and the
// dropdown arrows -- is drawn in black at 80% opacity. The tab-bar background these sit
// on isn't always the same color, and black-at-80% reads well across those backgrounds.
// Every affordance in this group -- text, menu-button labels, the help icon, and all
// the dropdown arrows -- is drawn in black at 80% opacity. (The avatar image is not
// affected by color.) The tab-bar background these sit on isn't always the same color,
// and black-at-80% reads well across those backgrounds.
const kTopRightControlColor = "rgba(0, 0, 0, 0.8)";

export const WorkspaceTopRightControls: React.FunctionComponent = () => {
Expand Down Expand Up @@ -39,11 +41,18 @@ export const WorkspaceTopRightControls: React.FunctionComponent = () => {
<div
css={css`
display: flex;
flex-direction: column;
gap: 1px;
align-items: end;
flex-direction: row;
align-items: flex-start;
gap: 12px;
font-size: 12px;

// The neighboring Settings/Other Collection buttons (TopBarButton) have
// 8px of internal top padding before their icon, so their visible content
// starts ~10px below the top of this group's boxes. Nudge this group down
// by the same amount so the language menu, help icon, zoom, and avatar
// top-align with the Settings icon rather than riding above it.
margin-top: 10px;

// See comment on kTopRightControlColor above.
color: ${kTopRightControlColor};
button {
Expand All @@ -55,20 +64,32 @@ export const WorkspaceTopRightControls: React.FunctionComponent = () => {
}
`}
>
{/* This grid keeps the two menu buttons the same width which keeps the down arrows aligned horizontally */}
{/* The language chooser, help menu, and zoom control sit together on the
left so that the account menu can stand by itself on the right. */}
<div
css={css`
display: grid;
grid-template-columns: 1fr;
grid-auto-rows: auto;
width: max-content;
row-gap: 1px;
display: flex;
flex-direction: column;
gap: 1px;
align-items: end;
`}
>
<UiLanguageMenu />
<HelpMenu />
{/* This grid keeps the two menu buttons the same width which keeps the down arrows aligned horizontally */}
<div
css={css`
display: grid;
grid-template-columns: 1fr;
grid-auto-rows: auto;
width: max-content;
row-gap: 1px;
`}
>
<UiLanguageMenu />
<HelpMenu />
</div>
<ZoomControl />
</div>
<ZoomControl />
<AccountMenu />
</div>
</ThemeProvider>
);
Expand Down
33 changes: 22 additions & 11 deletions src/BloomBrowserUI/react_components/bloomAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@ import { css } from "@emotion/react";

import * as React from "react";
import Avatar, { Cache, ConfigProvider } from "react-avatar";
import { getMd5 } from "../bookEdit/toolbox/talkingBook/md5Util";
import { getBloomApiPrefix } from "../utils/bloomApi";

// react-avatar does not cache actual avatars. It only caches which source urls failed
// (whether because the user was offline or the source 404'd), and then doesn't retry the failed
// urls so long as they are valid in cache. We do want it to retry (the local server may have gained
// connectivity), so keep its cache empty. The config never changes, so this is a module-level
// constant rather than being rebuilt on every render.
const emptyAvatarCache = new Cache({
sourceTTL: 0, // retain for 0 milliseconds
sourceSize: 0, // retain a maximum of 0 items in cache
});

export const BloomAvatar: React.FunctionComponent<{
email: string;
Expand All @@ -19,14 +29,15 @@ export const BloomAvatar: React.FunctionComponent<{
? `${borderSizeInt}px solid ${props.borderColor}`
: undefined;

// react-avatar does not cache actual avatars. It only caches which gravatar urls failed
// (whether because user was offline or doesn't have a gravatar),
// and then doesn't retry the failed urls so long as they are valid in cache.
// We do want it to retry retrieving gravatars, so keep its cache empty
const cache = new Cache({
sourceTTL: 0, // retain for 0 milliseconds
sourceSize: 0, // retain a maximum of 0 items in cache
});
// The avatar image comes from Bloom's own local server, keyed by email: it decides the best
// source (the person's known Google/Firebase photo if we have one, otherwise Gravatar), caches
// the actual bytes so avatars survive restart and work offline, and returns 404 when it has
// nothing -- in which case react-avatar falls back to the generated initials from `name`. The
// server hashes/normalizes the email itself, so we just pass the email.
const avatarSrc = props.email
? `${getBloomApiPrefix()}avatar?email=${encodeURIComponent(props.email)}`
: undefined;

return (
<React.Suspense fallback={<React.Fragment />}>
<div
Expand All @@ -43,9 +54,9 @@ export const BloomAvatar: React.FunctionComponent<{
background-color: ${props.borderColor};
`}
>
<ConfigProvider cache={cache}>
<ConfigProvider cache={emptyAvatarCache}>
<Avatar
md5Email={getMd5(props.email)}
src={avatarSrc}
name={props.name}
size={avatarSize}
maxInitials={3}
Expand Down
21 changes: 21 additions & 0 deletions src/BloomBrowserUI/react_components/useLoginState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { post, useWatchApiObject } from "../utils/bloomApi";

// Shared, app-wide state for whether the user is signed in to BloomLibrary.org.
// This is used both by the account control in the workspace top bar and by the
// sign-in/out UI on the Publish screen, so that they always agree with each other.
// The backend keeps them in sync by broadcasting a "loginStateChanged" event on the
// "account" websocket context whenever the signed-in user changes (including changes
// made from another one of these UI locations, or from an external browser login).
export const useLoginState = () => {
const status = useWatchApiObject<{ email: string }>(
"account/status",
{ email: "" },
"account",
"loginStateChanged",
);
return {
email: status.email || undefined,
signIn: () => post("account/login"),
signOut: () => post("account/logout"),
};
};
Loading