diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4d722540..51dd3b49 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 137a7b81..c6bec86c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1059,7 +1059,11 @@ "messages": { "olderHidden": "在此公开视图中,更早的消息已隐藏。", "emptyTitle": "暂无消息", - "emptyText": "此 Pod 目前很安静。请稍后再来看看。" + "emptyText": "此 Pod 目前很安静。请稍后再来看看。", + "loadOlder": "加载更早的消息", + "loadingOlder": "加载中…", + "copyLink": "复制此消息的链接", + "linkCopied": "已复制" }, "footer": { "title": "这是一个真实的 Commonly Pod。", diff --git a/frontend/src/v2/showcase/V2Showcase.tsx b/frontend/src/v2/showcase/V2Showcase.tsx index 8a998bd1..f79b50d0 100644 --- a/frontend/src/v2/showcase/V2Showcase.tsx +++ b/frontend/src/v2/showcase/V2Showcase.tsx @@ -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'; @@ -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= 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 @@ -143,6 +147,18 @@ 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(); + 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 = () => { @@ -150,13 +166,25 @@ const V2Showcase: React.FC = () => { 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('loading'); const [info, setInfo] = useState(null); const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [highlightId, setHighlightId] = useState(null); + const [copiedId, setCopiedId] = useState(null); const messagesEndRef = useRef(null); const didInitialScrollRef = useRef(false); + // Latest messages, readable from the poll/loadOlder callbacks without + // re-creating them on every render. + const messagesRef = useRef([]); + 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 @@ -173,20 +201,45 @@ const V2Showcase: React.FC = () => { } }, [podId]); - const fetchMessages = useCallback(async (): Promise => { + // 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 => { try { const res = await getShowcaseClient().get( `/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 => { + const oldest = messagesRef.current[0]; + if (!oldest) return; + setLoadingOlder(true); + try { + const res = await getShowcaseClient().get( + `/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; @@ -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; }; @@ -215,6 +268,9 @@ const V2Showcase: React.FC = () => { // the scroll position. useEffect(() => { if (state !== 'ready' || didInitialScrollRef.current || messages.length === 0) return; + // A ?m= 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. @@ -223,6 +279,43 @@ const V2Showcase: React.FC = () => { } }, [state, messages.length]); + // Permalink target (?m=). 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 = (
@@ -345,7 +438,16 @@ const V2Showcase: React.FC = () => { no identity inspector. V2MessageBubble with no handlers is inert. */}
{hasMore && ( -
{t('showcase.messages.olderHidden')}
+
+ +
)} {messages.length === 0 ? (
@@ -353,9 +455,27 @@ const V2Showcase: React.FC = () => {
{t('showcase.messages.emptyText')}
) : ( - messages.map((m) => ( - - )) + messages.map((m) => { + const mid = String(m.id); + return ( +
+ + +
+ ); + }) )}
diff --git a/frontend/src/v2/showcase/__tests__/V2Showcase.test.tsx b/frontend/src/v2/showcase/__tests__/V2Showcase.test.tsx index 65c78b75..bbccf44d 100644 --- a/frontend/src/v2/showcase/__tests__/V2Showcase.test.tsx +++ b/frontend/src/v2/showcase/__tests__/V2Showcase.test.tsx @@ -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'; @@ -37,8 +37,8 @@ jest.mock('../../../context/AuthContext', () => ({ useAuth: () => ({ token: null, currentUser: null, isAuthenticated: false }), })); -const renderAt = (podId: string) => render( - +const renderAt = (podId: string, search = '') => render( + } /> landing-page} /> @@ -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 } }); diff --git a/frontend/src/v2/showcase/v2-showcase.css b/frontend/src/v2/showcase/v2-showcase.css index 629c03bb..bd2288a5 100644 --- a/frontend/src/v2/showcase/v2-showcase.css +++ b/frontend/src/v2/showcase/v2-showcase.css @@ -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; } +}