Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion assets/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3485,5 +3485,7 @@
},
"unblockUserToSendMessages": "Unblock user to send messages",
"supportChat": "Support Chat",
"supportChatDescription": "I’ve got your back! If you encounter bugs or need any help using Twake Workplace, send a message, I will reply to you swiftly"
"supportChatDescription": "I’ve got your back! If you encounter bugs or need any help using Twake Workplace, send a message, I will reply to you swiftly",
"inviteFriend": "Invite friend",
"linkCopiedToClipboard": "Link copied to clipboard"
}
4 changes: 0 additions & 4 deletions lib/pages/chat_draft/draft_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,6 @@ class DraftChatController extends State<DraftChat>

TextEditingController sendController = TextEditingController();

void setActiveClient(Client c) => setState(() {
Matrix.of(context).setActiveClient(c);
});

Future<String> _triggerTagGreetingMessage() async {
if (_userProfile.value == null) {
return sendController.value.text;
Expand Down
14 changes: 0 additions & 14 deletions lib/pages/chat_list/chat_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -466,20 +466,6 @@ class ChatListController extends State<ChatList>
});
}

void setActiveBundle(String bundle) {
context.go('/rooms');
setState(() {
conversationSelectionNotifier.value.clear();
Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context)
.currentBundle!
.any((client) => client == client)) {
Matrix.of(context)
.setActiveClient(Matrix.of(context).currentBundle!.first);
}
});
}

void editBundlesForAccount(String? userId, String? activeBundle) async {
final l10n = L10n.of(context)!;
final client = Matrix.of(context)
Expand Down
4 changes: 3 additions & 1 deletion lib/pages/chat_list/receive_sharing_intent_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ mixin ReceiveSharingIntentMixin<T extends StatefulWidget> on State<T> {
}
WidgetsBinding.instance.addPostFrameCallback((_) {
UrlLauncher(TwakeApp.routerKey.currentContext!, url: text)
.openMatrixToUrl();
.launchUrl(client: matrixState.client);
});
}

Expand All @@ -110,6 +110,7 @@ mixin ReceiveSharingIntentMixin<T extends StatefulWidget> on State<T> {
if (!PlatformInfos.isMobile) return;

// For sharing images coming from outside the app while the app is in the memory
intentFileStreamSubscription?.cancel();
intentFileStreamSubscription =
ReceiveSharingIntent.instance.getMediaStream().listen(
(shareMedia) {
Expand All @@ -120,6 +121,7 @@ mixin ReceiveSharingIntentMixin<T extends StatefulWidget> on State<T> {

// For receiving shared Uris
final appLinks = AppLinks();
intentUriStreamSubscription?.cancel();
intentUriStreamSubscription =
appLinks.stringLinkStream.listen(_processIncomingUris);

Expand Down
20 changes: 6 additions & 14 deletions lib/pages/contacts_tab/contacts_appbar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ContactsAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TwakeAppBar(
title: L10n.of(context)!.contacts,
Expand Down Expand Up @@ -61,20 +62,11 @@ class ContactsAppBar extends StatelessWidget {
: LinagoraSysColors.material().onPrimary,
),
height: ContactsAppbarStyle.textFieldHeight,
child: Padding(
padding: ContactsAppbarStyle.searchFieldPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SearchTextField(
textEditingController: textEditingController,
autofocus: false,
focusNode: searchFocusNode,
),
),
],
),
padding: ContactsAppbarStyle.searchFieldPadding,
child: SearchTextField(
textEditingController: textEditingController,
autofocus: false,
focusNode: searchFocusNode,
),
);
},
Expand Down
3 changes: 3 additions & 0 deletions lib/pages/contacts_tab/contacts_tab_body_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:fluffychat/domain/app_state/contact/get_contacts_state.dart';
import 'package:fluffychat/pages/contacts_tab/contacts_tab.dart';
import 'package:fluffychat/pages/contacts_tab/contacts_tab_view_style.dart';
import 'package:fluffychat/pages/contacts_tab/empty_contacts_body.dart';
import 'package:fluffychat/pages/contacts_tab/widgets/sliver_invite_friend_button.dart';
import 'package:fluffychat/pages/new_private_chat/widget/expansion_contact_list_tile.dart';
import 'package:fluffychat/pages/new_private_chat/widget/expansion_phonebook_contact_list_tile.dart';
import 'package:fluffychat/pages/new_private_chat/widget/loading_contact_widget.dart';
Expand Down Expand Up @@ -31,6 +32,8 @@ class ContactsTabBodyView extends StatelessWidget {
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
if (controller.client.userID != null)
SliverInviteFriendButton(userId: controller.client.userID!),
_SliverWarningBanner(controller: controller),
_SliverPhonebookLoading(controller: controller),
_SliverRecentContacts(controller: controller),
Expand Down
111 changes: 111 additions & 0 deletions lib/pages/contacts_tab/widgets/sliver_invite_friend_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:fluffychat/config/app_constants.dart';
import 'package:fluffychat/generated/l10n/app_localizations.dart';
import 'package:fluffychat/resource/image_paths.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/string_extension.dart';
import 'package:fluffychat/utils/twake_snackbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart';
import 'package:matrix/matrix.dart';
import 'package:share_plus/share_plus.dart';

class SliverInviteFriendButton extends StatelessWidget {
const SliverInviteFriendButton({super.key, required this.userId});

final String userId;

@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
floating: true,
delegate: _InviteFriendButtonDelegate(userId),
);
}
}

class _InviteFriendButtonDelegate extends SliverPersistentHeaderDelegate {
const _InviteFriendButtonDelegate(this.userId);

final String userId;

@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final sysColor = LinagoraSysColors.material();
final textTheme = Theme.of(context).textTheme;
return Material(
color: sysColor.onPrimary,
child: InkWell(
onTap: () async {
// TODO: Placeholder url
const domain = AppConstants.appLinkUniversalLinkDomain;
final url = 'https://$domain/chat/#/${Uri.encodeComponent(userId)}';
try {
if (PlatformInfos.isMobile) {
await Share.share(url);
} else if (PlatformInfos.isWeb) {
await Clipboard.setData(ClipboardData(text: url));
TwakeSnackBar.show(
context,
L10n.of(context)!.linkCopiedToClipboard,
);
}
} catch (e) {
Logs().e('InviteFriendButtonDelegate::onTap():', e);
}
},
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: sysColor.surfaceTint.withValues(alpha: 0.16),
),
),
),
child: Row(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: SvgPicture.asset(
ImagePaths.icPersonCheck,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
sysColor.primary,
BlendMode.srcIn,
),
),
),
Flexible(
child: Text(
L10n.of(context)!.inviteFriend.capitalize(context),
style: textTheme.bodyLarge?.copyWith(
fontSize: 17,
height: 24 / 17,
color: sysColor.primary,
),
),
),
],
),
),
),
);
}

@override
double get maxExtent => 64;

@override
double get minExtent => 0;

@override
bool shouldRebuild(covariant _InviteFriendButtonDelegate oldDelegate) =>
oldDelegate.userId != userId;
}
47 changes: 38 additions & 9 deletions lib/utils/url_launcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class UrlLauncher with GoToDraftChatMixin {
final ChromeSafariBrowser? browser =
PlatformInfos.isMobile ? ChromeSafariBrowser() : null;

void launchUrl() {
Future<void> launchUrl({Client? client}) async {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for launchUrl method calls with client parameter
rg -nP --type=dart -C3 'launchUrl\s*\(\s*client:'

Repository: linagora/twake-on-matrix

Length of output: 659


🏁 Script executed:

# Also check the actual method body to see if client is used
rg -nP --type=dart -A 20 'Future<void> launchUrl\(\{Client\?'

Repository: linagora/twake-on-matrix

Length of output: 1643


🏁 Script executed:

# Search for any imports or usage of Client class in the file
rg -nP --type=dart 'Client' lib/utils/url_launcher.dart

Repository: linagora/twake-on-matrix

Length of output: 145


🏁 Script executed:

# Check the complete launchUrl method body and see all method calls
rg -nP --type=dart -A 100 'Future<void> launchUrl\(\{Client\?' lib/utils/url_launcher.dart | head -150

Repository: linagora/twake-on-matrix

Length of output: 4528


🏁 Script executed:

# Check if openMatrixToUrl method accepts a client parameter
rg -nP --type=dart 'void openMatrixToUrl' lib/utils/url_launcher.dart

Repository: linagora/twake-on-matrix

Length of output: 123


🏁 Script executed:

# Check if client parameter is passed to any method within launchUrl
rg -nP --type=dart 'client:' lib/utils/url_launcher.dart

Repository: linagora/twake-on-matrix

Length of output: 50


Remove the unused client parameter from the launchUrl method signature.

The client parameter is passed to launchUrl but is never used within the method or passed to any called methods. The openMatrixToUrl method obtains the client context via Matrix.of(context) instead. Either remove the unused parameter or implement the intended functionality if this parameter was meant for a specific purpose.

🤖 Prompt for AI Agents
In lib/utils/url_launcher.dart around line 33, the launchUrl method declares an
unused Client? client parameter; remove this unused parameter from the method
signature and from any declarations/overrides, update all call sites to stop
passing a client argument, and run the analyzer to ensure no remaining
references; if the parameter was intended to be forwarded to
Matrix/openMatrixToUrl instead, instead forward it there or replace
Matrix.of(context) usage accordingly, but by default remove the parameter to
eliminate the dead argument.

if (url!.toLowerCase().startsWith(AppConfig.deepLinkPrefix) ||
url!.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
{'#', '@', '!', '+', '\$'}.contains(url![0]) ||
Expand All @@ -44,9 +44,14 @@ class UrlLauncher with GoToDraftChatMixin {
return;
}
if (uri.host == AppConstants.appLinkUniversalLinkDomain) {
final pathWithoutChatPrefix = uri.path.replaceFirst('/chat', '');
context.go(pathWithoutChatPrefix);
return;
// Handle links.twake.app URLs with /chat# pattern
if (uri.path.startsWith('/chat') && uri.fragment.isNotEmpty) {
final matrixToUrl = url!.replaceFirst(
'https://${AppConstants.appLinkUniversalLinkDomain}',
AppConfig.inviteLinkPrefix,
);
return openMatrixToUrl(matrixToUrl);
}
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

}
if (!{'https', 'http'}.contains(uri.scheme)) {
// just launch non-https / non-http uris directly
Expand Down Expand Up @@ -104,12 +109,12 @@ class UrlLauncher with GoToDraftChatMixin {
);
}

void openMatrixToUrl() async {
void openMatrixToUrl([String? customUrl]) async {
final matrix = Matrix.of(context);
final url = this.url!.replaceFirst(
AppConfig.deepLinkPrefix,
AppConfig.inviteLinkPrefix,
);
final url = (customUrl ?? this.url!).replaceFirst(
AppConfig.deepLinkPrefix,
AppConfig.inviteLinkPrefix,
);

// The identifier might be a matrix.to url and needs escaping. Or, it might have multiple
// identifiers (room id & event id), or it might also have a query part.
Expand Down Expand Up @@ -241,4 +246,28 @@ class UrlLauncher with GoToDraftChatMixin {
}
}
}

bool isCreatingChatFromUrl = false;

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');
}
Comment on lines +250 to +272
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Protect the guard flag with exception handling and verify roomId before navigation.

The isCreatingChatFromUrl flag will remain stuck at true if an exception occurs during the async operations (lines 258-269), permanently blocking future chat creation attempts. Additionally, if startDirectChat returns null, navigation to /rooms/null will occur.

Apply this diff to ensure the flag is always reset and verify roomId:

  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');
+   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);
+     if (roomId == null) {
+       Logs().e('_openChatWithUser: startDirectChat returned null for $matrixId');
+       return;
+     }
+     context.go('/rooms/$roomId');
+   } finally {
+     isCreatingChatFromUrl = false;
+   }
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool isCreatingChatFromUrl = false;
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');
}
bool isCreatingChatFromUrl = false;
Future<void> _openChatWithUser(
String matrixId, {
Client? client,
}) async {
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);
if (roomId == null) {
Logs().e('_openChatWithUser: startDirectChat returned null for $matrixId');
return;
}
context.go('/rooms/$roomId');
} finally {
isCreatingChatFromUrl = false;
}
}
🤖 Prompt for AI Agents
In lib/utils/url_launcher.dart around lines 250 to 272, the
isCreatingChatFromUrl guard can remain true if an exception is thrown during the
async operations and the code navigates to /rooms/null when startDirectChat
returns null; wrap the async work in try/catch/finally so isCreatingChatFromUrl
is always reset in a finally block, handle/log any exception in catch, and
before calling context.go verify roomId is non-null/non-empty (only navigate
when a valid roomId is returned); also return early on error instead of
navigating.

}
26 changes: 25 additions & 1 deletion lib/widgets/matrix.dart
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ class MatrixState extends State<Matrix>
StreamSubscription<html.Event>? onFocusSub;
StreamSubscription<html.Event>? onBlurSub;
final initSettingsCompleter = Completer<void>();
bool _hasInitializedSharingIntent = false;

String? _cachedPassword;
Timer? _cachedPasswordClearTimer;
Expand Down Expand Up @@ -352,7 +353,6 @@ class MatrixState extends State<Matrix>
initMatrix();
final emojiRawData = await EmojiData.builtIn();
emojiData = emojiRawData.filterByVersion(13.5);
initReceiveSharingIntent();
await tryToGetFederationConfigurations();
if (PlatformInfos.isWeb) {
initConfigWeb().then((_) => initSettings());
Expand All @@ -369,6 +369,24 @@ class MatrixState extends State<Matrix>
});
}

/// Initializes sharing intent once when user is logged in
void _initializeSharingIntentOnce() {
if (_hasInitializedSharingIntent) return;
_hasInitializedSharingIntent = true;
initReceiveSharingIntent();
}

/// Initializes sharing intent after first sync completes
void _initializeSharingIntentAfterFirstSync(Client client) {
if (_hasInitializedSharingIntent) return;
client.onSync.stream.first.then((_) {
Logs().d(
'MatrixState::_initializeSharingIntentAfterFirstSync: First sync completed, initializing sharing intent',
);
_initializeSharingIntentOnce();
});
}

void _registerSubs(String name) async {
final currentClient = getClientByName(name);
if (currentClient == null) {
Expand Down Expand Up @@ -512,6 +530,7 @@ class MatrixState extends State<Matrix>
await _getUserInfoWithActiveClient(newActiveClient);
await _getHomeserverInformation(newActiveClient);
matrixState.reSyncContacts();
_initializeSharingIntentAfterFirstSync(newActiveClient);
onClientLoginStateChanged.add(
ClientLoginStateEvent(
client: client,
Expand Down Expand Up @@ -639,6 +658,11 @@ class MatrixState extends State<Matrix>
}

createVoipPlugin();

// Initialize sharing intent if there are already logged-in clients
if (widget.clients.isNotEmpty && widget.clients.any((c) => c.isLogged())) {
_initializeSharingIntentOnce();
}
}

void createVoipPlugin() async {
Expand Down