Skip to content

Conversation

@tddang-linagora
Copy link
Collaborator

@tddang-linagora tddang-linagora commented Dec 9, 2025

Ticket

Resolved

Android

Invite link

android-invite-link.webm

Logged out

android-logged-out.webm

Logged in

android-logged-in.webm

iOS

Invite link

ios-invite-link.mp4

Logged out

ios-logged-out.mp4

Logged in

ios-logged-in.mp4

Summary by CodeRabbit

  • New Features

    • Added an "Invite Friend" sliver button in Contacts to share chat links (mobile share / web copy with snackbar).
  • Improvements

    • Universal chat links now open or start direct conversations more reliably.
    • Contacts header/search layout simplified for a cleaner look.
    • Sharing/intent handling made more robust to avoid duplicate subscriptions and race conditions.
  • Localization

    • Added English strings for "Invite friend" and "Link copied to clipboard".

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 9, 2025

Walkthrough

Adds an "Invite friend" UI and English i18n keys, updates URL launching to optionally open/create direct chats via Matrix IDs, defers sharing-intent initialization until after first sync, cleans up intent subscriptions, and removes two controller methods.

Changes

Cohort / File(s) Summary
Localization
assets/l10n/intl_en.arb
Adds inviteFriend and linkCopiedToClipboard keys and fixes JSON punctuation for supportChatDescription.
Removed controller methods
lib/pages/chat_draft/draft_chat.dart, lib/pages/chat_list/chat_list.dart
Removes setActiveClient(Client c) from DraftChatController and setActiveBundle(String bundle) from ChatListController.
Sharing intent stream cleanup & launcher call-site
lib/pages/chat_list/receive_sharing_intent_mixin.dart
Cancels existing file/URI subscriptions before re-subscribing; replaces openMatrixToUrl() with launchUrl(client: matrixState.client).
Contacts UI refinements
lib/pages/contacts_tab/contacts_appbar.dart
Sets Column.mainAxisSize to MainAxisSize.min and simplifies search layout by removing an inner Row/Expanded.
Contacts tab integration
lib/pages/contacts_tab/contacts_tab_body_view.dart
Conditionally inserts SliverInviteFriendButton when controller.client.userID is present; adds import for the new widget.
New invite friend widget
lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart
Adds SliverInviteFriendButton and _InviteFriendButtonDelegate; builds app universal invite link, shares on mobile, copies to clipboard and shows localized snackbar on web; includes error logging and styling.
URL launcher enhancements
lib/utils/url_launcher.dart
Changes launchUrl() to Future<void> launchUrl({Client? client}); adds branch to open/chat with Matrix IDs and helper _openChatWithUser(String matrixId, {Client? client}); adds isCreatingChatFromUrl guard.
Sharing intent initialization timing
lib/widgets/matrix.dart
Adds _hasInitializedSharingIntent, _initializeSharingIntentOnce(), and _initializeSharingIntentAfterFirstSync(Client) to defer and guard sharing-intent initialization until after first sync or once when clients already logged in.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Contacts UI
    participant Widget as SliverInviteFriendButton
    participant Platform as Platform API
    participant Clipboard as Clipboard/Share
    UI->>Widget: Tap Invite
    Widget->>Platform: detect platform
    alt Mobile
        Widget->>Clipboard: Share.share(url)
    else Web
        Widget->>Clipboard: writeToClipboard(url)
        Clipboard-->>Widget: success
        Widget->>UI: show snackbar "linkCopiedToClipboard"
    end
Loading
sequenceDiagram
    participant External as Incoming Universal Link
    participant UrlLauncher as UrlLauncher.launchUrl(client)
    participant Client as Matrix Client
    participant Router as Router/Rooms
    External->>UrlLauncher: open link (matrix ID path)
    UrlLauncher->>Client: find or create direct chat for matrixId
    alt room exists
        Client-->>UrlLauncher: roomId
        UrlLauncher->>Router: navigate to /rooms/{roomId}
    else no room
        Client-->>UrlLauncher: newRoomId
        UrlLauncher->>Router: navigate to /rooms/{newRoomId}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Inspect async/concurrency and the isCreatingChatFromUrl guard in lib/utils/url_launcher.dart.
  • Review first-sync detection and sharing-intent initialization guards in lib/widgets/matrix.dart for race conditions.
  • Validate platform branching, clipboard/share handling, and localization in lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart.
  • Search the codebase for callers of the removed controller methods to ensure no dangling references.

Possibly related issues

Suggested reviewers

  • hoangdat
  • dab246

Poem

🐰 I hopped a little link for you,
A tiny invite, shiny and new.
Share on mobile, copy on web,
A nibble, a hop — friends join the thread,
One tap, one link, and we're two (or a few). ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete, missing critical sections: Root cause, Solution, Impact description, and Test recommendations are all absent or empty. Add the missing sections: explain the root cause, outline all implementation details across the changed files, describe the impact, and provide testing recommendations.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly reflects the core feature: implementing invite functionality when the app is installed, matching the PR's primary objective and changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch TW-2759/invite-when-app-installed

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between db3c6c2 and 296dc32.

📒 Files selected for processing (1)
  • lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Deploy preview versions on pull requests
  • GitHub Check: Widget test
  • GitHub Check: Analyze code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
lib/utils/url_launcher.dart (1)

48-54: Minor: Redundant replaceFirst calls.

The path manipulation calls replaceFirst('/', '') twice. Consider extracting to a local variable for clarity:

     if (uri.host == AppConstants.appLinkUniversalLinkDomain) {
       final pathWithoutChatPrefix = uri.path.replaceFirst('/chat', '');
-      if (pathWithoutChatPrefix.startsWith('/') &&
-          pathWithoutChatPrefix.replaceFirst('/', '').isValidMatrixId) {
-        return _openChatWithUser(
-          pathWithoutChatPrefix.replaceFirst('/', ''),
-          client: client,
-        );
-      }
+      final matrixId = pathWithoutChatPrefix.replaceFirst('/', '');
+      if (pathWithoutChatPrefix.startsWith('/') && matrixId.isValidMatrixId) {
+        return _openChatWithUser(matrixId, client: client);
+      }
       context.go(pathWithoutChatPrefix);
lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (1)

50-58: Desktop platforms not handled - tap does nothing silently.

The onTap handler only covers mobile (Share.share) and web (Clipboard). On desktop (Linux, macOS, Windows), tapping the button has no effect. Consider adding clipboard support for desktop:

           try {
             if (PlatformInfos.isMobile) {
               await Share.share(url);
-            } else if (PlatformInfos.isWeb) {
+            } else {
+              // Web and Desktop
               await Clipboard.setData(ClipboardData(text: url));
               TwakeSnackBar.show(
                 context,
                 L10n.of(context)!.linkCopiedToClipboard,
               );
             }
lib/widgets/matrix.dart (1)

379-388: Consider adding error handling for sync stream.

If onSync.stream.first never completes (e.g., network issues during initial sync), sharing intent won't be initialized. Consider adding a timeout or error handler:

   void _initializeSharingIntentAfterFirstSync(Client client) {
     if (_hasInitializedSharingIntent) return;
-    client.onSync.stream.first.then((_) {
+    client.onSync.stream.first.timeout(
+      const Duration(minutes: 2),
+      onTimeout: () => SyncUpdate(nextBatch: ''),
+    ).then((_) {
       Logs().d(
         'MatrixState::_initializeSharingIntentAfterFirstSync: First sync completed, initializing sharing intent',
       );
       _initializeSharingIntentOnce();
+    }).catchError((e) {
+      Logs().e('MatrixState::_initializeSharingIntentAfterFirstSync: Error waiting for first sync: $e');
+      _initializeSharingIntentOnce(); // Initialize anyway to not block sharing
     });
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 77aef66 and 64b78dc.

📒 Files selected for processing (9)
  • assets/l10n/intl_en.arb (1 hunks)
  • lib/pages/chat_draft/draft_chat.dart (0 hunks)
  • lib/pages/chat_list/chat_list.dart (0 hunks)
  • lib/pages/chat_list/receive_sharing_intent_mixin.dart (3 hunks)
  • lib/pages/contacts_tab/contacts_appbar.dart (2 hunks)
  • lib/pages/contacts_tab/contacts_tab_body_view.dart (2 hunks)
  • lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (1 hunks)
  • lib/utils/url_launcher.dart (3 hunks)
  • lib/widgets/matrix.dart (4 hunks)
💤 Files with no reviewable changes (2)
  • lib/pages/chat_draft/draft_chat.dart
  • lib/pages/chat_list/chat_list.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Deploy preview versions on pull requests
  • GitHub Check: Analyze code
  • GitHub Check: Widget test
🔇 Additional comments (7)
lib/pages/contacts_tab/contacts_appbar.dart (1)

29-70: LGTM! Layout simplification.

Adding mainAxisSize: MainAxisSize.min prevents unnecessary vertical expansion, and removing the redundant Row/Expanded wrapper around SearchTextField simplifies the widget tree without changing visual behavior.

assets/l10n/intl_en.arb (1)

3488-3491: LGTM! Localization entries added correctly.

The new inviteFriend and linkCopiedToClipboard keys follow the existing ARB format and support the new invite friend feature.

lib/pages/chat_list/receive_sharing_intent_mixin.dart (2)

99-101: LGTM! Client parameter correctly passed.

Passing matrixState.client enables the new Matrix ID navigation flow in UrlLauncher.


113-126: Good defensive programming - preventing duplicate subscriptions.

Cancelling existing subscriptions before re-subscribing prevents memory leaks and duplicate event handling if initReceiveSharingIntent() is called multiple times.

lib/pages/contacts_tab/contacts_tab_body_view.dart (1)

35-36: LGTM! Conditional rendering with proper null check.

The SliverInviteFriendButton is correctly guarded by the userID != null check, ensuring it only appears for authenticated users. The force unwrap is safe given the preceding condition.

lib/widgets/matrix.dart (2)

372-388: LGTM! Deferred sharing intent initialization improves reliability.

Initializing sharing intent after the first sync ensures the client has room data before processing deep links. The guard flag prevents duplicate initialization.


661-665: LGTM! Immediate initialization for already logged-in clients.

For returning users with persisted sessions, initializing sharing intent immediately in initMatrix ensures deep links work without waiting for a redundant sync trigger.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/utils/url_launcher.dart (1)

33-39: Fix return type mismatch: cannot return void from Future<void>

At line 38, return openMatrixToUrl(); attempts to return the result of a void async function. Since openMatrixToUrl() is declared as void openMatrixToUrl() async, the expression has type void, which cannot be returned from a function declared to return Future<void>. This is a type error that the analyzer will flag.

Call openMatrixToUrl() as a statement and then return explicitly:

      return openMatrixToUrl();
+     openMatrixToUrl();
+     return;

Alternatively, if you want launchUrl's Future to complete only after matrix navigation logic finishes, change openMatrixToUrl to return Future<void> and await it (this only affects the single call site at line 38).

♻️ Duplicate comments (1)
lib/utils/url_launcher.dart (1)

252-273: Guard _openChatWithUser against null client/roomId and always reset isCreatingChatFromUrl

As written:

  • If client is null, rooms is null and startDirectChat is never called, so roomId will be null and you still navigate to /rooms/null.
  • If client.startDirectChat throws, isCreatingChatFromUrl is never reset to false, so this instance will silently ignore all future URL‑driven chat opens (if (isCreatingChatFromUrl) return;).
  • Even if startDirectChat returns a nullable ID, you unconditionally call context.go('/rooms/$roomId').

A safer structure:

  • Early‑return (with optional user feedback/log) when client is null.
  • Wrap the body in try/finally so isCreatingChatFromUrl is always reset.
  • Only navigate when you have a non‑null roomId.

For example:

   Future<void> _openChatWithUser(
     String matrixId, {
     Client? client,
   }) async {
-    if (isCreatingChatFromUrl) return;
-    isCreatingChatFromUrl = true;
-    final rooms = client?.rooms.where((room) => room.isDirectChat).toList();
+    if (isCreatingChatFromUrl) return;
+    isCreatingChatFromUrl = true;
+
+    if (client == null) {
+      // Optionally show a snackbar or log here to indicate failure.
+      isCreatingChatFromUrl = false;
+      return;
+    }
+
+    final rooms = client.rooms.where((room) => room.isDirectChat).toList();
@@
-    if (availableRoom != null) {
-      isCreatingChatFromUrl = false;
-      context.go('/rooms/${availableRoom.id}');
-      return;
-    }
-
-    final roomId = await client?.startDirectChat(matrixId);
-    isCreatingChatFromUrl = false;
-    context.go('/rooms/$roomId');
+    if (availableRoom != null) {
+      isCreatingChatFromUrl = false;
+      context.go('/rooms/${availableRoom.id}');
+      return;
+    }
+
+    try {
+      final roomId = await client.startDirectChat(matrixId);
+      if (roomId == null) {
+        // Optionally show error/snackbar here.
+        return;
+      }
+      context.go('/rooms/$roomId');
+    } finally {
+      isCreatingChatFromUrl = false;
+    }
   }

This addresses the previously‑raised /rooms/null navigation risk and makes the re‑entrancy guard robust even when the SDK throws.

🧹 Nitpick comments (1)
lib/utils/url_launcher.dart (1)

48-54: Minor readability tweak when extracting Matrix ID from universal‑link path

The logic is correct, but pathWithoutChatPrefix.replaceFirst('/', '') is computed three times, which hurts readability slightly and risks subtle inconsistencies if it’s later changed.

You can factor out the matrix ID once and reuse it:

       final pathWithoutChatPrefix = uri.path.replaceFirst('/chat', '');
-      if (pathWithoutChatPrefix.startsWith('/') &&
-          pathWithoutChatPrefix.replaceFirst('/', '').isValidMatrixId) {
-        return _openChatWithUser(
-          pathWithoutChatPrefix.replaceFirst('/', ''),
-          client: client,
-        );
-      }
+      if (pathWithoutChatPrefix.startsWith('/')) {
+        final matrixId = pathWithoutChatPrefix.substring(1);
+        if (matrixId.isValidMatrixId) {
+          return _openChatWithUser(
+            matrixId,
+            client: client,
+          );
+        }
+      }

Behavior stays the same but the intent (“take the path after /chat/ and treat it as a Matrix ID”) is clearer.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 64b78dc and 01fbba8.

📒 Files selected for processing (1)
  • lib/utils/url_launcher.dart (3 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Widget test
  • GitHub Check: Analyze code

@github-actions
Copy link
Contributor

github-actions bot commented Dec 9, 2025

This PR has been deployed to https://linagora.github.io/twake-on-matrix/2761

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (2)

45-45: Address the TODO comment for the URL format.

The TODO indicates the URL structure may still be a placeholder.

Do you want me to help finalize the URL format or open a new issue to track this task?


58-60: Consider showing user feedback when sharing/copying fails.

The error is logged but the user receives no indication that the operation failed. This may lead to confusion if they expect the link to be shared or copied.

Consider showing a snackbar with an error message:

   } catch (e) {
     Logs().e('InviteFriendButtonDelegate::onTap():', e);
+    if (context.mounted) {
+      TwakeSnackBar.show(
+        context,
+        L10n.of(context)!.anErrorOccurred, // or a more specific message
+      );
+    }
   }
lib/utils/url_launcher.dart (2)

33-55: Clarify behavior for matrix‑ID universal links when client is null

In the universal-link branch, if pathWithoutChatPrefix is a Matrix ID you now always go through _openChatWithUser. But _openChatWithUser immediately returns when client == null, so in that case launchUrl completes without any navigation. Previously this path would have fallen through to context.go(pathWithoutChatPrefix).

If launchUrl can be called before the Matrix client is ready (e.g. app opened from an invite while logged out or before first sync), this can make invite links appear to “do nothing”.

Consider keeping the previous routing behavior as a fallback when no client is available:

-      if (pathWithoutChatPrefix.startsWith('/') &&
-          pathWithoutChatPrefix.replaceFirst('/', '').isValidMatrixId) {
-        return _openChatWithUser(
-          pathWithoutChatPrefix.replaceFirst('/', ''),
-          client: client,
-        );
-      }
-      context.go(pathWithoutChatPrefix);
-      return;
+      if (pathWithoutChatPrefix.startsWith('/') &&
+          pathWithoutChatPrefix.replaceFirst('/', '').isValidMatrixId) {
+        if (client != null) {
+          return _openChatWithUser(
+            pathWithoutChatPrefix.replaceFirst('/', ''),
+            client: client,
+          );
+        }
+        // No client yet (e.g. logged out) – fall back to plain navigation.
+      }
+      context.go(pathWithoutChatPrefix);
+      return;

This keeps the new “open or create DM” behavior for logged‑in cases while preserving a sane default when the client isn’t available.


252-274: Make isCreatingChatFromUrl robust with try/finally

isCreatingChatFromUrl is set back to false only on the happy paths. If client.startDirectChat (or the room iteration) throws, the flag stays true and future calls on the same UrlLauncher instance will be ignored.

Wrapping the body in try/finally avoids this sticky state and simplifies the resets:

   Future<void> _openChatWithUser(
     String matrixId, {
     Client? client,
   }) async {
-    if (client == null || isCreatingChatFromUrl) return;
-    isCreatingChatFromUrl = true;
-    final rooms = client.rooms.where((room) => room.isDirectChat).toList();
-    final availableRoom = rooms.firstWhereOrNull((room) {
-      return room.getParticipants().any((user) => user.id == matrixId);
-    });
-
-    if (availableRoom != null) {
-      isCreatingChatFromUrl = false;
-      context.go('/rooms/${availableRoom.id}');
-      return;
-    }
-
-    final roomId = await client.startDirectChat(matrixId);
-    isCreatingChatFromUrl = false;
-    context.go('/rooms/$roomId');
+    if (client == null || isCreatingChatFromUrl) return;
+    isCreatingChatFromUrl = true;
+    try {
+      final rooms = client.rooms.where((room) => room.isDirectChat).toList();
+      final availableRoom = rooms.firstWhereOrNull((room) {
+        return room.getParticipants().any((user) => user.id == matrixId);
+      });
+
+      if (availableRoom != null) {
+        context.go('/rooms/${availableRoom.id}');
+        return;
+      }
+
+      final roomId = await client.startDirectChat(matrixId);
+      context.go('/rooms/$roomId');
+    } finally {
+      isCreatingChatFromUrl = false;
+    }
   }

This keeps the behavior the same on success while ensuring the reentrancy guard doesn’t get stuck after an exception.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01fbba8 and db3c6c2.

📒 Files selected for processing (2)
  • lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (1 hunks)
  • lib/utils/url_launcher.dart (3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-09T05:40:46.528Z
Learnt from: nqhhdev
Repo: linagora/twake-on-matrix PR: 2751
File: lib/pages/chat_draft/draft_chat.dart:608-611
Timestamp: 2025-12-09T05:40:46.528Z
Learning: In draft_chat.dart, when sending media with caption through sendImagesWithCaption, the dialog returns SendMediaWithCaptionStatus.emptyRoom (not done) when the user confirms the send, because no room exists yet. The emptyRoom status triggers room creation and media upload, making it effectively the "success" case. Therefore, text should only be restored when status != emptyRoom.

Applied to files:

  • lib/utils/url_launcher.dart
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Deploy preview versions on pull requests
  • GitHub Check: Widget test
  • GitHub Check: Analyze code
🔇 Additional comments (1)
lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart (1)

109-110: Good fix for shouldRebuild!

The shouldRebuild method now correctly compares userId to determine when the delegate needs to rebuild, addressing the previous review concern. This ensures the invite link updates when the user switches accounts.

pathWithoutChatPrefix.replaceFirst('/', ''),
client: client,
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safer to add a subpath in the universal link that manages invite ?

Like links.twake.app/chat/user/@aaa:linagora.com instead of links.twake.app/chat/@aaa:linagora.com to keep other routing possibilities for the future? Maybe one day links.twake.app/chat/group/ or links.twake.app/chat/settings/ that opens settings or I don't know

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okie, so please keep as the matrix scheme, I propose links.twake.app/chat/user/#/@aaa:linagora.com

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://matrix.to/#/@tddang:linagora.com

Twake chat is not listed in the supported apps.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not know that Matrix scheme already manage rooms, users, messages, etc so maybe my comment is less relevant

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.

4 participants