From a8351cfda42f9012e873fc5a48afc702f4083468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 7 Dec 2025 16:48:37 +0100 Subject: [PATCH] feat: Add identity service suppot --- lib/l10n/intl_en.arb | 1 + lib/pages/chat_list/chat_list_view.dart | 13 +- .../new_private_chat_view.dart | 174 ++++++----- .../matrix_sdk_extensions/identity_api.dart | 276 ++++++++++++++++++ 4 files changed, 386 insertions(+), 78 deletions(-) create mode 100644 lib/utils/matrix_sdk_extensions/identity_api.dart diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f76710677..545469c53 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1389,6 +1389,7 @@ "type": "String", "placeholders": {} }, + "newMessage": "New message", "newMessageInFluffyChat": "💬 New message in FluffyChat", "@newMessageInFluffyChat": { "type": "String", diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 36a4f67b8..b2344a2cc 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -49,13 +49,14 @@ class ChatListView extends StatelessWidget { body: ChatListViewBody(controller), floatingActionButton: !controller.isSearchMode && controller.activeSpaceId == null - ? FloatingActionButton.extended( + ? FloatingActionButton( onPressed: () => context.go('/rooms/newprivatechat'), - icon: const Icon(Icons.add_outlined), - label: Text( - L10n.of(context).chat, - overflow: TextOverflow.fade, - ), + tooltip: L10n.of(context).newMessage, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + backgroundColor: Theme.of(context).colorScheme.primary, + child: const Icon(Icons.edit_square), ) : const SizedBox.shrink(), ), diff --git a/lib/pages/new_private_chat/new_private_chat_view.dart b/lib/pages/new_private_chat/new_private_chat_view.dart index d72ba4c28..95c2ff8de 100644 --- a/lib/pages/new_private_chat/new_private_chat_view.dart +++ b/lib/pages/new_private_chat/new_private_chat_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:pretty_qr_code/pretty_qr_code.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; @@ -26,12 +25,27 @@ class NewPrivateChatView extends StatelessWidget { final theme = Theme.of(context); final searchResponse = controller.searchResponse; - final userId = Matrix.of(context).client.userID!; + final client = Matrix.of(context).client; + final userId = client.userID!; + final dmRoomContactList = client.rooms + .where((room) => room.isDirectChat) + .map( + (room) => + room.unsafeGetUserFromMemoryOrFallback(room.directChatMatrixID!), + ) + .map( + (user) => Profile( + userId: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + ), + ) + .toList(); return Scaffold( appBar: AppBar( scrolledUnderElevation: 0, leading: const Center(child: BackButton()), - title: Text(L10n.of(context).newChat), + title: Text(L10n.of(context).newMessage), backgroundColor: theme.scaffoldBackgroundColor, actions: [ TextButton( @@ -131,17 +145,6 @@ class NewPrivateChatView extends StatelessWidget { ), ), const SizedBox(height: 8), - ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.secondaryContainer, - foregroundColor: - theme.colorScheme.onSecondaryContainer, - child: Icon(Icons.adaptive.share_outlined), - ), - title: Text(L10n.of(context).shareInviteLink), - onTap: controller.inviteAction, - ), ListTile( leading: CircleAvatar( backgroundColor: @@ -153,6 +156,22 @@ class NewPrivateChatView extends StatelessWidget { title: Text(L10n.of(context).createGroup), onTap: () => context.go('/rooms/newgroup'), ), + ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.secondaryContainer, + foregroundColor: + theme.colorScheme.onSecondaryContainer, + child: Icon(Icons.adaptive.share_outlined), + ), + title: Text(L10n.of(context).shareInviteLink), + trailing: IconButton( + icon: Icon(Icons.qr_code_outlined), + onPressed: () => + showQrCodeViewer(context, userId), + ), + onTap: controller.inviteAction, + ), if (PlatformInfos.isMobile) ListTile( leading: CircleAvatar( @@ -167,50 +186,45 @@ class NewPrivateChatView extends StatelessWidget { title: Text(L10n.of(context).scanQrCode), onTap: controller.openScannerAction, ), - Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 64.0, - vertical: 24.0, - ), - child: Material( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - side: BorderSide( - width: 3, - color: theme.colorScheme.primary, - ), - ), - color: Colors.transparent, - clipBehavior: Clip.hardEdge, - child: InkWell( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - onTap: () => - showQrCodeViewer(context, userId), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 200, - ), - child: PrettyQrView.data( - data: 'https://matrix.to/#/$userId', - decoration: PrettyQrDecoration( - shape: PrettyQrSmoothSymbol( - roundFactor: 1, - color: theme.colorScheme.primary, - ), + ListView.builder( + shrinkWrap: true, + itemCount: dmRoomContactList.length, + itemBuilder: (context, i) { + if (i == 0 || + dmRoomContactList[i] + .calcDisplayname() + .substring(0, 1) + .toUpperCase() != + dmRoomContactList[i - 1] + .calcDisplayname() + .substring(0, 1) + .toUpperCase()) { + return Column( + mainAxisSize: .min, + children: [ + ListTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + child: Text( + dmRoomContactList[i] + .calcDisplayname() + .toUpperCase() + .substring(0, 1), ), ), ), - ), - ), - ), - ), + ProfileListTile( + profile: dmRoomContactList[i], + onTap: controller.openUserModal, + ), + ], + ); + } + return ProfileListTile( + profile: dmRoomContactList[i], + onTap: controller.openUserModal, + ); + }, ), ], ) @@ -266,23 +280,10 @@ class NewPrivateChatView extends StatelessWidget { } return ListView.builder( itemCount: result.length, - itemBuilder: (context, i) { - final contact = result[i]; - final displayname = - contact.displayName ?? - contact.userId.localpart ?? - contact.userId; - return ListTile( - leading: Avatar( - name: displayname, - mxContent: contact.avatarUrl, - presenceUserId: contact.userId, - ), - title: Text(displayname), - subtitle: Text(contact.userId), - onTap: () => controller.openUserModal(contact), - ); - }, + itemBuilder: (context, i) => ProfileListTile( + profile: result[i], + onTap: controller.openUserModal, + ), ); }, ), @@ -294,3 +295,32 @@ class NewPrivateChatView extends StatelessWidget { ); } } + +extension on Profile { + String calcDisplayname() => displayName ?? userId.localpart ?? userId; +} + +class ProfileListTile extends StatelessWidget { + final Profile profile; + final void Function(Profile) onTap; + const ProfileListTile({ + super.key, + required this.profile, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final displayname = profile.calcDisplayname(); + return ListTile( + leading: Avatar( + name: displayname, + mxContent: profile.avatarUrl, + presenceUserId: profile.userId, + ), + title: Text(displayname), + subtitle: Text(profile.userId), + onTap: () => onTap(profile), + ); + } +} diff --git a/lib/utils/matrix_sdk_extensions/identity_api.dart b/lib/utils/matrix_sdk_extensions/identity_api.dart new file mode 100644 index 000000000..598e81af1 --- /dev/null +++ b/lib/utils/matrix_sdk_extensions/identity_api.dart @@ -0,0 +1,276 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:matrix/matrix.dart'; + +extension MatrixIdentityExtension on Client { + Future getIdentityApi() async { + final wellKnown = await getWellknown(); + final identityServerInformation = wellKnown.mIdentityServer; + if (identityServerInformation == null) { + throw Exception( + 'Well-Known does not include identity server information', + ); + } + + final openIdCredentials = await requestOpenIdToken(userID!, {}); + + final registrationResult = await request( + RequestType.POST, + '/_matrix/identity/v2/account/register', + data: openIdCredentials.toJson(), + ); + final accessToken = registrationResult['token'] as String; + + return MatrixIdentityApi( + accessToken: accessToken, + httpClient: httpClient, + homeserver: homeserver!, + ); + } + + static const String threePidAccountDataType = 'chat.fluffy.three_pid'; +} + +class MatrixIdentityApi { + final String accessToken; + final http.Client httpClient; + final Uri homeserver; + + MatrixIdentityApi({ + required this.accessToken, + required this.httpClient, + required this.homeserver, + }); + + Future> request( + RequestType type, + String path, { + Map? body, + Map? queryParameters, + }) async { + final request = http.Request( + type.name, + homeserver.resolveUri(Uri(path: path, queryParameters: queryParameters)), + ); + request.headers['authorization'] = 'Bearer $accessToken'; + request.headers['content-type'] = 'application/json'; + request.bodyBytes = utf8.encode(jsonEncode(body)); + final response = await httpClient.send(request); + final responseBody = await response.stream.toBytes(); + if (response.statusCode >= 300) throw response; + return jsonDecode(utf8.decode(responseBody)); + } + + Future getTerms() => request( + RequestType.GET, + '_matrix/identity/v2/terms', + ).then(Policies.fromJson); + + Future acceptTerms(List accepted) => request( + RequestType.POST, + '_matrix/identity/v2/terms', + body: {'user_accepts': accepted.map((uri) => uri.toString()).toList()}, + ); + + Future getHashDetails() => request( + RequestType.GET, + '/_matrix/identity/v2/hash_details', + ).then(HashDetails.fromJson); + + Future lookUp( + List addresses, + String algorithm, + String pepper, + ) => request( + .POST, + '/_matrix/identity/v2/lookup', + body: {'addresses': addresses, 'algorithm': algorithm, 'pepper': pepper}, + ).then(LookUpResult.fromJson); + + Future requestMsisdnToken( + String clientSecret, + String country, + String phoneNumber, + int sendAttempt, { + Uri? nextLink, + }) => request( + .POST, + '/_matrix/identity/v2/validate/msisdn/requestToken', + body: { + "client_secret": clientSecret, + "country": country, + if (nextLink != null) "next_link": nextLink.toString(), + "phone_number": phoneNumber, + "send_attempt": sendAttempt, + }, + ).then(RequestTokenResult.fromJson); + + Future submitMsIsdnToken() => request( + .POST, + '/_matrix/identity/v2/validate/msisdn/submitToken', + body: {}, + ).then(SuccessResult.fromJson); + + Future bind3Pid( + String clientSecret, + String mxid, + String sid, + ) => request( + .POST, + '/_matrix/identity/v2/3pid/bind', + body: {"client_secret": clientSecret, "mxid": mxid, "sid": sid}, + ).then(Bind3PidResult.fromJson); + + Future getValidated3Pid(String clientSecret, String sid) => + request( + .GET, + '/_matrix/identity/v2/3pid/getValidated3pid', + queryParameters: {'client_secret': clientSecret, 'sid': sid}, + ).then(Validated3Pid.fromJson); + + Future unbind3Pid( + String mxId, + ThreePid threepid, { + String? sid, + String? clientSecret, + }) => request( + .POST, + '/_matrix/identity/v2/3pid/unbind', + body: { + if (clientSecret != null) 'client_secret': clientSecret, + if (sid != null) 'sid': sid, + 'mxid': mxId, + 'threepid': threepid.toJson(), + }, + ); + + Future logout() => + request(.POST, '/_matrix/identity/v2/account/logout'); +} + +class ThreePid { + final String address, medium; + + ThreePid({required this.address, required this.medium}); + + factory ThreePid.fromJson(Map json) => ThreePid( + address: json['address'] as String, + medium: json['medium'] as String, + ); + Map toJson() => {'address': address, 'medium': medium}; +} + +class Validated3Pid { + final String address, medium; + final DateTime validatedAt; + + Validated3Pid({ + required this.address, + required this.medium, + required this.validatedAt, + }); + + factory Validated3Pid.fromJson(Map json) => Validated3Pid( + address: json['address'] as String, + medium: json['medium'] as String, + validatedAt: DateTime.fromMillisecondsSinceEpoch( + json['validated_at'] as int, + ), + ); +} + +class Bind3PidResult { + final String address, medium, mxid; + final int notAfter, notBefore; + final DateTime ts; + final Map> signatures; + + Bind3PidResult({ + required this.address, + required this.medium, + required this.mxid, + required this.notAfter, + required this.notBefore, + required this.ts, + required this.signatures, + }); + factory Bind3PidResult.fromJson(Map json) => Bind3PidResult( + address: json['address'] as String, + medium: json['medium'] as String, + mxid: json['mxid'] as String, + notAfter: json['not_after'] as int, + notBefore: json['not_before'] as int, + ts: DateTime.fromMillisecondsSinceEpoch(json['ts'] as int), + signatures: Map>.from( + json['signatures'] as Map, + ), + ); +} + +class SuccessResult { + final bool success; + + SuccessResult({required this.success}); + factory SuccessResult.fromJson(Map json) => + SuccessResult(success: json['success'] as bool); +} + +class RequestTokenResult { + final String sid; + + RequestTokenResult({required this.sid}); + factory RequestTokenResult.fromJson(Map json) => + RequestTokenResult(sid: json['sid'] as String); +} + +class LookUpResult { + final Map mappings; + + LookUpResult(this.mappings); + + factory LookUpResult.fromJson(Map json) => + LookUpResult(json.tryGetMap('mapping')!); +} + +class HashDetails { + final List algorithms; + final String lookupPepper; + + HashDetails({required this.algorithms, required this.lookupPepper}); + + factory HashDetails.fromJson(Map json) => HashDetails( + algorithms: json.tryGetList('algorithms')!, + lookupPepper: json['lookup_peppers'] as String, + ); +} + +class Policies { + final Map> policies; + + Policies(this.policies); + + factory Policies.fromJson(Map json) => Policies( + json.map( + (key, value) => MapEntry( + key, + (value as Map).map( + (key, value) => + MapEntry(key, Terms.fromJson(value as Map)), + ), + ), + ), + ); +} + +class Terms { + final String name; + final Uri url; + + Terms({required this.name, required this.url}); + + factory Terms.fromJson(Map json) => Terms( + name: json['name'] as String, + url: Uri.parse(json['url'] as String), + ); +}