feat: Add identity service suppot
This commit is contained in:
parent
4f196b29bd
commit
a8351cfda4
4 changed files with 386 additions and 78 deletions
|
|
@ -1389,6 +1389,7 @@
|
|||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"newMessage": "New message",
|
||||
"newMessageInFluffyChat": "💬 New message in FluffyChat",
|
||||
"@newMessageInFluffyChat": {
|
||||
"type": "String",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
itemBuilder: (context, i) => ProfileListTile(
|
||||
profile: result[i],
|
||||
onTap: controller.openUserModal,
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
276
lib/utils/matrix_sdk_extensions/identity_api.dart
Normal file
276
lib/utils/matrix_sdk_extensions/identity_api.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue