diff --git a/.github/instructions/joining-courses.instructions.md b/.github/instructions/joining-courses.instructions.md new file mode 100644 index 000000000..38d1d021c --- /dev/null +++ b/.github/instructions/joining-courses.instructions.md @@ -0,0 +1,125 @@ +--- +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. + +- **Synapse module cleanup**: [synapse-pangea-chat PR #21](https://github.com/pangeachat/synapse-pangea-chat/pull/21) +- **Course plans**: [course-plans.instructions.md](course-plans.instructions.md) + +--- + +## 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. + +--- + +## Route 1 — Class Link + +**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](https://github.com/pangeachat/synapse-pangea-chat/pull/21)). Auto-accept now lives client-side via [`KnockTracker`](../../lib/pangea/join_codes/knock_tracker.dart). + +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 + diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 7daeefea8..e91ac526a 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -18,6 +18,8 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart import 'package:fluffychat/pangea/chat_settings/widgets/chat_context_menu_action.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_tracker.dart'; import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; import 'package:fluffychat/pangea/join_codes/space_code_repo.dart'; import 'package:fluffychat/pangea/navigation/navigation_util.dart'; @@ -550,6 +552,10 @@ class ChatListController extends State event.type == EventTypes.RoomCreate && event.content['type'] == PangeaRoomTypes.analytics, ); + final hasKnocked = KnockTracker.hasKnocked( + Matrix.of(context).client, + inviteEntry.key, + ); if (isSpace) { final spaceId = inviteEntry.key; @@ -565,18 +571,20 @@ class ChatListController extends State } } - if (isAnalytics) { - final analyticsRoom = Matrix.of( + if (isAnalytics || hasKnocked) { + final room = Matrix.of( context, ).client.getRoomById(inviteEntry.key); + if (room == null) return; + try { - await analyticsRoom?.join(); + await room.joinKnockedRoom(); } catch (err, s) { ErrorHandler.logError( m: "Failed to join analytics room", e: err, s: s, - data: {"analyticsRoom": analyticsRoom?.id}, + data: {"roomId": room.id}, ); } return; diff --git a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart index 980380c0f..c54d1fffb 100644 --- a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart @@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/pangea/join_codes/space_code_repo.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; @@ -73,7 +74,7 @@ Future showInviteDialog(Room room, BuildContext context) async { final joinResult = await showFutureLoadingDialog( context: context, future: () async { - await room.join(); + await room.joinKnockedRoom(); }, exceptionContext: ExceptionContext.joinRoom, ); @@ -98,7 +99,7 @@ void chatListHandleSpaceTap(BuildContext context, Room space) { showFutureLoadingDialog( context: context, future: () async { - await space.join(); + await space.joinKnockedRoom(); setActiveSpaceAndCloseChat(); }, ); @@ -121,6 +122,8 @@ void chatListHandleSpaceTap(BuildContext context, Room space) { } else if (justInputtedCode != null && justInputtedCode == space.classCode) { // do nothing + } else if (space.hasKnocked) { + autoJoin(space); } else { showInviteDialog(space, context); } diff --git a/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart b/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart index b7bb3fb28..744fd3f98 100644 --- a/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart +++ b/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart @@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -131,7 +132,7 @@ class PublicRoomBottomSheetState extends State { await showFutureLoadingDialog( context: context, future: () async => - client.knockRoom(roomAlias ?? chunk!.roomId, via: via), + client.knockAndRecordRoom(roomAlias ?? chunk!.roomId, via: via), onSuccess: () => L10n.of(context).knockSpaceSuccess, delay: false, ); diff --git a/lib/pangea/course_chats/course_chats_page.dart b/lib/pangea/course_chats/course_chats_page.dart index 27bf0cf63..8ebca2de0 100644 --- a/lib/pangea/course_chats/course_chats_page.dart +++ b/lib/pangea/course_chats/course_chats_page.dart @@ -20,6 +20,7 @@ import 'package:fluffychat/pangea/course_plans/course_activities/activity_summar import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/pangea/navigation/navigation_util.dart'; import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; @@ -334,96 +335,113 @@ class CourseChatsController extends State void onChatTap(Room room) async { if (room.membership == Membership.invite) { - final theme = Theme.of(context); - final inviteEvent = room.getState( - EventTypes.RoomMember, - room.client.userID!, - ); - final matrixLocals = MatrixLocals(L10n.of(context)); - final action = await showAdaptiveDialog( - barrierDismissible: true, - context: context, - builder: (context) => AlertDialog.adaptive( - title: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 256), - child: Center( + if (room.hasKnocked) { + if (!mounted) return; + await showFutureLoadingDialog( + context: context, + future: () async { + final waitForRoom = room.client.waitForRoomInSync( + room.id, + join: true, + ); + await room.joinKnockedRoom(); + await waitForRoom; + }, + exceptionContext: ExceptionContext.joinRoom, + ); + } else { + final theme = Theme.of(context); + final inviteEvent = room.getState( + EventTypes.RoomMember, + room.client.userID!, + ); + final matrixLocals = MatrixLocals(L10n.of(context)); + final action = await showAdaptiveDialog( + barrierDismissible: true, + context: context, + builder: (context) => AlertDialog.adaptive( + title: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256), + child: Center( + child: Text( + room.getLocalizedDisplayname(matrixLocals), + textAlign: TextAlign.center, + ), + ), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), child: Text( - room.getLocalizedDisplayname(matrixLocals), + inviteEvent == null + ? L10n.of(context).inviteForMe + : inviteEvent.content.tryGet('reason') ?? + L10n.of(context).youInvitedBy( + room + .unsafeGetUserFromMemoryOrFallback( + inviteEvent.senderId, + ) + .calcDisplayname(i18n: matrixLocals), + ), textAlign: TextAlign.center, ), ), - ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), - child: Text( - inviteEvent == null - ? L10n.of(context).inviteForMe - : inviteEvent.content.tryGet('reason') ?? - L10n.of(context).youInvitedBy( - room - .unsafeGetUserFromMemoryOrFallback( - inviteEvent.senderId, - ) - .calcDisplayname(i18n: matrixLocals), - ), - textAlign: TextAlign.center, - ), - ), - actions: [ - AdaptiveDialogAction( - onPressed: () => Navigator.of(context).pop(InviteAction.accept), - bigButtons: true, - child: Text(L10n.of(context).accept), - ), - AdaptiveDialogAction( - onPressed: () => Navigator.of(context).pop(InviteAction.decline), - bigButtons: true, - child: Text( - L10n.of(context).decline, - style: TextStyle(color: theme.colorScheme.error), + actions: [ + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(InviteAction.accept), + bigButtons: true, + child: Text(L10n.of(context).accept), ), - ), - AdaptiveDialogAction( - onPressed: () => Navigator.of(context).pop(InviteAction.block), - bigButtons: true, - child: Text( - L10n.of(context).block, - style: TextStyle(color: theme.colorScheme.error), + AdaptiveDialogAction( + onPressed: () => + Navigator.of(context).pop(InviteAction.decline), + bigButtons: true, + child: Text( + L10n.of(context).decline, + style: TextStyle(color: theme.colorScheme.error), + ), ), - ), - ], - ), - ); - switch (action) { - case null: - return; - case InviteAction.accept: - break; - case InviteAction.decline: - await showFutureLoadingDialog( - context: context, - future: () => room.leave(), - ); - return; - case InviteAction.block: - final userId = inviteEvent?.senderId; - context.go('/rooms/settings/security/ignorelist', extra: userId); - return; + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(InviteAction.block), + bigButtons: true, + child: Text( + L10n.of(context).block, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ); + switch (action) { + case null: + return; + case InviteAction.accept: + break; + case InviteAction.decline: + await showFutureLoadingDialog( + context: context, + future: () => room.leave(), + ); + return; + case InviteAction.block: + final userId = inviteEvent?.senderId; + context.go('/rooms/settings/security/ignorelist', extra: userId); + return; + } + if (!mounted) return; + final joinResult = await showFutureLoadingDialog( + context: context, + future: () async { + final waitForRoom = room.client.waitForRoomInSync( + room.id, + join: true, + ); + await room.join(); + await waitForRoom; + }, + exceptionContext: ExceptionContext.joinRoom, + ); + if (joinResult.error != null) return; } - if (!mounted) return; - final joinResult = await showFutureLoadingDialog( - context: context, - future: () async { - final waitForRoom = room.client.waitForRoomInSync( - room.id, - join: true, - ); - await room.join(); - await waitForRoom; - }, - exceptionContext: ExceptionContext.joinRoom, - ); - if (joinResult.error != null) return; } if (room.membership == Membership.ban) { diff --git a/lib/pangea/course_creation/public_course_preview.dart b/lib/pangea/course_creation/public_course_preview.dart index 8a56b1b32..1f0088ce6 100644 --- a/lib/pangea/course_creation/public_course_preview.dart +++ b/lib/pangea/course_creation/public_course_preview.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/course_creation/public_course_preview_view.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -140,7 +141,7 @@ class PublicCoursePreviewController extends State String roomId; try { roomId = knock - ? await client.knockRoom(widget.roomID!) + ? await client.knockAndRecordRoom(widget.roomID!) : await client.joinRoom(widget.roomID!); } catch (e, s) { ErrorHandler.logError(e: e, s: s, data: {'roomID': widget.roomID}); diff --git a/lib/pangea/join_codes/knock_room_extension.dart b/lib/pangea/join_codes/knock_room_extension.dart new file mode 100644 index 000000000..6193bdd99 --- /dev/null +++ b/lib/pangea/join_codes/knock_room_extension.dart @@ -0,0 +1,24 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/join_codes/knock_tracker.dart'; + +extension KnockRoomExtension on Room { + bool get hasKnocked => KnockTracker.hasKnocked(client, id); + + Future joinKnockedRoom() async { + await join(); + await KnockTracker.clearKnock(client, id); + } +} + +extension KnockClientExtension on Client { + Future knockAndRecordRoom( + String roomIdOrAlias, { + List? via, + String? reason, + }) async { + final resp = await knockRoom(roomIdOrAlias, via: via, reason: reason); + await KnockTracker.recordKnock(this, roomIdOrAlias); + return resp; + } +} diff --git a/lib/pangea/join_codes/knock_tracker.dart b/lib/pangea/join_codes/knock_tracker.dart new file mode 100644 index 000000000..ed89cc681 --- /dev/null +++ b/lib/pangea/join_codes/knock_tracker.dart @@ -0,0 +1,45 @@ +import 'package:matrix/matrix.dart'; + +/// Tracks room IDs the user has knocked on so the client can auto-accept +/// invites for previously knocked rooms. +/// +/// Stored in Matrix account data under [_accountDataKey] so the state +/// survives reinstall, logout, and syncs across devices. +class KnockTracker { + static const String _accountDataKey = 'org.pangea.knocked_rooms'; + static const String _roomIdsField = 'room_ids'; + + static Future recordKnock(Client client, String roomId) async { + final ids = _getKnockedRoomIds(client); + if (!ids.contains(roomId)) { + ids.add(roomId); + await _writeIds(client, ids); + } + } + + static bool hasKnocked(Client client, String roomId) { + return _getKnockedRoomIds(client).contains(roomId); + } + + static Future clearKnock(Client client, String roomId) async { + final ids = _getKnockedRoomIds(client); + if (ids.remove(roomId)) { + await _writeIds(client, ids); + } + } + + static List _getKnockedRoomIds(Client client) { + final data = client.accountData[_accountDataKey]; + final list = data?.content[_roomIdsField]; + if (list is List) { + return list.cast().toList(); + } + return []; + } + + static Future _writeIds(Client client, List ids) async { + await client.setAccountData(client.userID!, _accountDataKey, { + _roomIdsField: ids, + }); + } +} diff --git a/lib/pangea/space_analytics/space_analytics.dart b/lib/pangea/space_analytics/space_analytics.dart index 6aa19d514..8ef697742 100644 --- a/lib/pangea/space_analytics/space_analytics.dart +++ b/lib/pangea/space_analytics/space_analytics.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart' import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/space_analytics/analytics_download_model.dart'; @@ -285,7 +286,7 @@ class SpaceAnalyticsState extends State { try { final roomId = _analyticsRoomIdOfUser(user); if (roomId == null) return; - await Matrix.of(context).client.knockRoom( + await Matrix.of(context).client.knockAndRecordRoom( roomId, via: room?.spaceChildren .firstWhereOrNull((child) => child.roomId == roomId) diff --git a/lib/widgets/adaptive_dialogs/public_room_dialog.dart b/lib/widgets/adaptive_dialogs/public_room_dialog.dart index 7fee87bfb..a25f7c4ea 100644 --- a/lib/widgets/adaptive_dialogs/public_room_dialog.dart +++ b/lib/widgets/adaptive_dialogs/public_room_dialog.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/join_codes/knock_room_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import '../../config/themes.dart'; import '../../utils/url_launcher.dart'; @@ -36,7 +37,7 @@ class PublicRoomDialog extends StatelessWidget { return chunk.roomId; } final roomId = chunk != null && knock - ? await client.knockRoom(chunk.roomId, via: via) + ? await client.knockAndRecordRoom(chunk.roomId, via: via) : await client.joinRoom(roomAlias ?? chunk!.roomId, via: via); if (!knock && client.getRoomById(roomId) == null) {