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
5 changes: 5 additions & 0 deletions .changeset/title-share-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Use post and session titles for browser tab and share-page titles.
2 changes: 1 addition & 1 deletion e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export { expect };

export async function publish(
serverUrl: string,
body: { html: string; title?: string; agent?: string; session?: string },
body: { html: string; title?: string; agent?: string; session?: string; sessionTitle?: string },
token?: string,
): Promise<{ id: string; sessionId: string; version: number }> {
const headers: Record<string, string> = { "content-type": "application/json" };
Expand Down
8 changes: 7 additions & 1 deletion e2e/public-read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ test("readonly session-mode viewer loads without fetching the session list", asy
try {
const surface = await publish(
server.url,
{ html: "<p>session scoped</p>", title: "Session scoped", agent: "e2e" },
{
html: "<p>session scoped</p>",
title: "Session scoped",
agent: "e2e",
sessionTitle: "Auth refactor",
},
token,
);
const sessionListRequests: string[] = [];
Expand All @@ -56,6 +61,7 @@ test("readonly session-mode viewer loads without fetching the session list", asy

await page.goto(`${server.url}/session/${surface.sessionId}`);

await expect(page).toHaveTitle("Auth refactor · sideshow");
await expect(page.locator(".card:not(#whatsNew)")).toBeVisible();
await expect(page.locator(".card-title")).toContainText("Session scoped");
expect(sessionListRequests).toEqual([]);
Expand Down
44 changes: 44 additions & 0 deletions e2e/url-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,50 @@ test("navigating to /session/:id selects that session", async ({ page, server })
await expect(page.locator(".card .card-title")).toHaveText("Second");
});

test("the browser title follows the selected session", async ({ page, server }) => {
const s1 = await publish(server.url, {
html: "<p>one</p>",
title: "First post",
agent: "a1",
sessionTitle: "Auth refactor",
});
const s2 = await publish(server.url, {
html: "<p>two</p>",
title: "Second post",
agent: "a2",
sessionTitle: "Release prep",
});

await page.goto(`${server.url}/session/${s1.sessionId}`);
await expect(page).toHaveTitle("Auth refactor · sideshow");

await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click();
await expect(page).toHaveTitle("Release prep · sideshow");
});

test("the standalone share page title uses the shared post title", async ({ page, server }) => {
const post = await publish(server.url, {
html: "<p>one</p>",
title: "First post",
agent: "a1",
sessionTitle: "Auth refactor",
});
const sessionListRequests: string[] = [];
page.on("request", (req) => {
const url = new URL(req.url());
if (req.method() === "GET" && url.pathname === "/api/sessions") {
sessionListRequests.push(req.url());
}
});

await page.goto(`${server.url}/s/${post.id}`);
await expect(page).toHaveTitle("First post");

await publish(server.url, { html: "<p>two</p>", title: "Other work", agent: "a2" });
await expect.poll(() => sessionListRequests.length).toBeGreaterThan(0);
await expect(page).toHaveTitle("First post");
});

test("navigating to /session/:id/s/:surfaceId selects session and scrolls to surface", async ({
page,
server,
Expand Down
6 changes: 3 additions & 3 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,13 @@ test("activity in an unselected session badges the tab title until viewed", asyn
await publish(server.url, { html: "<p>a</p>", title: "First", agent: "one" });

await page.goto(server.url);
await expect(page).toHaveTitle("sideshow");
await expect(page).toHaveTitle("one session · sideshow");

await publish(server.url, { html: "<p>b</p>", title: "Second", agent: "two" });

await expect(page).toHaveTitle("(1) sideshow");
await expect(page).toHaveTitle("(1) one session · sideshow");
await page.locator(".sess", { hasText: "two" }).click();
await expect(page).toHaveTitle("sideshow");
await expect(page).toHaveTitle("two session · sideshow");
});

test("Cmd+Option+Up/Down switches between sessions, wrapping at the ends", async ({
Expand Down
57 changes: 45 additions & 12 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type MarkdownSurface,
MAX_ASSET_BYTES,
surfacesByteLength,
type Session,
type Store,
type Post,
type Surface,
Expand Down Expand Up @@ -651,9 +652,32 @@ export function createApp({
: `${head}${text}`;
};

const withViewerConfig = (text: string, request: Request, isReadonly: boolean) => {
const withDocumentTitle = (text: string, title: string | null | undefined) => {
if (!title) return text;
const escaped = escapeHtml(title);
const titleTag = `<title>${escaped}</title>`;
const titleStart = text.indexOf("<title>");
if (titleStart < 0) return injectHead(text, titleTag);
const titleEnd = text.indexOf("</title>", titleStart + "<title>".length);
if (titleEnd < 0) return injectHead(text, titleTag);
return `${text.slice(0, titleStart)}${titleTag}${text.slice(titleEnd + "</title>".length)}`;
};

const sessionDocumentTitle = (session: Session | null | undefined) => {
if (!session) return null;
const label = session.title || (session.agent ? `${session.agent} session` : null);
return label ? `${label} · sideshow` : null;
};

const withViewerConfig = (
text: string,
request: Request,
isReadonly: boolean,
pageTitle?: string | null,
) => {
const config = [
`window.__SIDESHOW_BASE_PATH__=${JSON.stringify(requestBasePath(request))};`,
pageTitle ? `window.__SIDESHOW_PAGE_TITLE__=${JSON.stringify(pageTitle)};` : "",
isReadonly ? "window.__SIDESHOW_READONLY__=true;" : "",
isReadonly && publicRead
? `window.__SIDESHOW_PUBLIC_READ__=${JSON.stringify(publicRead)};`
Expand Down Expand Up @@ -686,31 +710,40 @@ export function createApp({
].join("\n");
};

const configuredViewerHtml = (c: Context, surface?: Post) => {
const configured = withViewerConfig(
withOrigin(viewerHtml, { req: { url: c.req.url } }),
c.req.raw,
!!publicRead && !isAuthenticated(c),
const configuredViewerHtml = (
c: Context,
opts: { surface?: Post; title?: string | null } = {},
) => {
const pageTitle = opts.surface?.title ?? opts.title;
const html = withDocumentTitle(
withViewerConfig(
withOrigin(viewerHtml, { req: { url: c.req.url } }),
c.req.raw,
!!publicRead && !isAuthenticated(c),
pageTitle,
),
pageTitle,
);
return surface ? injectHead(configured, surfacePreviewHead(surface, c.req.raw)) : configured;
return opts.surface ? injectHead(html, surfacePreviewHead(opts.surface, c.req.raw)) : html;
};
app.get("/", (c) => c.html(configuredViewerHtml(c)));
app.get("/session/:id", async (c) => {
if (isUnauthenticatedSessionRead(c) && !(await store.getSession(c.req.param("id")))) {
const session = await store.getSession(c.req.param("id"));
if (isUnauthenticatedSessionRead(c) && !session) {
return c.text("Session not found", 404);
}
return c.html(configuredViewerHtml(c));
return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
});
const sessionSurfacePage = async (c: any) => {
const session = await store.getSession(c.req.param("id"));
if (isUnauthenticatedSessionRead(c)) {
const session = await store.getSession(c.req.param("id"));
const surfaceId = c.req.param("surfaceId") ?? c.req.param("postId");
const surface = await store.getPost(surfaceId ?? "");
if (!session || !surface || surface.sessionId !== session.id) {
return c.text("Session or surface not found", 404);
}
}
return c.html(configuredViewerHtml(c));
return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
};
app.get("/session/:id/s/:surfaceId", sessionSurfacePage);
app.get("/session/:id/p/:postId", sessionSurfacePage); // canonical alias
Expand Down Expand Up @@ -1098,7 +1131,7 @@ export function createApp({
const surface = await store.getPost(c.req.param("id"));
if (!surface) return c.text("Post not found", 404);
const partParam = c.req.query("surface") ?? c.req.query("part");
if (partParam == null) return c.html(configuredViewerHtml(c, surface));
if (partParam == null) return c.html(configuredViewerHtml(c, { surface }));

const ver = c.req.query("ver");
let title = surface.title;
Expand Down
18 changes: 18 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ test("GET /s/:id serves the viewer shell with link-preview metadata", async () =
const body = await page.text();
assert.ok(body.includes("viewer"), "should serve the trusted viewer shell");
assert.doesNotMatch(body, /<p>diagram<\/p>/, "should not inline agent HTML");
assert.match(body, /<title>Auth Flow<\/title>/);
assert.match(body, /<meta property="og:title" content="Auth Flow">/);
assert.match(body, /<meta name="twitter:title" content="Auth Flow">/);
assert.match(body, /<meta property="og:description" content="A https:\/\/sideshow\.sh surface">/);
Expand All @@ -192,6 +193,22 @@ test("GET /s/:id serves the viewer shell with link-preview metadata", async () =
assert.doesNotMatch(body, /Secret session/);
});

test("GET /session/:id serves the viewer shell with the session title", async () => {
const app = makeApp();
const res = await app.request(
"/api/snippets",
json({ html: "<p>x</p>", title: "Post", sessionTitle: "Auth refactor" }),
);
const surface = (await res.json()) as any;

const page = await app.request(`/session/${surface.sessionId}`);
assert.equal(page.status, 200);
assert.ok(page.headers.get("content-type")?.includes("text/html"));
const body = await page.text();
assert.ok(body.includes("viewer"), "should serve the trusted viewer shell");
assert.match(body, /<title>Auth refactor · sideshow<\/title>/);
});

test("GET /s/:id emits absolute token-free canonical and preview image URLs", async () => {
const app = makeApp("secret");
const res = await app.request(
Expand Down Expand Up @@ -246,6 +263,7 @@ test("GET /s/:id escapes surface metadata in preview tags", async () => {
body,
/<meta property="og:title" content="A &quot;quoted&quot; &lt;tag&gt; &amp; more">/,
);
assert.match(body, /<title>A &quot;quoted&quot; &lt;tag&gt; &amp; more<\/title>/);
assert.doesNotMatch(body, /content="A "quoted" <tag> & more"/);
});

Expand Down
23 changes: 20 additions & 3 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show }
import { AgentMark } from "./agentMarks.tsx";
import {
api,
initialPageTitle,
isReadonly,
layoutMode,
relTime,
Expand Down Expand Up @@ -71,6 +72,19 @@ function Brand() {
);
}

function pageTitle(
post: Post | null,
session: SessionRow | undefined,
unreadCount: number,
serverTitle: string | undefined,
) {
if (post) return post.title || "sideshow";
const sessionTitle =
session && (session.title || session.agent) ? `${sessionLabel(session)} · sideshow` : null;
const base = sessionTitle || serverTitle || "sideshow";
return unreadCount > 0 ? `(${unreadCount}) ${base}` : base;
}

export default function App() {
// Escape closes the integrations modal while it is open.
createEffect(() => {
Expand Down Expand Up @@ -141,9 +155,12 @@ export default function App() {
// post instead (set below), so don't fight it here.
createEffect(() => {
if (isShadow()) return;
const solo = standalonePost();
if (solo) document.title = solo.title ? `${solo.title} · sideshow` : "sideshow";
else document.title = unread().size ? `(${unread().size}) sideshow` : "sideshow";
document.title = pageTitle(
standalonePost(),
sessions.find((s) => s.id === selected()),
unread().size,
initialPageTitle(),
);
});
// the mobile drawer slides in via a class on the host element (see styles.css
// `body.nav-open`; self-hosted that element is <body>)
Expand Down
5 changes: 5 additions & 0 deletions viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ declare global {
__SIDESHOW_READONLY__?: boolean;
__SIDESHOW_PUBLIC_READ__?: PublicReadMode;
__SIDESHOW_SCREENSHOTS__?: boolean;
__SIDESHOW_PAGE_TITLE__?: string;
}
}

Expand All @@ -81,6 +82,10 @@ export function publicReadMode(): PublicReadMode | undefined {
return window.__SIDESHOW_PUBLIC_READ__;
}

export function initialPageTitle(): string | undefined {
return window.__SIDESHOW_PAGE_TITLE__;
}

// The engine's layout. "full" shows the sidebar + stream; "stream" shows only
// the current session's stream (no sidebar/session list). An embedder requests
// it through the host; the self-hosted public-read "session" link maps to
Expand Down
Loading