Add app-wide login and profile-photo avatars (BL-16514)#8046
Conversation
Move Bloom Library login state out of the Publish screen into an app-wide account API (account/status|login|logout) that broadcasts changes over a websocket, and add a top-bar account menu that shows the signed-in user's avatar and a sign-out item. Avatars are served by a new person-keyed cache on Bloom's local server (GET /bloom/api/avatar?email=). Keyed by the MD5 of the email (Gravatar's scheme), it caches the image bytes on disk so avatars survive a restart and work offline. Source precedence per person: the user's Google/Firebase profile photo (now sent by the website in the login callback, when present), otherwise Gravatar, otherwise a 404 so the UI shows generated initials. It refetches once per Bloom run, falling back to the cached copy if the fetch fails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| src/BloomExe/web/AvatarCache.cs | New person-keyed avatar byte cache. Core caching logic is well-designed (write-outside-lock, virtual FetchAsync for testing), but the "once per run" short-circuit is bypassed on failed fetches because _index is only written on success — an issue flagged in the previous review that remains unresolved. |
| src/BloomExe/web/controllers/AccountApi.cs | New app-wide account API (status/login/logout). Clean event-driven design: subscribes to LoginDataChanged, broadcasts loginStateChanged over websocket. Correctly captures email before Logout clears it in HandleLogout. Implements IDisposable to unsubscribe from the singleton event. |
| src/BloomExe/web/controllers/ExternalApi.cs | Accepts optional photoUrl/photoURL (both casings) from the browser login callback and registers it in AvatarCache before SetLoginData fires LoginDataChanged. Correctly tolerates absence of the field for older website builds. Old LoginSuccessful event removed and replaced by the new event-driven path. |
| src/BloomExe/web/controllers/AvatarApi.cs | Thin async endpoint (requiresSync:false) that delegates to AvatarCache and returns 404 when nothing is available so react-avatar falls back to generated initials. Clean and straightforward. |
| src/BloomExe/WebLibraryIntegration/BloomLibraryBookApiClient.cs | Adds LoginDataChanged event, TryRestoreSavedLogin (destination-matched settings restore), and fixes Logout to clear all persisted session tokens. AttemptSignInAgainForCommandLine now delegates to TryRestoreSavedLogin, removing code duplication. |
| src/BloomExe/ProjectContext.cs | Registers AccountApi, AvatarApi, and AvatarCache. Uses an explicit factory (new AvatarCache()) instead of RegisterType to avoid Autofac mis-injecting an unrelated registered string as the cache folder — the DI regression test guards this fix. |
| src/BloomBrowserUI/react_components/TopBar/workspaceTopRightControls/AccountMenu.tsx | New top-bar account control. Shows "Sign in" button when logged out; when logged in shows avatar + dropdown with email and sign-out item. Correctly uses useLoginState hook for shared app-wide state. |
| src/BloomBrowserUI/react_components/bloomAvatar.tsx | BloomAvatar now sources images from the local server (avatar?email=…) rather than calling Gravatar directly. The per-render Cache allocation is correctly promoted to a module-level constant. Falls back to react-avatar's name-based initials on 404. |
| src/BloomBrowserUI/react_components/useLoginState.ts | New shared hook for app-wide login state using useWatchApiObject. Provides email (undefined when signed out), signIn, and signOut — used by both AccountMenu and LibraryPublishSteps so they always agree. |
| src/BloomTests/web/AvatarCacheTests.cs | Comprehensive unit tests for AvatarCache covering source precedence, once-per-run refresh (success path), offline fallback, persistence, and the DI injection regression guard. The once-per-run guarantee for failed fetches (no Gravatar, no known photo) is tested only with a single call; a second call in the same run would reveal the existing bug. |
| src/BloomTests/web/AvatarApiTests.cs | Integration tests covering the avatar HTTP endpoint (200 with bytes, 404 when nothing) and ExternalApi's photo-URL wiring (both photoUrl and photoURL casings, and the absence of the field). Good use of Monitor.Enter to serialize against the shared test port. |
| src/BloomTests/WebLibraryIntegration/BloomLibraryBookApiClientLoginTests.cs | Tests for TryRestoreSavedLogin (no token, destination mismatch, matching destination), Logout (clears all saved settings), and LoginDataChanged event firing. Settings are saved and restored around each test. |
| src/BloomExe/web/controllers/LibraryPublishApi.cs | Removes the now-redundant screen-local login/logout handlers and the ExternalApi.LoginSuccessful subscription; these responsibilities have moved to AccountApi and the LoginDataChanged event. |
| src/BloomBrowserUI/publish/LibraryPublish/LibraryPublishSteps.tsx | Replaces the screen-local WebSocket subscription and post("libraryPublish/login |
| DistFiles/localization/en/Bloom.xlf | Adds three new AccountMenu strings (Account, Sign in, Sign out) with translate="no", consistent with the project-wide convention used throughout this file. |
| src/BloomExe/Publish/BloomLibrary/BloomLibraryPublishModel.cs | Removes the now-unused LogIn/LogOut forwarding methods; callers have been redirected to AccountApi. |
| src/BloomBrowserUI/react_components/TopBar/workspaceTopRightControls/WorkspaceTopRightControls.tsx | Layout updated from column to row flex to accommodate the AccountMenu alongside the existing language/help/zoom controls. |
Reviews (5): Last reviewed commit: "Avoid nested ternary; document the prefe..." | Re-trigger Greptile
|
[Claude Opus 4.8] Consulted Devin on 2026-07-08 19:00 UTC up to commit |
…ton (BL-16514) - BookUpload.UseSandboxByDefault: revert the temporary DEBUG override so debug builds default to the sandbox again (flagged by all three reviewers as an accidental production-upload risk). - Register AvatarCache as SingleInstance instead of InstancePerLifetimeScope, so its shared in-memory known-photo map and disk cache can't be duplicated by a child-scope resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…6514) Autofac was injecting the registered editableCollectionDirectory string into AvatarCache's optional `cacheFolder` constructor parameter, so the avatar cache wrote its files into the user's open collection folder instead of the app-data AvatarCache folder -- polluting the collection and tying the persisted known-photo map to whichever collection happened to be open. Register AvatarCache via a `new AvatarCache()` factory so the parameterless (app-data) path is used. Also accept Firebase's native `photoURL` casing as well as `photoUrl` in the external/login POST, since the website (a separate repo that deploys independently) may forward `user.photoURL` verbatim. Adds a photoURL-casing test and a DI-registration regression test (the latter with a sanity assertion proving the Autofac optional-parameter gotcha is real). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Devin flagged that the HttpResponseMessage returned by s_httpClient.GetAsync was never disposed on any path, leaking its pooled network connection. Since we fetch one avatar per person, a Team Collection with many members could eventually starve the shared HttpClient's connection pool. Wrap it in a using declaration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
[Claude Opus 4.8] Consulted Devin on 2026-07-08 (23:03 UTC) up to commit |
|
[Claude Opus 4.8] Preflight decision record — two Devin/local-review items reviewed and deliberately left as-is:
|
Replace the stacked ternary for photoUrl/photoURL casing with an if/else-if chain per review feedback, and add the no-nested-ternary rule to AGENTS.md so it's documented as a codebase convention.
hatton
left a comment
There was a problem hiding this comment.
@hatton reviewed 6 files and all commit messages.
Reviewable status: 6 of 18 files reviewed, 1 unresolved discussion.
StephenMcConnel
left a comment
There was a problem hiding this comment.
@StephenMcConnel reviewed 19 files and all commit messages, and made 1 comment.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on hatton).
StephenMcConnel
left a comment
There was a problem hiding this comment.
@StephenMcConnel resolved 1 discussion.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on hatton).
[Claude Opus 4.8]
What this does
Makes Bloom Library login an app-wide concern (instead of living only on the Publish-to-Web screen) and shows the signed-in user's avatar in the workspace top bar.
Login
accountAPI —account/status,account/login,account/logout— exposes and controls the Bloom Library login state (backed byBloomLibraryBookApiClient). State changes are broadcast over anaccountwebsocket so every screen stays in sync; the front end consumes this through theuseLoginStatehook.AccountMenuin the top bar shows a Sign in button when signed out, and when signed in shows the user's avatar; clicking it opens a menu with the email and a Sign out item.external/login.Avatars
GET /bloom/api/avatar?email=<email>.BloomAvatarpoints its image at this endpoint, so the account menu and Team Collection member avatars both go through it.react-avatarshows generated initials.photoUrlin theexternal/logincallback (companion PR: Send Google/Firebase profile picture to Bloom editor on login (BL-16516) BloomLibrary2#615). Bloom stores it keyed by the user's email. This is currently the only way a "known" provider photo gets populated, so team members who have never logged in on this machine resolve via Gravatar.Compatibility
The
photoUrlfield on theexternal/logincallback is optional. Bloom and the website deploy independently in either order: an older website just omits the field (Bloom falls back to Gravatar), and an older Bloom ignores it. No coordinated deploy is required.Tests
New C# tests cover the avatar cache (source precedence, once-per-run refresh, offline fallback to cached bytes, and persistence/clearing of the known-photo map) and the
avatar/external/loginendpoints.Ref: BL-16514
Devin review
This change is