fix(frontend): consolidate chat sidebar and modal auth flow
CI / test (push) Successful in 1m55s
CI / docker (push) Successful in 28s

This commit is contained in:
zwitschi committed 2026-08-16 13:40:16 +02:00
1 parent d267173e30
commit 474da7418e
7 files changed
+145 -125

No files matched your search

-8
View File
@@ -2,14 +2,6 @@
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
## Playout startup performance (2026-08-16)
- Parallelized playlist validation: dedup repeated paths, probe unique files concurrently (`PLAYOUT_VALIDATE_WORKERS` default `runtime.NumCPU()`), run the two `ffprobe` probes concurrently, and validate once in `main()` (removed the redundant `-validate` pre-step in `start-playout.sh`). See CHANGELOG.
## Cloudflare Workers frontend URLs (2026-08-16)
- Viewer `main.ts` uses `VITE_PUBLIC_APP_URL` for all API requests and `VITE_PUBLIC_WS_URL` for WebSocket connections so the Workers-served SPA reaches the app origin cross-origin. See CHANGELOG.
## Test infrastructure & refactors (2026-08-07)
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
+32
View File
@@ -2,6 +2,38 @@
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
## Chat sidebar
- [x] remove search bar from chat sidebar (not needed for single-room)
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
- [x] when user is logged in, do not show login/registration form
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
### Plan
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
1. **Remove search bar** (single-room)
- `index.html`: delete the `<div id="dm-search-row">` block.
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
2. **Remove "Users" toggle button**
- `index.html`: delete the `<button id="users-toggle-btn">` button.
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
3. **Login/registration form as a modal**
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
4. **Hide form when logged in**
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
5. **No duplicate messages**
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
## Frontend Tests
### User Frontend
+2
View File
@@ -18,10 +18,12 @@
- **Cloudflare Workers frontend hosting**: Replaced Cloudflare Pages with a Cloudflare Worker (`linearvoid-frontend`) that serves the static SPA via Workers Static Assets. Added `frontend/wrangler.toml` and renamed the deploy workflow to `.gitea/workflows/workers-deploy.yml` (builds the SPA and runs `npx wrangler deploy`). Architecture, setup/deployment, and ops documentation updated from Pages to Workers.
- **Viewer session cookie cross-site**: The `linearvoid_user_session` cookie now uses `SameSite=None; Secure` over HTTPS (so it is sent cross-site from the Workers-served SPA) and `SameSite=Lax` over plain HTTP (local dev). The admin session cookie remains `SameSite=Strict`.
- **Parallel playout startup validation**: `ValidatePlaylist` now deduplicates repeated paths and probes unique files concurrently through a bounded worker pool (`PLAYOUT_VALIDATE_WORKERS`, default `runtime.NumCPU()`); `ValidateVideo` runs its stream/format and keyframe `ffprobe` probes concurrently; and `main()` performs the strict validation once (removing the redundant `-validate` pre-step in `start-playout.sh`). This sharply cuts startup time on large or duplicated playlists.
- **Chat sidebar cleanup**: Removed the DM search bar and the "Users" toggle button, and converted the login/register form into a modal that opens from "Sign in" only when signed out and is hidden once logged in.
### Fixed
- **Login/register cross-origin API URLs**: `submitAuth` in `frontend/src/main.ts` now prefixes `/api/auth/login` and `/api/auth/register` with `VITE_PUBLIC_APP_URL` (falling back to same-origin), matching the other API calls so the Workers-served SPA reaches the app origin instead of a same-origin path.
- **Duplicate chat history on reconnect/initial load**: `connectWebSocket` now clears `#messages` before each (re)connect, and the initial connect waits for session resolution, so room history renders once instead of being appended repeatedly.
### Tests
+14 -13
View File
@@ -44,29 +44,30 @@
<div class="chat-header-actions">
<span id="chat-user-label" class="chat-user-label" hidden></span>
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</div>
</header>
<div id="auth-panel" class="auth-panel" hidden>
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
<div class="button-row">
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
<div id="auth-panel" class="auth-modal" hidden>
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
<div class="auth-modal-content">
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
<h2 class="auth-modal-title">Sign in</h2>
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
<div class="button-row">
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
</div>
<p id="auth-status" class="status-line" aria-live="polite"></p>
</div>
<p id="auth-status" class="status-line" aria-live="polite"></p>
</div>
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
<section id="messages" class="chat-messages" aria-live="polite"></section>
<div id="dm-search-row" class="dm-search-row" hidden>
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
</div>
<section id="presence-panel" class="presence-panel">
<div id="presence-list" class="presence-list"></div>
</section>
+11 -36
View File
@@ -124,19 +124,18 @@ beforeEach(() => {
<div id="chat-user-label" hidden></div>
<button id="auth-toggle-btn" type="button">Sign in</button>
<button id="auth-logout-btn" type="button" hidden>Logout</button>
<div id="auth-panel" hidden>
<input id="auth-username" type="text" />
<input id="auth-password" type="password" />
<span id="auth-status"></span>
<button id="auth-login-btn" type="button">Login</button>
<button id="auth-register-btn" type="button">Register</button>
<div id="auth-panel" class="auth-modal" hidden>
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
<div class="auth-modal-content">
<button id="auth-close-btn" type="button">×</button>
<input id="auth-username" type="text" />
<input id="auth-password" type="password" />
<span id="auth-status"></span>
<button id="auth-login-btn" type="button">Login</button>
<button id="auth-register-btn" type="button">Register</button>
</div>
</div>
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
<div id="typing-indicator" hidden></div>
<div id="dm-search-row" hidden>
<input id="dm-search-input" type="text" />
<button id="dm-search-btn" type="button">Search</button>
</div>
`;
// Stub prompt for DM
@@ -456,7 +455,7 @@ describe("sendMessage over websocket", () => {
});
});
describe("auth and user list wiring", () => {
describe("auth wiring", () => {
it("logs in and updates the auth UI", async () => {
await import("../src/main");
@@ -486,30 +485,6 @@ describe("auth and user list wiring", () => {
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
});
it("toggles the user list visibility", async () => {
await import("../src/main");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ authenticated: false, entries: [] }),
text: async () => "",
}) as any;
document.dispatchEvent(new Event("DOMContentLoaded"));
const panel = document.getElementById("presence-panel")!;
const btn = document.getElementById("users-toggle-btn")!;
expect(panel.hidden).toBe(false);
btn.click();
expect(panel.hidden).toBe(true);
expect(btn.getAttribute("aria-expanded")).toBe("false");
btn.click();
expect(panel.hidden).toBe(false);
expect(btn.getAttribute("aria-expanded")).toBe("true");
});
});
describe("player error recovery", () => {
+44 -60
View File
@@ -16,9 +16,7 @@ let ws: WebSocket | null = null;
let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000);
let authenticated = false;
let currentDMTarget = "";
let chatVisible = true;
let usersVisible = true;
let typingUser = "";
let lastTypingSent = 0;
let typingTimeout: number | undefined;
@@ -38,7 +36,6 @@ export function resetViewerStateForTests() {
username = "viewer";
authenticated = false;
chatVisible = true;
usersVisible = true;
typingUser = "";
lastTypingSent = 0;
onlineUsers.clear();
@@ -79,6 +76,10 @@ export function resolveManifestUrl() {
}
function connectWebSocket(room: string) {
// Clear previous messages so reconnects render a fresh history instead of
// appending duplicates on top of the old log.
clearMessages();
// Detach the old socket's close handler before closing it. Otherwise an
// intentional reconnect (login/logout/room switch/session refresh) would
// have the replaced socket schedule yet another reconnect, and each new
@@ -137,6 +138,11 @@ function connectWebSocket(room: string) {
};
}
function clearMessages() {
const messages = document.getElementById("messages");
if (messages) messages.innerHTML = "";
}
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
const messages = document.getElementById("messages");
if (!messages) return;
@@ -281,6 +287,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
}
}
// Resolve the user session first so we connect once with the correct username
// (avoids pushing chat history twice on initial load).
await sessionReady;
connectWebSocket("main");
// Start schedule polling
@@ -337,50 +346,34 @@ function sendMessage() {
function switchRoom(room: string) {
currentRoom = room;
const isDM = room.startsWith("dm:");
document.getElementById("room-name")!.textContent =
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
addMessage("system", `Switched to ${room} room`);
const searchRow = document.getElementById("dm-search-row");
if (searchRow) searchRow.hidden = !isDM;
connectWebSocket(room);
}
function togglePrivateDM() {
const target = prompt("Enter username to start private chat with:");
if (target && target !== username) {
currentDMTarget = target;
const dmRoom = `dm:${[username, target].sort().join("-")}`;
switchRoom(dmRoom);
}
}
async function searchDM() {
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
const input = document.getElementById("dm-search-input") as HTMLInputElement;
const q = input.value.trim();
if (!q || !currentDMTarget) return;
try {
const res = await fetch(
`${apiUrl}/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`,
{ credentials: "include" },
);
if (!res.ok) return;
const results = await res.json();
addMessage("system", `Search results (${results.length}) for "${q}":`);
for (const m of results) {
addMessage(m.type || "private", m.text, m.username, m.timestamp);
}
} catch {
/* ignore */
}
}
window.switchRoom = switchRoom;
window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel;
function openAuthModal() {
const panel = document.getElementById("auth-panel");
if (panel) panel.hidden = false;
}
function closeAuthModal() {
const panel = document.getElementById("auth-panel");
if (panel) panel.hidden = true;
}
function updateAuthUI() {
const label = document.getElementById("chat-user-label");
const toggle = document.getElementById("auth-toggle-btn");
@@ -395,25 +388,26 @@ function updateAuthUI() {
if (panel) panel.hidden = true;
}
async function refreshUserSession() {
try {
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const wasAnonymous = username.startsWith("viewer");
if (data.authenticated && data.username) {
authenticated = true;
username = data.username;
updateAuthUI();
if (wasAnonymous) connectWebSocket(currentRoom);
} else {
authenticated = false;
let sessionReady: Promise<void> = Promise.resolve();
function refreshUserSession(): void {
sessionReady = (async () => {
try {
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
if (data.authenticated && data.username) {
authenticated = true;
username = data.username;
} else {
authenticated = false;
}
updateAuthUI();
} catch {
/* non-critical */
}
} catch {
/* non-critical */
}
})();
}
async function submitAuth(path: string) {
@@ -476,15 +470,6 @@ async function logout() {
connectWebSocket(currentRoom);
}
function toggleUserList() {
const panel = document.getElementById("presence-panel");
const btn = document.getElementById("users-toggle-btn");
if (!panel) return;
usersVisible = !usersVisible;
panel.hidden = !usersVisible;
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
}
function showTyping(name: string) {
if (name === username) return;
const el = document.getElementById("typing-indicator");
@@ -521,15 +506,14 @@ function sendTyping() {
document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat();
void refreshUserSession();
refreshUserSession();
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
const panel = document.getElementById("auth-panel");
if (panel) panel.hidden = !panel.hidden;
if (!authenticated) openAuthModal();
});
document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
document.getElementById("auth-login-btn")?.addEventListener("click", login);
document.getElementById("auth-register-btn")?.addEventListener("click", register);
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
document.getElementById("message-input")?.addEventListener("input", sendTyping);
});
+42 -8
View File
@@ -305,19 +305,53 @@ select {
white-space: nowrap;
}
.auth-panel {
.auth-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.auth-modal[hidden] {
display: none;
}
.auth-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
}
.auth-modal-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--bg-2);
width: min(20rem, calc(100vw - 2rem));
padding: 1.25rem;
border: 1px solid var(--bg-2);
border-radius: 0.5rem;
background: var(--panel-2);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.dm-search-row {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--bg-2);
.auth-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 1.2rem;
line-height: 1;
}
.auth-modal-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--ink-0);
}
.typing-indicator {