Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Christian Kußowski
a8351cfda4
feat: Add identity service suppot 2025-12-07 16:48:37 +01:00
4 changed files with 386 additions and 78 deletions

View file

@ -1389,6 +1389,7 @@
"type": "String", "type": "String",
"placeholders": {} "placeholders": {}
}, },
"newMessage": "New message",
"newMessageInFluffyChat": "💬 New message in FluffyChat", "newMessageInFluffyChat": "💬 New message in FluffyChat",
"@newMessageInFluffyChat": { "@newMessageInFluffyChat": {
"type": "String", "type": "String",

View file

@ -49,13 +49,14 @@ class ChatListView extends StatelessWidget {
body: ChatListViewBody(controller), body: ChatListViewBody(controller),
floatingActionButton: floatingActionButton:
!controller.isSearchMode && controller.activeSpaceId == null !controller.isSearchMode && controller.activeSpaceId == null
? FloatingActionButton.extended( ? FloatingActionButton(
onPressed: () => context.go('/rooms/newprivatechat'), onPressed: () => context.go('/rooms/newprivatechat'),
icon: const Icon(Icons.add_outlined), tooltip: L10n.of(context).newMessage,
label: Text( foregroundColor: Theme.of(
L10n.of(context).chat, context,
overflow: TextOverflow.fade, ).colorScheme.onPrimary,
), backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.edit_square),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.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/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/config/themes.dart';
@ -26,12 +25,27 @@ class NewPrivateChatView extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final searchResponse = controller.searchResponse; 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
leading: const Center(child: BackButton()), leading: const Center(child: BackButton()),
title: Text(L10n.of(context).newChat), title: Text(L10n.of(context).newMessage),
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
actions: [ actions: [
TextButton( TextButton(
@ -131,17 +145,6 @@ class NewPrivateChatView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), 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( ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor:
@ -153,6 +156,22 @@ class NewPrivateChatView extends StatelessWidget {
title: Text(L10n.of(context).createGroup), title: Text(L10n.of(context).createGroup),
onTap: () => context.go('/rooms/newgroup'), 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) if (PlatformInfos.isMobile)
ListTile( ListTile(
leading: CircleAvatar( leading: CircleAvatar(
@ -167,50 +186,45 @@ class NewPrivateChatView extends StatelessWidget {
title: Text(L10n.of(context).scanQrCode), title: Text(L10n.of(context).scanQrCode),
onTap: controller.openScannerAction, onTap: controller.openScannerAction,
), ),
Center( ListView.builder(
child: Padding( shrinkWrap: true,
padding: const EdgeInsets.symmetric( itemCount: dmRoomContactList.length,
horizontal: 64.0, itemBuilder: (context, i) {
vertical: 24.0, if (i == 0 ||
), dmRoomContactList[i]
child: Material( .calcDisplayname()
shape: RoundedRectangleBorder( .substring(0, 1)
borderRadius: BorderRadius.circular( .toUpperCase() !=
AppConfig.borderRadius, dmRoomContactList[i - 1]
), .calcDisplayname()
side: BorderSide( .substring(0, 1)
width: 3, .toUpperCase()) {
color: theme.colorScheme.primary, return Column(
), mainAxisSize: .min,
), children: [
color: Colors.transparent, ListTile(
clipBehavior: Clip.hardEdge, leading: CircleAvatar(
child: InkWell( backgroundColor: Colors.transparent,
borderRadius: BorderRadius.circular( child: Text(
AppConfig.borderRadius, dmRoomContactList[i]
), .calcDisplayname()
onTap: () => .toUpperCase()
showQrCodeViewer(context, userId), .substring(0, 1),
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,
),
),
),
),
), ),
), ),
), ),
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( return ListView.builder(
itemCount: result.length, itemCount: result.length,
itemBuilder: (context, i) { itemBuilder: (context, i) => ProfileListTile(
final contact = result[i]; profile: result[i],
final displayname = onTap: controller.openUserModal,
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),
);
},
); );
}, },
), ),
@ -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),
);
}
}

View file

@ -0,0 +1,276 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
extension MatrixIdentityExtension on Client {
Future<MatrixIdentityApi> 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<Map<String, Object>> request(
RequestType type,
String path, {
Map<String, Object?>? body,
Map<String, Object?>? 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<Policies> getTerms() => request(
RequestType.GET,
'_matrix/identity/v2/terms',
).then(Policies.fromJson);
Future<void> acceptTerms(List<Uri> accepted) => request(
RequestType.POST,
'_matrix/identity/v2/terms',
body: {'user_accepts': accepted.map((uri) => uri.toString()).toList()},
);
Future<HashDetails> getHashDetails() => request(
RequestType.GET,
'/_matrix/identity/v2/hash_details',
).then(HashDetails.fromJson);
Future<LookUpResult> lookUp(
List<String> addresses,
String algorithm,
String pepper,
) => request(
.POST,
'/_matrix/identity/v2/lookup',
body: {'addresses': addresses, 'algorithm': algorithm, 'pepper': pepper},
).then(LookUpResult.fromJson);
Future<RequestTokenResult> 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<SuccessResult> submitMsIsdnToken() => request(
.POST,
'/_matrix/identity/v2/validate/msisdn/submitToken',
body: {},
).then(SuccessResult.fromJson);
Future<Bind3PidResult> 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<Validated3Pid> getValidated3Pid(String clientSecret, String sid) =>
request(
.GET,
'/_matrix/identity/v2/3pid/getValidated3pid',
queryParameters: {'client_secret': clientSecret, 'sid': sid},
).then(Validated3Pid.fromJson);
Future<void> 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<void> 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<String, Object?> json) => ThreePid(
address: json['address'] as String,
medium: json['medium'] as String,
);
Map<String, Object?> 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<String, Object?> 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<String, Map<String, String>> 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<String, Object> 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<String, Map<String, String>>.from(
json['signatures'] as Map<String, Object>,
),
);
}
class SuccessResult {
final bool success;
SuccessResult({required this.success});
factory SuccessResult.fromJson(Map<String, Object> json) =>
SuccessResult(success: json['success'] as bool);
}
class RequestTokenResult {
final String sid;
RequestTokenResult({required this.sid});
factory RequestTokenResult.fromJson(Map<String, Object> json) =>
RequestTokenResult(sid: json['sid'] as String);
}
class LookUpResult {
final Map<String, String> mappings;
LookUpResult(this.mappings);
factory LookUpResult.fromJson(Map<String, Object> json) =>
LookUpResult(json.tryGetMap<String, String>('mapping')!);
}
class HashDetails {
final List<String> algorithms;
final String lookupPepper;
HashDetails({required this.algorithms, required this.lookupPepper});
factory HashDetails.fromJson(Map<String, Object> json) => HashDetails(
algorithms: json.tryGetList<String>('algorithms')!,
lookupPepper: json['lookup_peppers'] as String,
);
}
class Policies {
final Map<String, Map<String, Terms>> policies;
Policies(this.policies);
factory Policies.fromJson(Map<String, Object> json) => Policies(
json.map(
(key, value) => MapEntry(
key,
(value as Map<String, Object>).map(
(key, value) =>
MapEntry(key, Terms.fromJson(value as Map<String, Object>)),
),
),
),
);
}
class Terms {
final String name;
final Uri url;
Terms({required this.name, required this.url});
factory Terms.fromJson(Map<String, Object> json) => Terms(
name: json['name'] as String,
url: Uri.parse(json['url'] as String),
);
}