fluffychat/.github/instructions/joining-courses.instructions.md
wcjord 7f1e8f5ea0
feat: client-side knock auto-accept via KnockTracker (#5794)
* feat: client-side knock auto-accept via KnockTracker

Replace server-side AutoAcceptInviteIfKnocked (removed in
synapse-pangea-chat PR #21) with client-side KnockTracker.

- Record knocked room IDs in Matrix account data (org.pangea.knocked_rooms)
- Auto-join when invite arrives for a previously knocked room
- Migrate storage from GetStorage to Matrix account data for
  cross-device sync and reinstall persistence
- Add joining-courses.instructions.md design doc

* formatting

* centralizes calls to knock-storage related functions

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
2026-02-24 14:44:36 -05:00

6.8 KiB

applyTo
lib/pangea/join_codes/**,lib/pangea/chat_list/**,lib/pangea/course_creation/**,lib/pangea/spaces/**,lib/pages/chat_list/**,lib/utils/url_launcher.dart,lib/config/routes.dart

Joining Courses — Client Design

How users join courses (Matrix spaces) through three routes: class link, class code, and knock-accept.


Join Routes Overview

Route Entry Point Mechanism User Interaction
Class link Deep link URL Extracts class code from URL → knock_with_code → join None after click (auto-join)
Class code Manual text input knock_with_code → join Enter code only
Knock-accept "Ask to Join" button Matrix knock → admin approves → invite → client auto-joins Knock button; then wait for admin

All three routes converge on the user becoming a Membership.join member of the course space. Child rooms (announcements, introductions, activity chats) are joined separately afterward.


URL format: https://pangea.chat/#/join_with_link?classcode=XYZ

  1. User clicks link → GoRouter navigates to /join_with_link
  2. Class code is saved to local storage (SpaceCodeRepo)
  3. Redirect to /home
  4. On home load, joinCachedSpaceCode() fires the knock_with_code + join flow (same as Route 2)

Pre-login persistence: If not logged in, the code persists on disk. After account creation, joinCachedSpaceCode() auto-joins.

matrix.to links: Standard Matrix links (https://matrix.to/#/...) go through a separate path. For knock-only rooms, this shows the public room bottom sheet with a code field and "Ask to Join" button.


Route 2 — Class Code

Three UI entry points (onboarding, in-app code page, public room bottom sheet) all converge on SpaceCodeController.joinSpaceWithCode():

  1. Save code as recentCode (used for invite dedup in Route 3)
  2. POST /_synapse/client/pangea/v1/knock_with_code — Pangea-custom Synapse endpoint that validates the access code and invites the user
  3. Response contains { roomIds, alreadyJoined, rateLimited }
  4. joinRoomById(spaceId) → wait for sync → navigate to space

This is NOT a standard Matrix knock — the Synapse module handles the invite directly.


Route 3 — Knock-Accept

User knocks

User finds the course in one of three places:

  • Public course preview — linked from matrix.to or direct room ID navigation. Shows a "Join" button that becomes a knock if the room's join rule is knock.
  • Public room bottom sheet — search results for public rooms. For knock rooms, shows a code field and an "Ask to Join" button side by side. TODO: change to "Knock" for consistency.
  • Public room dialog — a simpler variant of the bottom sheet used in some navigation paths. Button label changes to "Knock" for knock-rule rooms.

User taps the knock button → standard Matrix knockRoom() call → confirmation dialog ("You have knocked") → wait for admin.

Admin approves

Admin notices the knock in one of three places:

  • Notification badge on the space icon in the navigation bar. TODO: need to add this.
  • Chat list for that space — the knocking user appears as a pending entry. TODO: make this a bit more attention-grabbing.
  • Member list (room participants page) — knocking users are sorted below joined members, labeled "Knocking."

Admin taps the knocking user → popup menu shows "Approve" (only visible for Membership.knock) → room.invite(userId).

Analytics room knocks follow the same mechanism — admins request access to a student's analytics room by knocking, and the student's client auto-accepts when the invite arrives.


Invite Handling

When an invite arrives via /sync, the sync listener routes it by room type:

Room type Action
Space Evaluate priority rules (below)
Analytics room Auto-join immediately
Previously knocked room Auto-join immediately
Other No sync-time action; handled when user taps it

Space invite priority

When a space invite arrives, the client evaluates in order:

  1. Child of joined parent → auto-join (no prompt)
  2. Code just inputted → skip (Route 2 is handling it)
  3. Previously knocked → auto-join (no prompt)
  4. Otherwise → show accept/decline dialog

KnockTracker

The server-side auto_accept_invite module was removed because it crashed Synapse (PR #21). Auto-accept now lives client-side via KnockTracker.

Matrix /sync invites use StrippedStateEvent, which lacks unsigned / prev_content — so the client can't tell from the invite alone whether it previously knocked. KnockTracker solves this by recording each knocked room ID in Matrix account data (org.pangea.knocked_rooms). When an invite arrives for a tracked room, the client auto-joins and clears the record.

Account data is used so the state survives reinstall, logout, and syncs across devices. Only the user can initiate a knock, so an invite for a tracked room is always a legitimate approval. Applies to both spaces and non-space rooms.

All auto-join cases

Every case where room.join() is called without explicit user confirmation:

Condition Trigger
Navigating to an invited room's chat view Building the widget
Invited space is a child of a joined parent Space invite via sync
Tapping a left space in the list User tap
Analytics room invite Always auto-joined
Default chats in a course (announcements, introductions) Viewing the course
knock_with_code succeeded Code entry flow
User previously knocked on the room Invite received for a prior knock

Future Work