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
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,11 @@
"messages": {
"olderHidden": "Earlier messages are hidden in this public view.",
"emptyTitle": "No messages yet",
"emptyText": "This room is quiet right now. Check back soon."
"emptyText": "This room is quiet right now. Check back soon.",
"loadOlder": "Load older messages",
"loadingOlder": "Loading…",
"copyLink": "Copy link to this message",
"linkCopied": "Copied"
},
"footer": {
"title": "This is a real Commonly room.",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,11 @@
"messages": {
"olderHidden": "在此公开视图中,更早的消息已隐藏。",
"emptyTitle": "暂无消息",
"emptyText": "此 Pod 目前很安静。请稍后再来看看。"
"emptyText": "此 Pod 目前很安静。请稍后再来看看。",
"loadOlder": "加载更早的消息",
"loadingOlder": "加载中…",
"copyLink": "复制此消息的链接",
"linkCopied": "已复制"
},
"footer": {
"title": "这是一个真实的 Commonly Pod。",
Expand Down
138 changes: 129 additions & 9 deletions frontend/src/v2/showcase/V2Showcase.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import getApiBaseUrl from '../../utils/apiBaseUrl';
Expand Down Expand Up @@ -39,6 +39,10 @@ const getShowcaseClient = () => {
// supported, so freshness comes from a light poll rather than a live socket.
const POLL_INTERVAL_MS = 12000;
const MESSAGE_LIMIT = 50;
// Bound the auto-paging that hunts for a ?m=<id> permalink target. A link to a
// message that no longer exists must not page the whole room into memory.
const MAX_AUTO_PAGES = 20;
const HIGHLIGHT_MS = 2600;

// TODO(showcase): point this at the curated public demo pod once it is seeded
// and toggled publicRead on each instance. Overridable per-deploy via
Expand Down Expand Up @@ -143,20 +147,44 @@ const toV2Message = (m: ShowcaseMessage): V2Message => {
};
};

// The poll used to replace the whole list. Once older pages can be loaded that
// would silently discard them every POLL_INTERVAL_MS, so both paths merge by id
// and re-sort chronologically instead.
const mergeById = (a: ShowcaseMessage[], b: ShowcaseMessage[]): ShowcaseMessage[] => {
const byId = new Map<string, ShowcaseMessage>();
for (const m of a) byId.set(String(m.id), m);
for (const m of b) byId.set(String(m.id), m);
return [...byId.values()].sort(
(x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime(),
);
};

type LoadState = 'loading' | 'ready' | 'not-public' | 'error';

const V2Showcase: React.FC = () => {
const { t } = useTranslation();
const { podId: podIdParam } = useParams<{ podId: string }>();
const podId = podIdParam || DEFAULT_SHOWCASE_POD_ID;

const [searchParams] = useSearchParams();
const targetId = searchParams.get('m');

const [state, setState] = useState<LoadState>('loading');
const [info, setInfo] = useState<ShowcaseInfo | null>(null);
const [messages, setMessages] = useState<ShowcaseMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [highlightId, setHighlightId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);

const messagesEndRef = useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = useRef(false);
// Latest messages, readable from the poll/loadOlder callbacks without
// re-creating them on every render.
const messagesRef = useRef<ShowcaseMessage[]>([]);
const autoPagesRef = useRef(0);
const targetDoneRef = useRef(false);
messagesRef.current = messages;

// Fetch the pod info + member/agent roster. Drives the load state: a 404 here
// means the pod is missing OR not publicRead (the backend returns the same 404
Expand All @@ -173,20 +201,45 @@ const V2Showcase: React.FC = () => {
}
}, [podId]);

const fetchMessages = useCallback(async (): Promise<void> => {
// Newest page. `initial` is the only path allowed to set hasMore: on a poll the
// flag describes the newest page's tail, not how far back we have actually
// paged, so letting a poll write it would resurrect the "load older" button
// after the visitor had reached the beginning.
const fetchMessages = useCallback(async (initial = false): Promise<void> => {
try {
const res = await getShowcaseClient().get<ShowcaseMessagesResponse>(
`/api/showcase/${podId}/messages`,
{ params: { limit: MESSAGE_LIMIT } },
);
setMessages(Array.isArray(res.data?.messages) ? res.data.messages : []);
setHasMore(Boolean(res.data?.hasMore));
const next = Array.isArray(res.data?.messages) ? res.data.messages : [];
setMessages((prev) => (prev.length === 0 ? next : mergeById(prev, next)));
if (initial) setHasMore(Boolean(res.data?.hasMore));
} catch {
// A transient message-fetch failure shouldn't blow away a room that
// already loaded — keep the last good list and let the next poll retry.
}
}, [podId]);

// One page further back, anchored on the oldest message we currently hold.
const loadOlder = useCallback(async (): Promise<void> => {
const oldest = messagesRef.current[0];
if (!oldest) return;
setLoadingOlder(true);
try {
const res = await getShowcaseClient().get<ShowcaseMessagesResponse>(
`/api/showcase/${podId}/messages`,
{ params: { limit: MESSAGE_LIMIT, before: oldest.createdAt } },
);
const older = Array.isArray(res.data?.messages) ? res.data.messages : [];
setMessages((prev) => mergeById(older, prev));
setHasMore(Boolean(res.data?.hasMore) && older.length > 0);
} catch {
// Leave hasMore alone so the visitor can retry the same button.
} finally {
setLoadingOlder(false);
}
}, [podId]);

// Initial load: info first (gates everything), then the conversation.
useEffect(() => {
let cancelled = false;
Expand All @@ -197,7 +250,7 @@ const V2Showcase: React.FC = () => {
(async () => {
const ok = await fetchInfo();
if (cancelled || !ok) return;
await fetchMessages();
await fetchMessages(true);
if (!cancelled) setState('ready');
})();
return () => { cancelled = true; };
Expand All @@ -215,6 +268,9 @@ const V2Showcase: React.FC = () => {
// the scroll position.
useEffect(() => {
if (state !== 'ready' || didInitialScrollRef.current || messages.length === 0) return;
// A ?m=<id> visitor is being sent to a specific message — don't yank them to
// the bottom first. The permalink effect below owns the scroll in that case.
if (targetId) { didInitialScrollRef.current = true; return; }
didInitialScrollRef.current = true;
// Guard: jsdom (tests) and some non-DOM environments don't implement
// scrollIntoView, so calling it unguarded throws inside this effect.
Expand All @@ -223,6 +279,43 @@ const V2Showcase: React.FC = () => {
}
}, [state, messages.length]);

// Permalink target (?m=<id>). If it isn't in the loaded window yet, page
// backwards until we find it — bounded by MAX_AUTO_PAGES so a stale link can't
// walk the entire room. Once found, centre it and flash a highlight.
useEffect(() => {
if (state !== 'ready' || !targetId || targetDoneRef.current) return;
const found = messages.some((m) => String(m.id) === String(targetId));
if (found) {
targetDoneRef.current = true;
const el = document.getElementById(`msg-${targetId}`);
if (typeof el?.scrollIntoView === 'function') el.scrollIntoView({ block: 'center' });
setHighlightId(String(targetId));
return;
}
if (hasMore && !loadingOlder && autoPagesRef.current < MAX_AUTO_PAGES) {
autoPagesRef.current += 1;
loadOlder();
} else if (!hasMore) {
// Walked back to the beginning without finding it — stop looking.
targetDoneRef.current = true;
}
}, [state, messages, targetId, hasMore, loadingOlder, loadOlder]);

// Let the highlight fade rather than sit there permanently.
useEffect(() => {
if (!highlightId) return undefined;
const id = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS);
return () => window.clearTimeout(id);
}, [highlightId]);

const copyLink = useCallback((messageId: string) => {
const url = `${window.location.origin}${window.location.pathname}?m=${encodeURIComponent(messageId)}`;
const done = () => { setCopiedId(messageId); window.setTimeout(() => setCopiedId(null), 1600); };
// clipboard is unavailable over plain http and in jsdom; fall back silently.
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(url).then(done).catch(() => {});
else done();
}, []);

const topBar = (
<header className="v2-showcase__bar">
<Link className="v2-showcase__brand" to="/v2/landing" aria-label={t('showcase.nav.brandHome')}>
Expand Down Expand Up @@ -345,17 +438,44 @@ const V2Showcase: React.FC = () => {
no identity inspector. V2MessageBubble with no handlers is inert. */}
<section className="v2-showcase__messages">
{hasMore && (
<div className="v2-showcase__older-note">{t('showcase.messages.olderHidden')}</div>
<div className="v2-showcase__older">
<button
type="button"
className="v2-showcase__older-btn"
onClick={() => { autoPagesRef.current = 0; loadOlder(); }}
disabled={loadingOlder}
>
{loadingOlder ? t('showcase.messages.loadingOlder') : t('showcase.messages.loadOlder')}
</button>
</div>
)}
{messages.length === 0 ? (
<div className="v2-showcase__empty">
<div className="v2-showcase__empty-title">{t('showcase.messages.emptyTitle')}</div>
<div className="v2-showcase__empty-text">{t('showcase.messages.emptyText')}</div>
</div>
) : (
messages.map((m) => (
<V2MessageBubble key={m.id} message={toV2Message(m)} />
))
messages.map((m) => {
const mid = String(m.id);
return (
<div
key={mid}
id={`msg-${mid}`}
className={`v2-showcase__msg${highlightId === mid ? ' v2-showcase__msg--target' : ''}`}
>
<V2MessageBubble message={toV2Message(m)} />
<button
type="button"
className="v2-showcase__msg-link"
onClick={() => copyLink(mid)}
aria-label={t('showcase.messages.copyLink')}
title={t('showcase.messages.copyLink')}
>
{copiedId === mid ? t('showcase.messages.linkCopied') : '#'}
</button>
</div>
);
})
)}
<div ref={messagesEndRef} />
</section>
Expand Down
56 changes: 53 additions & 3 deletions frontend/src/v2/showcase/__tests__/V2Showcase.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-nocheck
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import V2Showcase from '../V2Showcase';

Expand Down Expand Up @@ -37,8 +37,8 @@ jest.mock('../../../context/AuthContext', () => ({
useAuth: () => ({ token: null, currentUser: null, isAuthenticated: false }),
}));

const renderAt = (podId: string) => render(
<MemoryRouter initialEntries={[`/v2/showcase/${podId}`]}>
const renderAt = (podId: string, search = '') => render(
<MemoryRouter initialEntries={[`/v2/showcase/${podId}${search}`]}>
<Routes>
<Route path="/v2/showcase/:podId" element={<V2Showcase />} />
<Route path="/v2/landing" element={<div>landing-page</div>} />
Expand Down Expand Up @@ -106,6 +106,56 @@ describe('V2Showcase', () => {
expect(screen.queryByPlaceholderText(/Message/i)).not.toBeInTheDocument();
});

test('load older fetches with a before cursor and prepends; a poll must not discard them', async () => {
const info = { pod: { name: 'Room', memberCount: 2 }, agents: [] };
const newest = [{ id: '10', content: 'newest-msg', author: { username: 'sam' }, createdAt: '2026-07-20T00:00:00Z' }];
const older = [{ id: '1', content: 'ancient-msg', author: { username: 'sam' }, createdAt: '2026-07-01T00:00:00Z' }];
mockGet.mockImplementation((url, cfg = {}) => {
if (String(url).endsWith('/messages')) {
return Promise.resolve({
data: cfg?.params?.before ? { messages: older, hasMore: false } : { messages: newest, hasMore: true },
});
}
return Promise.resolve({ data: info });
});

renderAt('abc');
await screen.findByText('newest-msg');
const btn = await screen.findByRole('button', { name: /load older/i });
await act(async () => { btn.click(); });

await screen.findByText('ancient-msg');
expect(screen.getByText('newest-msg')).toBeInTheDocument();

const paged = mockGet.mock.calls.find((c) => c[1]?.params?.before);
expect(paged[1].params.before).toBe('2026-07-20T00:00:00Z');

await waitFor(() => {
expect(screen.queryByRole('button', { name: /load older/i })).not.toBeInTheDocument();
});
});

test('pages back to find a ?m= permalink target, then highlights it', async () => {
const info = { pod: { name: 'Room', memberCount: 2 }, agents: [] };
const newest = [{ id: '10', content: 'newest-msg', author: { username: 'sam' }, createdAt: '2026-07-20T00:00:00Z' }];
const older = [{ id: '3', content: 'target-msg', author: { username: 'sam' }, createdAt: '2026-07-02T00:00:00Z' }];
mockGet.mockImplementation((url, cfg = {}) => {
if (String(url).endsWith('/messages')) {
return Promise.resolve({
data: cfg?.params?.before ? { messages: older, hasMore: false } : { messages: newest, hasMore: true },
});
}
return Promise.resolve({ data: info });
});

renderAt('abc', '?m=3');

await screen.findByText('target-msg');
await waitFor(() => {
expect(document.getElementById('msg-3')?.className).toContain('v2-showcase__msg--target');
});
});

test('renders the not-public state on a 404', async () => {
mockGet.mockRejectedValue({ response: { status: 404 } });

Expand Down
47 changes: 47 additions & 0 deletions frontend/src/v2/showcase/v2-showcase.css
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,50 @@
.v2-showcase__main { padding: 20px 16px 56px; }
.v2-showcase__room-name { font-size: 18px; }
}

/* ---------- History paging + per-message permalinks ---------- */
/* The room previously told visitors older messages existed and gave them no way
to reach them. `hasMore` now drives a real control. */
.v2-showcase__older { display: flex; justify-content: center; padding: 4px 0 14px; }
.v2-showcase__older-btn {
padding: 7px 16px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--v2-text-secondary);
background: var(--v2-surface);
border: 1px solid var(--v2-border);
border-radius: 999px;
cursor: pointer;
}
.v2-showcase__older-btn:hover:not(:disabled) { color: var(--v2-text-primary); border-color: var(--v2-text-tertiary); }
.v2-showcase__older-btn:disabled { opacity: 0.6; cursor: default; }

/* Wrapper exists so a message can be an anchor target and carry a hover control
without touching V2MessageBubble, which stays inert/read-only by construction. */
.v2-showcase__msg { position: relative; scroll-margin-top: 84px; border-radius: var(--v2-radius-lg); }
.v2-showcase__msg-link {
position: absolute;
top: 4px;
right: 4px;
padding: 2px 8px;
font: inherit;
font-size: 12px;
line-height: 1.6;
color: var(--v2-text-tertiary);
background: var(--v2-surface);
border: 1px solid var(--v2-border-soft);
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease;
}
.v2-showcase__msg:hover .v2-showcase__msg-link,
.v2-showcase__msg-link:focus-visible { opacity: 1; }
.v2-showcase__msg-link:hover { color: var(--v2-text-primary); }

/* Flash, don't shout — the target fades back to normal after a moment. */
.v2-showcase__msg--target { background: var(--v2-accent-soft); box-shadow: 0 0 0 2px var(--v2-accent) inset; }
@media (prefers-reduced-motion: no-preference) {
.v2-showcase__msg--target { transition: background 0.4s ease, box-shadow 0.4s ease; }
}
Loading