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>
This commit is contained in:
wcjord 2026-02-24 14:44:36 -05:00 committed by GitHub
parent 54a9a9a500
commit 7f1e8f5ea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 320 additions and 93 deletions

View file

@ -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

View file

@ -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<ChatList>
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<ChatList>
}
}
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;

View file

@ -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<void> 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);
}

View file

@ -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<PublicRoomBottomSheet> {
await showFutureLoadingDialog<String>(
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,
);

View file

@ -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<CourseChats>
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<InviteAction>(
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<InviteAction>(
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<String>('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<String>('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) {

View file

@ -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<PublicCoursePreview>
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});

View file

@ -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<void> joinKnockedRoom() async {
await join();
await KnockTracker.clearKnock(client, id);
}
}
extension KnockClientExtension on Client {
Future<String> knockAndRecordRoom(
String roomIdOrAlias, {
List<String>? via,
String? reason,
}) async {
final resp = await knockRoom(roomIdOrAlias, via: via, reason: reason);
await KnockTracker.recordKnock(this, roomIdOrAlias);
return resp;
}
}

View file

@ -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<void> 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<void> clearKnock(Client client, String roomId) async {
final ids = _getKnockedRoomIds(client);
if (ids.remove(roomId)) {
await _writeIds(client, ids);
}
}
static List<String> _getKnockedRoomIds(Client client) {
final data = client.accountData[_accountDataKey];
final list = data?.content[_roomIdsField];
if (list is List) {
return list.cast<String>().toList();
}
return [];
}
static Future<void> _writeIds(Client client, List<String> ids) async {
await client.setAccountData(client.userID!, _accountDataKey, {
_roomIdsField: ids,
});
}
}

View file

@ -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<SpaceAnalytics> {
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)

View file

@ -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) {