` raw links and
@@ -15,49 +14,34 @@ function urlSessionId() {
}
function AppContent() {
- const {sessionData, clearSession} = useSession();
- const confirm = useConfirm();
+ const {sessionData} = useSession();
const [isReady, setIsReady] = useState(false);
useEffect(() => {
- (async () => {
- try {
- await initConfig();
- } catch (err) {
- console.error('Failed to initialize:', err);
- }
- // A saved session plus a *different* id in the URL — scanning a new
- // QR while an old session is still stored — is ambiguous. Without
- // this the stored session always won, the URL was ignored, and the
- // only thing the user saw was the old session reported as expired.
- const newId = urlSessionId();
- if (sessionData && newId && newId !== sessionData.connection_id) {
- const oldId = sessionData.connection_id;
- const oldAlive = await getSession(oldId, sessionData.user_id).then(() => true, () => false);
- const shown = oldAlive ? {oldId} : {oldId};
- const goNew = await confirm({
- title: 'Open the new connection?',
- message: (
- <>
- You already have connection {shown}{oldAlive ? '' : ' open but expired'}.
- {' '}Open {newId} instead?
- >
- ),
- confirmText: 'Open new',
- cancelText: 'Stay',
- confirmStyle: 'primary',
- });
- if (goNew) {
- clearSession(); // SessionEntry joins from the URL on mount
- } else {
- window.history.replaceState({}, '', `/${oldId}`);
- }
- }
- setIsReady(true);
- })();
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ initConfig()
+ .catch((err) => console.error('Failed to initialize:', err))
+ .finally(() => setIsReady(true));
}, []);
+ // Back and forward move between connections now that the path decides what
+ // is open. Everything in the app writes history with replaceState, so this
+ // only ever fires for a navigation the user made.
+ useEffect(() => {
+ const reload = () => window.location.reload();
+ window.addEventListener('popstate', reload);
+ return () => window.removeEventListener('popstate', reload);
+ }, []);
+
+ // The address bar is the source of truth. A stored session opens only when
+ // the path already names it; at `/` it is offered as a Resume button rather
+ // than quietly taking over the route.
+ const pathId = urlSessionId();
+ const inSession = Boolean(sessionData && pathId === sessionData.connection_id);
+ // Offered whenever the stored session is not the one on screen, not only at
+ // `/`. Following a friend's link to a session that has since expired left
+ // the entry page with an error and no way back to the session still held.
+ const resumeId = !inSession && sessionData ? sessionData.connection_id : null;
+
if (!isReady) {
return (
@@ -68,10 +52,19 @@ function AppContent() {
return (
+ {resumeId && (
+
+ )}
- {sessionData ? : }
+ {inSession ? : }
- {!sessionData && (
+ {!inSession && (
diff --git a/frontend/src/components/ClipboardInterface.css b/frontend/src/components/ClipboardInterface.css
index 9915b80..b3e5a00 100644
--- a/frontend/src/components/ClipboardInterface.css
+++ b/frontend/src/components/ClipboardInterface.css
@@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
gap: 24px;
+ animation: rise-in 320ms var(--ease) backwards;
}
.desk-head {
@@ -183,6 +184,7 @@
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
+ animation: rise-in 220ms var(--ease) backwards;
}
.compose-tabs {
@@ -405,3 +407,30 @@
font-family: var(--font-mono);
color: var(--fg-subtle);
}
+
+/* Drops are caught on the window, so the hint covers the viewport rather than
+ the card. pointer-events stays off: the composer's own drop zone has to keep
+ receiving the drop when the cursor is over it. */
+.desk-drop {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ pointer-events: none;
+ background: var(--bg);
+ background: color-mix(in srgb, var(--bg) 72%, transparent);
+}
+
+.desk-drop-card {
+ padding: 18px 26px;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--fg);
+ background: var(--bg-elev);
+ border: 1.5px dashed var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+}
diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx
index 1101ef2..4db8e9d 100644
--- a/frontend/src/components/ClipboardInterface.jsx
+++ b/frontend/src/components/ClipboardInterface.jsx
@@ -3,8 +3,17 @@ import {useSession} from '../context/SessionContext';
import {useToast} from '../context/ToastContext';
import {useConfirm} from '../context/ConfirmContext';
import {useWebSocket} from '../hooks/useWebSocket';
-import {createTextBlock, deleteBlock, getSession, replaceFileBlock, updateTextBlock, uploadFileBlock} from '../utils/api';
+import {
+ createTextBlock,
+ deleteBlock,
+ getSession,
+ replaceFileBlock,
+ toggleSessionPublic,
+ updateTextBlock,
+ uploadFileBlock,
+} from '../utils/api';
import {clearSessionKey, setSessionKeyFromConnectionId} from '../utils/encryption';
+import {viewTransition} from '../utils/motion';
import {SUPPORTED_LANGUAGES, encodeCodeBlock} from '../utils/codeBlock';
import {BlockItem} from './BlockItem';
import {Id} from './Id';
@@ -27,6 +36,8 @@ export function ClipboardInterface() {
const [notification, setNotification] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [newBlockType, setNewBlockType] = useState('text');
+ const [isDraggingFiles, setIsDraggingFiles] = useState(false);
+ const [pendingUploads, setPendingUploads] = useState(0);
useEffect(() => {
if (!sessionData?.connection_id) return;
@@ -43,16 +54,6 @@ export function ClipboardInterface() {
await loadSession();
})();
- // Only normalise the URL when it isn't already pointing at a *different*
- // session. Rewriting unconditionally meant opening someone's invite link
- // while already in a session silently snapped you back to your own.
- const expectedUrl = `/${sessionData.connection_id}`;
- const pathId = window.location.pathname.replace(/^\//, '').trim().toLowerCase();
- const isForeignInvite = pathId && pathId !== sessionData.connection_id;
- if (!isForeignInvite && window.location.pathname !== expectedUrl) {
- window.history.replaceState({}, '', expectedUrl);
- }
-
return () => {
cancelled = true;
clearSessionKey();
@@ -71,25 +72,38 @@ export function ClipboardInterface() {
}
};
+ // A dead connection is not a decision the user can help with, so there is
+ // nothing to ask: drop it and land on the dashboard.
+ const goHome = useCallback(() => {
+ clearSession();
+ window.history.replaceState({}, '', '/');
+ window.location.reload();
+ }, [clearSession]);
+
+ // 403 means this member was evicted, not that the room is gone — the server
+ // drops a user ten seconds after their socket does. Keep the path so the
+ // reload rejoins from the URL instead of stranding them on an empty
+ // dashboard with the ID no longer written anywhere.
+ const rejoinFromUrl = useCallback(() => {
+ clearSession();
+ window.location.reload();
+ }, [clearSession]);
+
+ // Acting on a failed request means deciding whether the session still
+ // exists. Only the server can say; a request that never landed says nothing,
+ // and treating it as a verdict throws away a live session and its blocks.
+ const leaveOnServerVerdict = useCallback((err) => {
+ if (err?.status === 404) goHome();
+ else if (err?.status === 403) rejoinFromUrl();
+ }, [goHome, rejoinFromUrl]);
+
useEffect(() => {
const validateSession = async () => {
if (!sessionData?.connection_id) return;
try {
await getSession(sessionData.connection_id, sessionData.user_id);
- } catch {
- const shouldGoHome = await confirm({
- title: 'Connection expired',
- message: 'Your connection has expired or is no longer available. Return to the home page?',
- confirmText: 'Go home',
- cancelText: 'Stay',
- confirmStyle: 'primary'
- });
-
- if (shouldGoHome) {
- clearSession();
- window.history.pushState({}, '', '/');
- window.location.reload();
- }
+ } catch (err) {
+ leaveOnServerVerdict(err);
}
};
@@ -123,7 +137,10 @@ export function ClipboardInterface() {
break;
case 'block_deleted':
- setBlocks((prev) => prev.filter((b) => b.id !== message.block_id));
+ // The only change here that removes something, so it is the only
+ // one worth a transition: the block fades out while the list
+ // closes the gap instead of everything below jumping up.
+ viewTransition(() => setBlocks((prev) => prev.filter((b) => b.id !== message.block_id)));
break;
case 'block_updated':
@@ -146,21 +163,30 @@ export function ClipboardInterface() {
setSession((prev) => ({...prev, allow_curl_upload: message.allow_curl_upload}));
break;
+ case 'public_changed':
+ setSession((prev) => ({...prev, is_public: message.is_public}));
+ break;
+
case 'session_destroyed':
- showNotification('Connection destroyed');
- setTimeout(() => {
- window.location.reload();
- }, 2000);
+ goHome();
break;
}
- }, [myPublicId]);
-
- const handleAuthRejected = useCallback(() => {
- toast.error('Session no longer available — returning to home.');
- clearSession();
- window.history.replaceState({}, '', '/');
- setTimeout(() => window.location.reload(), 1200);
- }, [clearSession, toast]);
+ }, [myPublicId, goHome]);
+
+ // The socket gave up reconnecting. That looks identical whether the server
+ // turned us away or the handshake never got out of the building — a proxy
+ // conn-limit, captive wifi, a sleeping laptop — so ask over HTTP before
+ // acting. Without this, roughly fifteen seconds offline silently deleted
+ // the stored session, and anyone able to exhaust the per-IP WebSocket
+ // slots could log a neighbour out on demand.
+ const handleSocketGaveUp = useCallback(async () => {
+ try {
+ await getSession(sessionData.connection_id, sessionData.user_id);
+ window.location.reload(); // still ours — start the socket over
+ } catch (err) {
+ leaveOnServerVerdict(err); // no status: stay put, badge reads Offline
+ }
+ }, [sessionData, leaveOnServerVerdict]);
// Passed by identity into a ref inside the hook, so re-creating it each
// render only refreshes that ref — it never re-opens the socket.
@@ -168,7 +194,7 @@ export function ClipboardInterface() {
sessionData?.connection_id,
sessionData?.user_id,
handleWebSocketMessage,
- handleAuthRejected,
+ handleSocketGaveUp,
loadSession,
);
@@ -207,6 +233,99 @@ export function ClipboardInterface() {
}
};
+ // Dropped files skip the composer entirely: they upload one after another
+ // rather than in parallel, because each one is held in memory whole while
+ // it is encrypted.
+ const uploadDroppedFiles = useCallback(async (fileList) => {
+ const files = Array.from(fileList || []);
+ if (files.length === 0) return;
+
+ // Counted as a delta, not assigned: a second drop while the first batch
+ // is still running would otherwise set the total from its own list and
+ // then zero it, hiding the overlay with uploads still in flight.
+ setPendingUploads((n) => n + files.length);
+ let uploaded = 0;
+ for (const file of files) {
+ try {
+ await uploadFileBlock(sessionData.connection_id, sessionData.user_id, file);
+ uploaded += 1;
+ } catch (err) {
+ toast.error(`Failed to upload ${file.name}: ${err.message}`);
+ }
+ setPendingUploads((n) => Math.max(0, n - 1));
+ }
+ if (uploaded > 0) {
+ toast.success(uploaded === 1 ? 'Uploaded' : `Uploaded ${uploaded} files`);
+ }
+ }, [sessionData, toast]);
+
+ // Bound to the window, not the layout: a file dropped just outside the card
+ // would otherwise be opened by the browser and navigate the session away.
+ useEffect(() => {
+ const carriesFiles = (e) => Array.from(e.dataTransfer?.types || []).includes('Files');
+ // dragenter/dragleave fire for every child element, so track depth
+ // instead of toggling — otherwise the overlay strobes as the cursor moves.
+ let depth = 0;
+
+ const onDragEnter = (e) => {
+ if (!carriesFiles(e)) return;
+ depth += 1;
+ setIsDraggingFiles(true);
+ };
+ const onDragOver = (e) => {
+ if (!carriesFiles(e)) return;
+ e.preventDefault();
+ };
+ const onDragLeave = (e) => {
+ if (!carriesFiles(e)) return;
+ depth = Math.max(0, depth - 1);
+ if (depth === 0) setIsDraggingFiles(false);
+ };
+ const onDrop = (e) => {
+ if (!carriesFiles(e)) return;
+ // Always reset first. A drop fires no matching dragleave, so this is
+ // the only place depth can return to zero — bailing out before it
+ // left the overlay painted over the page until a reload.
+ depth = 0;
+ setIsDraggingFiles(false);
+ // The composer's own drop zone runs first and calls preventDefault.
+ // That is how it claims the file; uploading here too would post it
+ // twice.
+ if (e.defaultPrevented) return;
+ e.preventDefault();
+ uploadDroppedFiles(e.dataTransfer.files);
+ };
+
+ window.addEventListener('dragenter', onDragEnter);
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('dragleave', onDragLeave);
+ window.addEventListener('drop', onDrop);
+ return () => {
+ window.removeEventListener('dragenter', onDragEnter);
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('dragleave', onDragLeave);
+ window.removeEventListener('drop', onDrop);
+ };
+ }, [uploadDroppedFiles]);
+
+ // Ctrl+V lands the same place a drop does. Also window-bound: a screenshot
+ // pasted with nothing focused has no other handler to reach.
+ useEffect(() => {
+ const onPaste = (e) => {
+ const files = Array.from(e.clipboardData?.files || []);
+ if (files.length === 0) return;
+ // Word and Excel put a bitmap on the clipboard next to the text.
+ // With the caret in a field, the text is what was meant.
+ const editable = e.target?.closest?.('input, textarea, [contenteditable]');
+ if (editable && e.clipboardData.getData('text')) return;
+ e.preventDefault();
+ uploadDroppedFiles(files);
+ };
+
+ window.addEventListener('paste', onPaste);
+ return () => window.removeEventListener('paste', onPaste);
+ }, [uploadDroppedFiles]);
+
const handleReplaceFile = async (blockId, file) => {
try {
await replaceFileBlock(sessionData.connection_id, sessionData.user_id, blockId, file);
@@ -226,6 +345,16 @@ export function ClipboardInterface() {
}
};
+ const handleToggleVisibility = async () => {
+ const next = !session?.is_public;
+ try {
+ await toggleSessionPublic(sessionData.connection_id, sessionData.user_id, next);
+ toast.success(next ? 'Listed on the home page' : 'Private again');
+ } catch (err) {
+ toast.error('Failed to change visibility: ' + err.message);
+ }
+ };
+
const handleLogoClick = async () => {
const confirmed = await confirm({
title: 'Leave connection',
@@ -235,11 +364,10 @@ export function ClipboardInterface() {
confirmStyle: 'danger'
});
- if (confirmed) {
- clearSession();
- window.history.pushState({}, '', '/');
- window.location.reload();
- }
+ // goHome, not pushState: leaving used to push a history entry, so Back
+ // returned to / — and now that the path decides what is open, that
+ // silently rejoined the connection the user had just left.
+ if (confirmed) goHome();
};
const currentUser = users.find((u) => u.id === myPublicId);
@@ -280,7 +408,12 @@ export function ClipboardInterface() {
)}
-
+
@@ -357,6 +490,16 @@ export function ClipboardInterface() {
)}
+ {(isDraggingFiles || pendingUploads > 0) && (
+
+
+ {pendingUploads > 0
+ ? `Uploading… ${pendingUploads} left`
+ : 'Drop files to upload'}
+
+
+ )}
+
{notification && }
);
@@ -460,6 +603,9 @@ function FileUploadForm({onSubmit}) {
}
};
+ // preventDefault, not stopPropagation: the window handler reads
+ // defaultPrevented to know this drop is already claimed, and it still needs
+ // the event so it can put its own overlay away.
const handleDrop = (e) => {
e.preventDefault();
setDragging(false);
diff --git a/frontend/src/components/Id.css b/frontend/src/components/Id.css
index 110862c..7e6f78c 100644
--- a/frontend/src/components/Id.css
+++ b/frontend/src/components/Id.css
@@ -31,11 +31,24 @@
transition: color 120ms var(--ease), border-color 120ms var(--ease);
}
-.id-btn:hover {
+.id-btn:hover:not(:disabled) {
color: var(--fg);
border-color: var(--border-strong);
}
+.id-btn:disabled {
+ cursor: default;
+}
+
+/* Public is the loud state: an unlocked room is worth noticing at a glance.
+ Listed alongside the hover rule so state, not the cursor, decides the colour
+ — the plain .id-btn:hover selector is the more specific of the two. */
+.id-btn.is-public,
+.id-btn.is-public:hover:not(:disabled) {
+ color: var(--accent-live);
+ border-color: var(--accent-live);
+}
+
.qr-popup {
position: absolute;
top: calc(100% + 8px);
diff --git a/frontend/src/components/Id.jsx b/frontend/src/components/Id.jsx
index f2c76cf..81c9a97 100644
--- a/frontend/src/components/Id.jsx
+++ b/frontend/src/components/Id.jsx
@@ -13,7 +13,27 @@ function readQrColors() {
return {dark: fg, light: bg};
}
-export function Id({sessionData}) {
+// Body plus shackle: closed sits square on the box, open swings clear of it.
+const IconLock = ({open}) => (
+
+);
+
+export function Id({sessionData, isPublic = false, canToggleVisibility = false, onToggleVisibility}) {
const [showQrPopup, setShowQrPopup] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [qrError, setQrError] = useState(false);
@@ -92,6 +112,21 @@ export function Id({sessionData}) {
+
{showQrPopup && (
diff --git a/frontend/src/components/Menu.jsx b/frontend/src/components/Menu.jsx
index bf77459..ff1388f 100644
--- a/frontend/src/components/Menu.jsx
+++ b/frontend/src/components/Menu.jsx
@@ -43,6 +43,10 @@ export function Menu({session, users, currentUser, onClose}) {
try {
await destroySession(sessionData.connection_id, sessionData.user_id);
clearSession();
+ // Reset the path too. Reloading with it still set raced the
+ // session_destroyed frame, and when the HTTP reply won, the tab
+ // came back up trying to join the session it had just destroyed.
+ window.history.replaceState({}, '', '/');
window.location.reload();
} catch (err) {
toast.error('Failed to destroy connection: ' + err.message);
diff --git a/frontend/src/components/PublicSessions.css b/frontend/src/components/PublicSessions.css
new file mode 100644
index 0000000..7799993
--- /dev/null
+++ b/frontend/src/components/PublicSessions.css
@@ -0,0 +1,111 @@
+.lobby {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.lobby-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--fg-subtle);
+}
+
+.lobby-title::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.lobby-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ list-style: none;
+}
+
+/* Rooms are published and expire while the page sits open, so a row arriving
+ should read as an arrival. Removals still snap: the list is replaced wholesale
+ on every push, and holding a row on screen to animate it out would mean
+ tracking a list React has already thrown away. */
+.lobby-row {
+ animation: rise-in 260ms var(--ease) backwards;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 4px 12px;
+ width: 100%;
+ padding: 10px 12px;
+ text-align: left;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ transition: border-color 120ms var(--ease), background 120ms var(--ease);
+}
+
+.lobby-row:hover:not(:disabled) {
+ border-color: var(--border-strong);
+}
+
+.lobby-row:active:not(:disabled) {
+ background: var(--bg);
+}
+
+.lobby-row:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.lobby-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--fg);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lobby-id {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--fg-muted);
+ padding: 2px 6px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+}
+
+.lobby-meta {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 2px 14px;
+ font-size: 12px;
+ color: var(--fg-subtle);
+}
+
+.lobby-time {
+ display: inline-flex;
+ gap: 5px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Last update sits under the ID, hard against the right edge. */
+.lobby-time.is-end {
+ margin-left: auto;
+ text-align: right;
+}
+
+.lobby-label {
+ color: var(--fg-muted);
+ opacity: 0.75;
+}
diff --git a/frontend/src/components/PublicSessions.jsx b/frontend/src/components/PublicSessions.jsx
new file mode 100644
index 0000000..9b5513b
--- /dev/null
+++ b/frontend/src/components/PublicSessions.jsx
@@ -0,0 +1,138 @@
+import React, {useEffect, useState} from 'react';
+import {getPublicSessions} from '../utils/api';
+import {getWebSocketUrl} from '../utils/config';
+import './PublicSessions.css';
+
+const RECONNECT_MS = 3000;
+// Relative timestamps go stale on their own, so re-render them on a slow tick
+// rather than waiting for the server to push an update the room may never make.
+const TICK_MS = 30000;
+
+/**
+ * Live list of sessions their host has published.
+ *
+ * The socket carries the whole list on every change, so there is no merge step
+ * — appearances and disappearances land as a straight replacement.
+ */
+function useLobby() {
+ const [sessions, setSessions] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ let socket = null;
+ let retryTimer = null;
+ // The REST snapshot is only a fallback for when the socket can't open;
+ // it must never overwrite a list the socket already delivered.
+ let live = false;
+
+ getPublicSessions()
+ .then((initial) => {
+ if (!cancelled && !live) setSessions(initial);
+ })
+ .catch(() => {});
+
+ const connect = () => {
+ if (cancelled) return;
+ try {
+ socket = new WebSocket(`${getWebSocketUrl()}/ws/lobby`);
+ } catch {
+ // A lobby that cannot connect is a missing list, not a broken
+ // entry page — keep retrying quietly behind the form.
+ retryTimer = setTimeout(connect, RECONNECT_MS);
+ return;
+ }
+
+ socket.onmessage = (event) => {
+ let message;
+ try {
+ message = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+ if (message.type === 'public_sessions') {
+ live = true;
+ setSessions(message.sessions ?? []);
+ }
+ };
+
+ socket.onclose = () => {
+ if (!cancelled) retryTimer = setTimeout(connect, RECONNECT_MS);
+ };
+ };
+
+ connect();
+
+ return () => {
+ cancelled = true;
+ clearTimeout(retryTimer);
+ if (socket) {
+ socket.onclose = null;
+ socket.close();
+ }
+ };
+ }, []);
+
+ return sessions;
+}
+
+function useTick(intervalMs) {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setTick((n) => n + 1), intervalMs);
+ return () => clearInterval(id);
+ }, [intervalMs]);
+}
+
+function formatCreated(iso) {
+ const date = new Date(iso);
+ const time = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
+ if (date.toDateString() === new Date().toDateString()) return time;
+ return `${date.toLocaleDateString([], {month: 'short', day: '2-digit'})} ${time}`;
+}
+
+function formatRelative(iso) {
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
+ if (seconds < 60) return 'just now';
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes} min ago`;
+ return `${Math.round(minutes / 60)} hr ago`;
+}
+
+export function PublicSessions({onJoin, disabled}) {
+ const sessions = useLobby();
+ useTick(TICK_MS);
+
+ // Nothing published means nothing to show — the section itself appears and
+ // disappears with the rooms.
+ if (sessions.length === 0) return null;
+
+ return (
+
+ Public Clippys
+
+ {sessions.map((entry) => (
+ -
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/SessionEntry.css b/frontend/src/components/SessionEntry.css
index f53589b..8dbb202 100644
--- a/frontend/src/components/SessionEntry.css
+++ b/frontend/src/components/SessionEntry.css
@@ -40,7 +40,12 @@
}
}
+.entry {
+ animation: rise-in 320ms var(--ease) backwards;
+}
+
.entry-tabs {
+ position: relative;
display: flex;
gap: 0;
border: 1px solid var(--border);
@@ -49,7 +54,27 @@
background: var(--bg-elev);
}
+/* The highlight is one element that slides, not a background that blinks from
+ tab to tab. It sits under the labels, so both stay readable mid-slide. */
+.entry-tabs::before {
+ content: '';
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ left: 2px;
+ width: calc(50% - 2px);
+ background: var(--bg);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow);
+ transition: transform 260ms var(--ease);
+}
+
+.entry-tabs[data-mode="join"]::before {
+ transform: translateX(100%);
+}
+
.entry-tab {
+ position: relative;
flex: 1;
padding: 10px 14px;
font-size: 14px;
@@ -57,7 +82,7 @@
color: var(--fg-muted);
background: transparent;
border-radius: var(--radius-sm);
- transition: color 120ms var(--ease), background 120ms var(--ease);
+ transition: color 160ms var(--ease);
}
.entry-tab:hover:not(.is-active) {
@@ -66,8 +91,6 @@
.entry-tab.is-active {
color: var(--fg);
- background: var(--bg);
- box-shadow: var(--shadow);
}
.entry-form {
@@ -137,6 +160,48 @@
text-transform: none;
}
+/* The ID field is a password input so the OS leaves its IME on focus, and a
+ share code has no business being masked. Chrome ignores -webkit-text-security
+ on password inputs, but the mask dots are glyphs painted in the text colour —
+ so make that transparent and echo the value in the span underneath. Every
+ metric below has to match .entry-field input or the echo drifts off the caret. */
+.entry-code-wrap {
+ position: relative;
+}
+
+/* Two classes deep on purpose: `.entry-field input` above is more specific than
+ a lone class would be, and it would keep painting the mask dots. */
+.entry-field .entry-input-code {
+ color: transparent;
+ caret-color: var(--fg);
+}
+
+.entry-input-code::-ms-reveal,
+.entry-input-code::-webkit-credentials-auto-fill-button {
+ display: none;
+}
+
+.entry-code-echo {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ padding: 12px 14px;
+ font-family: var(--font-mono);
+ font-size: 15px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--fg);
+ white-space: pre;
+ pointer-events: none;
+}
+
+@media (max-width: 639px) {
+ .entry-code-echo {
+ font-size: 16px;
+ }
+}
+
@media (max-width: 639px) {
.entry-field input {
font-size: 16px;
diff --git a/frontend/src/components/SessionEntry.jsx b/frontend/src/components/SessionEntry.jsx
index 52fb904..980f522 100644
--- a/frontend/src/components/SessionEntry.jsx
+++ b/frontend/src/components/SessionEntry.jsx
@@ -1,36 +1,122 @@
import React, {useEffect, useState} from 'react';
-import {createSession, getConnectionIdLength, joinSession} from '../utils/api';
+import {createSession, getConnectionIdRules, joinSession} from '../utils/api';
import {useSession} from '../context/SessionContext';
+import {PublicSessions} from './PublicSessions';
import './SessionEntry.css';
+const NAME_STORAGE_KEY = 'clippy_user_name';
+const DEFAULT_ID_RULES = {length: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789'};
+
function syncUrl(connectionId) {
window.history.replaceState({}, '', `/${connectionId}`);
}
+// localStorage throws in private-mode Safari and when storage is disabled, and
+// a throw in a useState initializer leaves a permanent white screen.
+function readStoredName() {
+ try {
+ return localStorage.getItem(NAME_STORAGE_KEY) ?? '';
+ } catch {
+ return '';
+ }
+}
+
+function storeName(name) {
+ try {
+ localStorage.setItem(NAME_STORAGE_KEY, name);
+ } catch {
+ /* nothing to do — the name is a convenience, not state we depend on */
+ }
+}
+
+/**
+ * The one connection-ID input, used by both tabs so New and Join cannot drift
+ * apart.
+ *
+ * It is a password field on purpose. Nothing here is secret — that is the only
+ * control macOS and Windows drop out of a Chinese IME for, and no web API
+ * exposes that switch (`ime-mode` has been dead for years). Chrome then refuses
+ * to unmask it from CSS, so the dots are hidden by painting the text
+ * transparent and the real value is echoed by the span underneath, which mirrors
+ * the input's font, padding and letter-spacing exactly.
+ *
+ * `sanitize` runs on composition end as well as on change: it is the backstop
+ * for any browser that lets an IME compose into the field anyway.
+ *
+ * Trade-off: assistive tech announces this as a password field and will not
+ * read the characters back.
+ */
+function ConnectionIdField({inputId, value, sanitize, onChange, placeholder, disabled, required}) {
+ const apply = (e) => onChange(sanitize(e.target.value));
+ return (
+
+
+
+
+
+
+
+ );
+}
+
export function SessionEntry() {
const [mode, setMode] = useState('create');
- const [userName, setUserName] = useState('');
+ const [userName, setUserName] = useState(readStoredName);
const [sessionId, setSessionId] = useState('');
+ const [customId, setCustomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
- const [idLength, setIdLength] = useState(6);
+ const [idRules, setIdRules] = useState(DEFAULT_ID_RULES);
const {setSessionData} = useSession();
+ // Persisted on every change, including when cleared: an empty field is a
+ // deliberate "give me a random name", not a reason to resurrect the old one.
useEffect(() => {
- getConnectionIdLength().then(setIdLength).catch(() => {});
+ storeName(userName);
+ }, [userName]);
+
+ useEffect(() => {
+ getConnectionIdRules().then(setIdRules).catch(() => {});
}, []);
useEffect(() => {
const pathname = window.location.pathname;
const urlSessionId = pathname.replace('/', '').trim().toLowerCase();
- const pattern = new RegExp(`^[a-z0-9]{${idLength}}$`);
- if (urlSessionId && urlSessionId.length === idLength && pattern.test(urlSessionId)) {
+ const pattern = new RegExp(`^[a-z0-9]{${idRules.length}}$`);
+ if (urlSessionId && urlSessionId.length === idRules.length && pattern.test(urlSessionId)) {
setMode('join');
setSessionId(urlSessionId);
setLoading(true);
setError('');
- joinSession(urlSessionId, null)
+ // Arriving by QR or a shared link is the common way in, and it used
+ // to be the one path that ignored the remembered name.
+ joinSession(urlSessionId, readStoredName() || null)
.then((data) => {
syncUrl(data.connection_id);
setSessionData(data);
@@ -38,14 +124,14 @@ export function SessionEntry() {
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}
- }, [idLength, setSessionData]);
+ }, [idRules.length, setSessionData]);
const handleCreate = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
- const data = await createSession(userName || null);
+ const data = await createSession(userName || null, customId || null);
syncUrl(data.connection_id);
setSessionData(data);
} catch (err) {
@@ -55,12 +141,11 @@ export function SessionEntry() {
}
};
- const handleJoin = async (e) => {
- e.preventDefault();
+ const joinById = async (id) => {
setLoading(true);
setError('');
try {
- const data = await joinSession(sessionId, userName || null);
+ const data = await joinSession(id, userName || null);
syncUrl(data.connection_id);
setSessionData(data);
} catch (err) {
@@ -70,7 +155,21 @@ export function SessionEntry() {
}
};
- const placeholder = '_'.repeat(idLength);
+ const handleJoin = (e) => {
+ e.preventDefault();
+ joinById(sessionId);
+ };
+
+ // Only what the server will accept survives being typed or pasted.
+ const sanitizeId = (value) => value
+ .toLowerCase()
+ .split('')
+ .filter((c) => idRules.alphabet.includes(c))
+ .join('')
+ .slice(0, idRules.length);
+
+ const placeholder = '_'.repeat(idRules.length);
+ const customIdIncomplete = customId.length > 0 && customId.length !== idRules.length;
return (
@@ -79,7 +178,7 @@ export function SessionEntry() {
Secure collaborative clipboard.
-
+
+ {/* Deliberately unkeyed: both tabs render the same three controls, so
+ React reuses them in place and only the highlight travels. */}
{mode === 'create' ? (
) : (
);
}
diff --git a/frontend/src/context/SessionContext.jsx b/frontend/src/context/SessionContext.jsx
index 8d698ec..d12c9d1 100644
--- a/frontend/src/context/SessionContext.jsx
+++ b/frontend/src/context/SessionContext.jsx
@@ -1,4 +1,5 @@
-import React, {createContext, useContext, useEffect, useState} from 'react';
+import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
+import {viewTransition} from '../utils/motion';
const SessionContext = createContext(null);
@@ -24,11 +25,27 @@ export function SessionProvider({children}) {
}, [sessionData]);
const clearSession = () => {
+ // Cleared here as well as in the effect above: callers reload the page
+ // immediately after this, which can beat React's effect flush and leave
+ // the dead session in storage to be restored on the next load.
+ try {
+ localStorage.removeItem('clippy_session');
+ } catch {
+ /* storage disabled — the effect below is the only other writer */
+ }
setSessionData(null);
};
+ // Landing in a session replaces the whole page, so it crossfades rather than
+ // cutting. Every way in — create, join, a shared link — sets the session
+ // here, which is why one wrapper covers all of them.
+ //
+ // Identity has to stay stable: SessionEntry lists this in an effect's deps,
+ // and a fresh function each render would re-run the auto-join from the URL.
+ const enterSession = useCallback((data) => viewTransition(() => setSessionData(data)), []);
+
return (
-
+
{children}
);
diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js
index 5f4ad56..bcefd35 100644
--- a/frontend/src/hooks/useWebSocket.js
+++ b/frontend/src/hooks/useWebSocket.js
@@ -1,5 +1,5 @@
import {useEffect, useRef, useState} from 'react';
-import {getBackendUrl} from '../utils/config';
+import {getWebSocketUrl} from '../utils/config';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
@@ -53,9 +53,7 @@ export function useWebSocket(sessionId, userId, onMessage, onAuthRejected, onRes
openedThisAttempt = false;
- const apiUrl = getBackendUrl();
- const wsUrl = apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws');
- const fullWsUrl = `${wsUrl}/ws/${sessionId}`;
+ const fullWsUrl = `${getWebSocketUrl()}/ws/${sessionId}`;
// The member token goes in the subprotocol, not the path: browsers
// can't set headers on a WS handshake, and a token in the URL lands
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f352fc4..7e3c652 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -97,6 +97,23 @@ h1, h2, h3, h4, h5, h6 {
letter-spacing: -0.02em;
}
+/* The one entrance used across the app: appear, and settle upward. `to` is left
+ off on purpose so it lands on whatever the element's own styles say. */
+@keyframes rise-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 1ms !important;
+ animation-delay: 0ms !important;
+ transition-duration: 1ms !important;
+ }
+}
+
.app-layout {
display: flex;
flex-direction: column;
@@ -134,6 +151,37 @@ h1, h2, h3, h4, h5, h6 {
}
+.app-resume {
+ position: fixed;
+ top: 14px;
+ right: 14px;
+ z-index: 10;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--fg-muted);
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ transition: color 120ms var(--ease), border-color 120ms var(--ease);
+}
+
+.app-resume:hover {
+ color: var(--fg);
+ border-color: var(--border-strong);
+}
+
+.app-resume code {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ color: var(--fg);
+}
+
.app-loading {
flex: 1;
display: flex;
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 0a033c2..5fdc7ce 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -23,38 +23,56 @@ function authHeaders(userId) {
return userId ? {Authorization: `Bearer ${userId}`} : {};
}
+// Carries the HTTP status so callers can tell "the server said no" apart from
+// "the request never landed" — a dropped connection must not be read as a
+// session that no longer exists.
+function apiError(message, status) {
+ const error = new Error(message);
+ error.status = status;
+ return error;
+}
+
async function handleApiResponse(response) {
let json;
try {
json = await response.json();
} catch {
- throw new Error(`Invalid JSON response (HTTP ${response.status})`);
+ throw apiError(`Invalid JSON response (HTTP ${response.status})`, response.status);
}
if (json.status !== undefined) {
if (json.status >= 200 && json.status < 300) {
return json.data ?? json;
}
- throw new Error(json.message || 'Request failed');
+ throw apiError(json.message || 'Request failed', json.status);
}
if (!response.ok) {
- throw new Error(json.detail || json.message || 'Request failed');
+ throw apiError(json.detail || json.message || 'Request failed', response.status);
}
return json;
}
-export async function getConnectionIdLength() {
+/**
+ * Length and allowed characters for a connection ID, straight from the server
+ * so the client never keeps a second copy of the rule.
+ *
+ * @returns {Promise<{length: number, alphabet: string}>}
+ */
+export async function getConnectionIdRules() {
const response = await fetch(`${getApiBase()}/session/id-length`);
const data = await handleApiResponse(response);
- return data.connection_id_length;
+ return {
+ length: data.connection_id_length,
+ alphabet: data.connection_id_alphabet ?? 'abcdefghijklmnopqrstuvwxyz0123456789',
+ };
}
-export async function createSession(userName) {
+export async function createSession(userName, connectionId) {
const response = await fetch(`${getApiBase()}/session/create`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({user_name: userName}),
+ body: JSON.stringify({user_name: userName, connection_id: connectionId || null}),
});
return handleApiResponse(response);
}
@@ -123,6 +141,26 @@ export async function toggleCurl(sessionId, userId, allowCurlUpload) {
return handleApiResponse(response);
}
+export async function toggleSessionPublic(sessionId, userId, isPublic) {
+ const response = await fetch(`${getApiBase()}/session/toggle_public`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ connection_id: sessionId,
+ user_id: userId,
+ is_public: isPublic,
+ }),
+ });
+ return handleApiResponse(response);
+}
+
+/** Snapshot of the public lobby; live updates come over the lobby socket. */
+export async function getPublicSessions() {
+ const response = await fetch(`${getApiBase()}/sessions/public`);
+ const data = await handleApiResponse(response);
+ return data.sessions ?? [];
+}
+
export async function createTextBlock(sessionId, userId, content) {
const encryptedContent = await encrypt(content);
const response = await fetch(`${getApiBase()}/block/create`, {
diff --git a/frontend/src/utils/config.js b/frontend/src/utils/config.js
index d09b2b5..d9dc200 100644
--- a/frontend/src/utils/config.js
+++ b/frontend/src/utils/config.js
@@ -43,3 +43,11 @@ export function getBackendUrl() {
}
return backendUrl;
}
+
+/**
+ * Backend URL with the scheme swapped for WebSockets. Anchored to the scheme so
+ * a host containing "http" later in the URL is left alone.
+ */
+export function getWebSocketUrl() {
+ return getBackendUrl().replace(/^https/, 'wss').replace(/^http/, 'ws');
+}
diff --git a/frontend/src/utils/motion.js b/frontend/src/utils/motion.js
new file mode 100644
index 0000000..9137a6f
--- /dev/null
+++ b/frontend/src/utils/motion.js
@@ -0,0 +1,22 @@
+import {flushSync} from 'react-dom';
+
+/**
+ * Run a React state update inside a browser view transition, so the DOM change
+ * crossfades instead of snapping.
+ *
+ * flushSync is the whole point: React batches updates, so without it the browser
+ * takes its "before" snapshot and the change lands after the transition has
+ * already started — nothing moves.
+ *
+ * ponytail: no polyfill and no fallback animation. Browsers without the API, and
+ * anyone who asked for less motion, get the plain instant update.
+ */
+export function viewTransition(update) {
+ const apply = () => flushSync(update);
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ if (reduced || !document.startViewTransition) {
+ apply();
+ return;
+ }
+ document.startViewTransition(apply);
+}
diff --git a/frontend/src/utils/motion.test.js b/frontend/src/utils/motion.test.js
new file mode 100644
index 0000000..bd42a72
--- /dev/null
+++ b/frontend/src/utils/motion.test.js
@@ -0,0 +1,42 @@
+// node --test src/utils/motion.test.js
+//
+// The only thing worth guarding here is that the update always lands. A wrong
+// branch would not look like a missing animation, it would look like the app
+// ignoring the click.
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+
+const stub = ({reduced, supported}) => {
+ let started = false;
+ globalThis.window = {matchMedia: () => ({matches: reduced})};
+ globalThis.document = supported
+ ? {startViewTransition: (fn) => { started = true; fn(); }}
+ : {};
+ return () => started;
+};
+
+const {viewTransition} = await import('./motion.js');
+
+test('runs the update through the browser transition when it is available', () => {
+ const startedTransition = stub({reduced: false, supported: true});
+ let ran = false;
+ viewTransition(() => { ran = true; });
+ assert.equal(ran, true);
+ assert.equal(startedTransition(), true);
+});
+
+test('still runs the update when the browser has no view transitions', () => {
+ const startedTransition = stub({reduced: false, supported: false});
+ let ran = false;
+ viewTransition(() => { ran = true; });
+ assert.equal(ran, true);
+ assert.equal(startedTransition(), false);
+});
+
+test('skips the transition when less motion was asked for', () => {
+ const startedTransition = stub({reduced: true, supported: true});
+ let ran = false;
+ viewTransition(() => { ran = true; });
+ assert.equal(ran, true);
+ assert.equal(startedTransition(), false);
+});