Skip to content

feat: public lobby, custom connection IDs, and a friendlier session page - #23

Merged
xinshoutw merged 33 commits into
mainfrom
dev
Aug 12, 2026
Merged

feat: public lobby, custom connection IDs, and a friendlier session page#23
xinshoutw merged 33 commits into
mainfrom
dev

Conversation

@xinshoutw

Copy link
Copy Markdown
Collaborator

Brings dev up to date with 31 commits of work: a public session lobby, custom connection IDs, and a round of frontend fixes.

Backend

  • Public lobby: sessions can publish themselves, and the lobby is pushed live over the WebSocket. The publication dies with the host who granted it.
  • Custom connection IDs, with confusable characters restricted to generated IDs only.
  • Lobby rooms carry the creator's name; lobby entries are timestamped and /lobby is reserved as a route.
  • The WebSocket keepalive no longer refreshes session idleness.

Frontend

  • Entry form reworked; the connection ID field locks out the IME and opts out of password managers.
  • The URL is now the source of truth for the open connection.
  • Files dropped anywhere on the session page upload, and so does a Ctrl+V paste.
  • Image blocks show thumbnails, loaded only when they scroll into view.
  • Page-shape changes are animated via view transitions.
  • A dead connection returns to the dashboard silently; the session is only left when the server rejects it.

Ops

  • Nginx gives the public lobby its own connection budget.
  • Version bumped to 2.1.0, uv.lock synced, READMEs corrected.
  • Frontend dependencies updated to latest.

Test plan

  • Create a session with a custom ID, publish it, confirm it appears in the lobby for a second browser
  • Close the host's tab, confirm the session leaves the lobby
  • Drop and Ctrl+V a file (and a screenshot) onto the session page
  • Paste text into the composer with Excel content on the clipboard — text should win, not the bitmap
  • Open an invite link while already in a session, confirm the URL is not rewritten

…ters

- exclude i, o, e, 0 and 1 from the connection ID alphabet so an ID can be
  read aloud or copied by eye without ambiguity
- accept an optional connection_id on session create, validated against the
  same length and alphabet and rejected with 409 when already in use
- expose the alphabet through /session/id-length so the client filters input
  against the server's rule instead of its own copy
- add a host-only is_public flag, off by default, toggled through
  /session/toggle_public and mirrored to members over the session socket
- serve the five newest published sessions from /sessions/public and stream
  the same list over an unauthenticated /ws/lobby socket
- push appearances and disappearances immediately; fold timestamp-only churn
  into the existing 2s flush loop
- drop the persisted snapshot between tests so state stops leaking forward
- send an optional connection_id on create and read the ID alphabet from
  /session/id-length instead of assuming a-z0-9
- add toggleSessionPublic and getPublicSessions
- move the http-to-ws scheme swap into config so both sockets share it
- let New request its own connection ID, filtered to the server's alphabet as
  it is typed, with the rule in the label so New and Join stay the same height
- keep the user name in local storage and reuse it every visit, an empty field
  included, so clearing it means "assign me one" rather than reverting
- list the five newest public Clippys under the form with name, created, ID and
  last update, fed by the lobby socket so rooms appear and vanish live
- padlock toggle next to the QR code, locked by default, that publishes the
  session to the home page and turns green while it is listed
- non-hosts see the current state but cannot change it, matching the other
  session switches
- follow public_changed over the socket so every member sees the flip
- window-level drag handlers so a drop outside the card uploads instead of
  navigating the tab to the file
- upload dropped files one after another, each held in memory whole while it
  is encrypted, and report per-file failures
- overlay shows the drop hint and the remaining count, with pointer events off
  so the composer's own drop zone still works
- decrypt image blocks in the tab and paint them from an object URL, typed
  from the file extension since the stored bytes carry no content type
- cap the preview at 12 MB so a large upload is downloaded, not rendered
- render through <img> only, never a link to the blob, so an uploaded SVG
  cannot run as a document on this origin
- allow blob: in the nginx img-src for the object URLs
- expiry, a destroyed session and a socket that stopped reconnecting all go
  straight home instead of raising a dialog or a toast first
- skip the "open the new connection?" prompt when the stored session is
  already gone; the URL simply wins
- clear the stored session synchronously, since every caller reloads right
  after and could otherwise outrun the effect that removes it
- attach the HTTP status to API errors
- treat 404 and 403 as gone, and leave a session alone when the request never
  landed, so an offline moment no longer discards live blocks
- retry quietly when the socket cannot be constructed at all
- drop the per-row guard; the form's loading state already blocks a second join
- keep the lock green while hovered; the plain hover rule is more specific
  than the public rule and was repainting a listed session as private
- drop maxLength from the custom ID field: it truncated a paste before the
  filter ran, so "Oi3E-x9k7m2" landed as "3x" instead of "3x9k7m"
- fix the label at creation instead of reading the current host, so a listed
  room keeps its name through a host transfer and after everyone has left
- fall back to the host in the loader for snapshots written without a name
- custom and confusion-free connection IDs
- public Clippy listing on the entry page
- drag-and-drop uploads and image thumbnails
- remembered user name and silent return to the dashboard
- keep i, o, e, 0 and 1 out of what the server mints, and accept them in an
  ID the caller names itself
- validate a requested ID against the full a-z0-9 set, which is still the
  filesystem guard for the session directory
- Connection ID sits at the top of both tabs and renders from one shared
  field component, so New and Join are the same markup and the same height
- drop the hint text next to the label; the ID rule is no longer worth saying
- filter the ID inputs on composition end as well as on change, so a Bopomofo
  keyboard commits nothing into a field that only accepts a-z0-9
- remove the "Last update" label and align its time to the right edge
- render it as a password input, the only control macOS and Windows leave a
  Chinese IME for; no web API exposes that switch
- Chrome ignores -webkit-text-security on password inputs, so hide the mask
  dots by painting the text transparent and echo the value in a span that
  mirrors the input's font, padding and letter-spacing
- keep password managers and the reveal button out of a field that holds a
  share code rather than a secret
- confusable characters are avoided only in server-generated IDs
- note the password-typed ID field and why it exists
Destroying a connection now lands on the dashboard directly, verified in
the browser: path '/', stored session cleared.
- add the Bitwarden, Proton Pass and Dashlane ignore attributes alongside
  the existing 1Password and LastPass ones
- note that no attribute suppresses the browser's own save prompt
…leness

- a ping fires every 30s regardless of whether anyone is there, so an
  abandoned open tab kept a session alive past SESSION_TIMEOUT_SECONDS
  forever
- idleness now measures real actions, matching what the README promises
- render from the path: a stored session opens only when the path names it
- offer the stored session as a Resume button at the top right of the entry
  page instead of taking over the route or asking which one to open
- reload on popstate so back and forward move between connections
- drop the now-unreachable URL normalisation in ClipboardInterface
A host whose laptop slept for ten seconds lost the session to whoever had
joined from the lobby: the seat auto-transfers after the disconnect grace,
and with it went the only token that could unpublish or destroy the room —
which kept advertising the original host's name to strangers. A room that
emptied was worse, since no host token existed at all and nothing but the
one-hour timeout could take it down.

- clear is_public whenever the host seat moves, whether by explicit transfer
  or by auto-promotion, and when the last member leaves
- push public_changed and the lobby update on both paths
- cover the takeover and the abandoned room in tests
- send lobby timestamps with the server's UTC offset: a bare local time is
  read by the browser as its own, so a UTC container told a UTC+8 reader a
  room was created 8 hours ago the moment it appeared
- reject "lobby" as a custom ID; at CONNECTION_ID_LENGTH=5 such a session
  would never receive an event, since /ws/lobby is matched first
- strip whitespace-only names instead of rendering a blank lobby row
- skip the fan-out when toggle_public is called with the value it already has
…erlay

Dropping a file on the composer's own zone left the full-screen overlay
painted over the page until a reload: the zone stopped propagation, and a
drop fires no matching dragleave, so the only code that resets the counter
never ran. The window handler now always resets and skips the upload when
the composer already claimed the event.

Losing the socket was treated as losing the session. Five failed connects
take about fifteen seconds, after which the stored session was deleted and
the ID wiped from the address bar — reachable by anything that stops a
handshake, including a neighbour on the same IP exhausting the proxy's
per-address WebSocket slots.

- probe over HTTP before giving up: 404 goes home, 403 keeps the path so the
  reload rejoins, and a request that never landed changes nothing
- reset the URL when destroying from the menu, which otherwise raced the
  session_destroyed frame and came back trying to join what it just destroyed
- leave via goHome so Back no longer walks into the connection just left
- count pending uploads as a delta, so a second drop cannot zero the first
Every image block fetched and decrypted itself on mount, so a room filled
with them made each member's tab download and AES-GCM the lot on join — a
member could push a session to its quota in 12 MB images and reliably take
every other tab down.

- wait for an IntersectionObserver hit before fetching, 300px ahead of the
  viewport so scrolling still feels instant
- key the effect on the block's id and contents instead of the object, which
  changed identity on every reconnect and re-downloaded everything on screen
- drop svg from the preview table: an <img> will not run it, but opening the
  object URL as a document would run it on this origin
The lobby socket is unauthenticated and every visitor idling on the entry
page holds one, so sharing the 20-per-IP WebSocket limit with real sessions
meant twenty colleagues behind one NAT could lock the twenty-first out of
their own connection — and one attacker could do it deliberately.
- offer Resume whenever the stored session is not the one on screen, not
  only at /: following a link to an expired connection left the entry page
  showing an error with no way back to the session still held
- reuse the remembered name when joining from a URL, the one path that
  still asked the server for a random one
- Slide one highlight between the New and Join tabs instead of blinking a
  background across. The fields below are identical on both tabs, so they
  stay put and unkeyed.
- Cross-fade the entry page into the connection via a view transition,
  wrapped once around the session setter so every way in — create, join,
  shared link — gets it for free.
- Blocks rise in on arrival, staggered down the list and capped so a long
  list reads as one sweep. Deleting runs a view transition, so the block
  fades while the rest close the gap.
- Public rooms rise in as they are published.
- Honour prefers-reduced-motion globally and in the transition helper.
xinshoutw and others added 3 commits August 12, 2026 11:49
- upload files from Ctrl+V through the same path as drag-and-drop
- bind to the window so a screenshot pasted with nothing focused still lands
- leave text paste alone when the caret is in a field and the clipboard
  carries a bitmap alongside it, as Word and Excel do
The dependency bump moved eslint to 10, but eslint-plugin-react has no
release that accepts it — its peer range still stops at ^9.7, and the
config uses the plugin's recommended rules. npm ci refused to resolve,
so the Docker build has failed on dev since the bump landed.

- pin eslint to ^9.39.5 and regenerate the lock
- every other package from the bump stays where it was
@xinshoutw
xinshoutw merged commit 6a4493e into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants