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
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:
parent
65f30a5231
commit
e740ea6c01
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user