Move live presence into the TopBar, signed-in only (#102)
All checks were successful
CD / Build and push images (push) Successful in 1m39s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 3m33s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m31s
CI / Import/export fidelity gate (push) Successful in 46s

- the TopBar registers a presence slot (only rendered for signed-in
  users) next to the page-actions slot; PageEditor portals the
  PresenceStrip into it — behavior unchanged (initials avatars, max 5 +
  overflow, viewer badge, hidden when empty, both view and edit mode)
- pinned guarantee: public.spec asserts the anonymous read path opens no
  /collab websocket and renders no presence data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 04:42:59 +02:00
parent 65f30a5231
commit e740ea6c01
6 changed files with 48 additions and 11 deletions

View File

@ -29,12 +29,21 @@ test('an anonymous visitor reads a public page and its image via the SPA', async
try { try {
const anon = await browser.newContext({ baseURL: BASE_URL }); // no session const anon = await browser.newContext({ baseURL: BASE_URL }); // no session
const page = await anon.newPage(); const page = await anon.newPage();
// Pinned guarantee (#102): the anonymous/public read path never opens an
// awareness/presence connection and never renders presence data.
const collabSockets: string[] = [];
page.on('websocket', (ws) => {
if (ws.url().includes('/collab')) collabSockets.push(ws.url());
});
await page.goto('/public/content-fixtures/fixture-image'); await page.goto('/public/content-fixtures/fixture-image');
// The read-only public view renders — with no collaborative editor. // The read-only public view renders — with no collaborative editor.
await expect(page.locator('.public-page__badge')).toBeVisible(); await expect(page.locator('.public-page__badge')).toBeVisible();
await expect(page.locator('.public-page__title')).toContainText('Fixture Image'); await expect(page.locator('.public-page__title')).toContainText('Fixture Image');
await expect(page.locator('.ProseMirror')).toHaveCount(0); await expect(page.locator('.ProseMirror')).toHaveCount(0);
await expect(page.locator('.presence-strip')).toHaveCount(0);
await expect(page.locator('.presence-avatar')).toHaveCount(0);
expect(collabSockets, `awareness sockets on the public path: ${collabSockets}`).toEqual([]);
// The embedded image streams to the anonymous visitor (media honors public): // The embedded image streams to the anonymous visitor (media honors public):
// fetch its resolved /media URL from the same session-less context. // fetch its resolved /media URL from the same session-less context.

View File

@ -19,12 +19,18 @@ export function AppLayout(): React.JSX.Element {
const collapsed = sidebarCollapsed || forcedHidden; const collapsed = sidebarCollapsed || forcedHidden;
// Clamp on read too — the persisted value may predate a bounds change. // Clamp on read too — the persisted value may predate a bounds change.
const widthRem = clampSidebarWidth(sidebarWidth); const widthRem = clampSidebarWidth(sidebarWidth);
// The TopBar registers its page-actions element here; the active page // The TopBar registers its page-scoped elements here; the active page
// portals its icon actions into it (issue #101). // portals its icon actions (#101) and presence strip (#102) into them.
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null); const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
const actionsSlot = useMemo( const actionsSlot = useMemo(
() => ({ element: actionsElement, setElement: setActionsElement }), () => ({
[actionsElement], element: actionsElement,
setElement: setActionsElement,
presenceElement,
setPresenceElement,
}),
[actionsElement, presenceElement],
); );
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of // Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of

View File

@ -27,7 +27,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
const navigate = useNavigate(); const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const { setElement: setPageActionsElement } = usePageActionsSlot(); const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot();
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
setMenuOpen(false); setMenuOpen(false);
@ -64,7 +64,10 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
</Link> </Link>
{user && <PondSwitcher />} {user && <PondSwitcher />}
<span className="topbar__spacer" /> <span className="topbar__spacer" />
{/* Pages portal their icon actions here while active (issue #101). */} {/* Page-scoped slots, rendered only for signed-in users: live presence
(#102) and the page's icon actions (#101) portal in while a page
route is active. */}
{user && <div className="topbar__presence" ref={setPresenceElement} />}
{user && <div className="topbar__page-actions" ref={setPageActionsElement} />} {user && <div className="topbar__page-actions" ref={setPageActionsElement} />}
{user && ( {user && (
<button <button

View File

@ -1,19 +1,26 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
/** /**
* The TopBar's page-actions slot (issue #101). The TopBar registers a DOM * The TopBar's page-scoped slots (issues #101/#102). The TopBar registers
* element here; the active page portals its icon actions into it, so the * DOM elements here; the active page portals its icon actions (and the
* TopBar itself stays page-agnostic. `null` outside page routes (or before * editor its presence strip) into them, so the TopBar itself stays
* the TopBar has mounted) consumers simply render nothing then. * page-agnostic. `null` outside page routes (or before the TopBar has
* mounted) consumers simply render nothing then. The TopBar only renders
* the elements for signed-in users, so nothing page-scoped can ever appear
* for an anonymous session.
*/ */
export interface PageActionsSlot { export interface PageActionsSlot {
element: HTMLElement | null; element: HTMLElement | null;
setElement: (element: HTMLElement | null) => void; setElement: (element: HTMLElement | null) => void;
presenceElement: HTMLElement | null;
setPresenceElement: (element: HTMLElement | null) => void;
} }
export const PageActionsSlotContext = createContext<PageActionsSlot>({ export const PageActionsSlotContext = createContext<PageActionsSlot>({
element: null, element: null,
setElement: () => {}, setElement: () => {},
presenceElement: null,
setPresenceElement: () => {},
}); });
export function usePageActionsSlot(): PageActionsSlot { export function usePageActionsSlot(): PageActionsSlot {

View File

@ -83,6 +83,7 @@ function PageEditor({
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { presenceElement } = usePageActionsSlot();
// Created and destroyed within the same effect (not `useMemo` + a separate // Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@ -213,7 +214,12 @@ function PageEditor({
<div className="editor-connection" role="status" data-status={collab.status}> <div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)} {t(`connection.${collab.status}`)}
</div> </div>
<PresenceStrip provider={collab.provider} /> {/* Live presence renders in the TopBar next to the page actions
(#102). The slot only exists for signed-in users, and the
public read view never mounts this editor (or any awareness
connection) at all. */}
{presenceElement &&
createPortal(<PresenceStrip provider={collab.provider} />, presenceElement)}
{collab.localOnly && ( {collab.localOnly && (
<div className="editor-banner editor-banner--info" role="note"> <div className="editor-banner editor-banner--info" role="note">
{t('offline.localOnly')} {t('offline.localOnly')}

View File

@ -284,6 +284,12 @@ button {
align-items: center; align-items: center;
} }
/* Live presence next to the page actions (issue #102). */
.topbar__presence {
display: flex;
align-items: center;
}
.page-actions { .page-actions {
display: flex; display: flex;
align-items: center; align-items: center;