diff --git a/lib/models/channel.dart b/lib/models/channel.dart index c6f47c4..6e2ed56 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:solian/models/account.dart'; +import 'package:solian/models/realm.dart'; class Channel { int id; @@ -13,6 +14,7 @@ class Channel { int type; Account account; int accountId; + Realm? realm; int? realmId; bool isEncrypted; @@ -30,6 +32,7 @@ class Channel { required this.account, required this.accountId, required this.isEncrypted, + this.realm, this.realmId, }); @@ -44,6 +47,7 @@ class Channel { type: json['type'], account: Account.fromJson(json['account']), accountId: json['account_id'], + realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null, realmId: json['realm_id'], isEncrypted: json['is_encrypted'], ); @@ -57,8 +61,9 @@ class Channel { 'name': name, 'description': description, 'type': type, - 'account': account, + 'account': account.toJson(), 'account_id': accountId, + 'realm': realm?.toJson(), 'realm_id': realmId, 'is_encrypted': isEncrypted, }; diff --git a/lib/models/message.dart b/lib/models/message.dart index 3bc8523..cf3fbe2 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -8,7 +8,7 @@ class Message { DateTime createdAt; DateTime updatedAt; DateTime? deletedAt; - String rawContent; + Map content; Map? metadata; String type; List? attachments; @@ -21,16 +21,12 @@ class Message { bool isSending = false; - Map get decodedContent { - return jsonDecode(utf8.fuse(base64).decode(rawContent)); - } - Message({ required this.id, required this.createdAt, required this.updatedAt, this.deletedAt, - required this.rawContent, + required this.content, required this.metadata, required this.type, this.attachments, @@ -47,14 +43,16 @@ class Message { createdAt: DateTime.parse(json['created_at']), updatedAt: DateTime.parse(json['updated_at']), deletedAt: json['deleted_at'], - rawContent: json['content'], + content: json['content'], metadata: json['metadata'], type: json['type'], attachments: json['attachments'], channel: Channel.fromJson(json['channel']), sender: Sender.fromJson(json['sender']), replyId: json['reply_id'], - replyTo: json['reply_to'] != null ? Message.fromJson(json['reply_to']) : null, + replyTo: json['reply_to'] != null + ? Message.fromJson(json['reply_to']) + : null, channelId: json['channel_id'], senderId: json['sender_id'], ); @@ -64,7 +62,7 @@ class Message { 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), 'deleted_at': deletedAt, - 'content': rawContent, + 'content': content, 'metadata': metadata, 'type': type, 'attachments': attachments, diff --git a/lib/models/realm.dart b/lib/models/realm.dart index 56ddc93..2d04fb8 100644 --- a/lib/models/realm.dart +++ b/lib/models/realm.dart @@ -10,7 +10,7 @@ class Realm { String description; bool isPublic; bool isCommunity; - int accountId; + int? accountId; Realm({ required this.id, @@ -22,14 +22,16 @@ class Realm { required this.description, required this.isPublic, required this.isCommunity, - required this.accountId, + this.accountId, }); factory Realm.fromJson(Map json) => Realm( id: json['id'], createdAt: DateTime.parse(json['created_at']), updatedAt: DateTime.parse(json['updated_at']), - deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null, + deletedAt: json['deleted_at'] != null + ? DateTime.parse(json['deleted_at']) + : null, alias: json['alias'], name: json['name'], description: json['description'], @@ -77,7 +79,9 @@ class RealmMember { id: json['id'], createdAt: DateTime.parse(json['created_at']), updatedAt: DateTime.parse(json['updated_at']), - deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null, + deletedAt: json['deleted_at'] != null + ? DateTime.parse(json['deleted_at']) + : null, realmId: json['realm_id'], accountId: json['account_id'], account: Account.fromJson(json['account']), diff --git a/lib/providers/content/channel.dart b/lib/providers/content/channel.dart index 4b523cc..06731a1 100644 --- a/lib/providers/content/channel.dart +++ b/lib/providers/content/channel.dart @@ -3,6 +3,21 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/services.dart'; class ChannelProvider extends GetxController { + Future getChannel(String alias, {String realm = 'global'}) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) throw Exception('unauthorized'); + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + + final resp = await client.get('/api/channels/$realm/$alias'); + if (resp.statusCode != 200) { + throw Exception(resp.bodyString); + } + + return resp; + } + Future listAvailableChannel({String realm = 'global'}) async { final AuthProvider auth = Get.find(); if (!await auth.isAuthorized) throw Exception('unauthorized'); diff --git a/lib/router.dart b/lib/router.dart index e6c1bb7..41e261d 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/personalize.dart'; +import 'package:solian/screens/channel/channel_chat.dart'; import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/contact.dart'; import 'package:solian/screens/posts/post_detail.dart'; @@ -87,6 +88,16 @@ abstract class AppRouter { ); }, ), + GoRoute( + path: '/chat/:alias', + name: 'channelChat', + builder: (context, state) { + return ChannelChatScreen( + alias: state.pathParameters['alias']!, + realm: state.uri.queryParameters['realm'] ?? 'global', + ); + }, + ), ], ); } diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart new file mode 100644 index 0000000..f1a6947 --- /dev/null +++ b/lib/screens/channel/channel_chat.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:get/get.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/models/pagination.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/content/channel.dart'; +import 'package:solian/services.dart'; +import 'package:solian/theme.dart'; +import 'package:solian/widgets/chat/chat_message.dart'; + +class ChannelChatScreen extends StatefulWidget { + final String alias; + final String realm; + + const ChannelChatScreen({ + super.key, + required this.alias, + this.realm = 'global', + }); + + @override + State createState() => _ChannelChatScreenState(); +} + +class _ChannelChatScreenState extends State { + bool _isBusy = false; + + Channel? _channel; + + final PagingController _pagingController = + PagingController(firstPageKey: 0); + + getChannel() async { + final ChannelProvider provider = Get.find(); + + setState(() => _isBusy = true); + + try { + final resp = await provider.getChannel(widget.alias, realm: widget.realm); + setState(() => _channel = Channel.fromJson(resp.body)); + } catch (e) { + context.showErrorDialog(e); + } + + setState(() => _isBusy = false); + } + + Future getMessages(int pageKey) async { + final AuthProvider auth = Get.find(); + if (!await auth.isAuthorized) return; + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['messaging']; + client.httpClient.addAuthenticator(auth.requestAuthenticator); + + final resp = await client.get( + '/api/channels/${widget.realm}/${widget.alias}/messages?take=10&offset=$pageKey'); + + if (resp.statusCode == 200) { + final PaginationResult result = PaginationResult.fromJson(resp.body); + final parsed = result.data?.map((e) => Message.fromJson(e)).toList(); + + if (parsed != null && parsed.length >= 10) { + _pagingController.appendPage(parsed, pageKey + parsed.length); + } else if (parsed != null) { + _pagingController.appendLastPage(parsed); + } + } else if (resp.statusCode == 403) { + _pagingController.appendLastPage([]); + } else { + _pagingController.error = resp.bodyString; + } + } + + bool checkMessageMergeable(Message? a, Message? b) { + if (a?.replyTo != null) return false; + if (a == null || b == null) return false; + if (a.senderId != b.senderId) return false; + return a.createdAt.difference(b.createdAt).inMinutes <= 3; + } + + Widget chatHistoryBuilder(context, item, index) { + bool isMerged = false, hasMerged = false; + if (index > 0) { + hasMerged = checkMessageMergeable( + _pagingController.itemList?[index - 1], + item, + ); + } + if (index + 1 < (_pagingController.itemList?.length ?? 0)) { + isMerged = checkMessageMergeable( + item, + _pagingController.itemList?[index + 1], + ); + } + return InkWell( + child: Container( + padding: EdgeInsets.only( + top: !isMerged ? 8 : 0, + bottom: !hasMerged ? 8 : 0, + left: 12, + right: 12, + ), + child: ChatMessage( + item: item, + isCompact: isMerged, + ), + ), + onLongPress: () {}, + ).animate(key: Key('m${item.id}'), autoPlay: true).slideY( + curve: Curves.fastEaseInToSlowEaseOut, + duration: 350.ms, + begin: 0.25, + end: 0, + ); + } + + @override + void initState() { + super.initState(); + + getChannel().then((_) { + _pagingController.addPageRequestListener(getMessages); + }); + } + + @override + Widget build(BuildContext context) { + if (_isBusy) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(_channel?.name ?? 'loading'.tr), + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () {}, + ), + SizedBox( + width: SolianTheme.isLargeScreen(context) ? 8 : 16, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: PagedListView( + reverse: true, + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + animateTransitions: true, + transitionDuration: 350.ms, + itemBuilder: chatHistoryBuilder, + noItemsFoundIndicatorBuilder: (_) => Container(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/contact.dart b/lib/screens/contact.dart index 978e380..3f5ff8f 100644 --- a/lib/screens/contact.dart +++ b/lib/screens/contact.dart @@ -78,8 +78,9 @@ class _ContactScreenState extends State { ); }, ), - if (!SolianTheme.isLargeScreen(context)) - const SizedBox(width: 16), + SizedBox( + width: SolianTheme.isLargeScreen(context) ? 8 : 16, + ), ], ), ), @@ -111,7 +112,16 @@ class _ContactScreenState extends State { const EdgeInsets.symmetric(horizontal: 24), title: Text(element.name), subtitle: Text(element.description), - onTap: () {}, + onTap: () { + AppRouter.instance.pushNamed( + 'channelChat', + pathParameters: {'alias': element.alias}, + queryParameters: { + if (element.realmId != null) + 'realm': element.realm!.alias, + }, + ); + }, ); }, ), diff --git a/lib/screens/social.dart b/lib/screens/social.dart index 3c02f75..591904a 100644 --- a/lib/screens/social.dart +++ b/lib/screens/social.dart @@ -87,8 +87,9 @@ class _SocialScreenState extends State { forceElevated: innerBoxIsScrolled, actions: [ const NotificationButton(), - if (!SolianTheme.isLargeScreen(context)) - const SizedBox(width: 16), + SizedBox( + width: SolianTheme.isLargeScreen(context) ? 8 : 16, + ), ], ), ), diff --git a/lib/translations.dart b/lib/translations.dart index 1378fb6..e18de87 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -14,6 +14,7 @@ class SolianMessages extends Translations { 'apply': 'Apply', 'cancel': 'Cancel', 'confirm': 'Confirm', + 'loading': 'Loading...', 'edit': 'Edit', 'delete': 'Delete', 'search': 'Search', @@ -100,6 +101,8 @@ class SolianMessages extends Translations { 'channelType': 'Channel type', 'channelTypeCommon': 'Regular', 'channelTypeDirect': 'DM', + 'messageDecoding': 'Decoding...', + 'messageDecodeFailed': 'Unable to decode: @message', }, 'zh_CN': { 'hide': '隐藏', @@ -108,6 +111,7 @@ class SolianMessages extends Translations { 'reset': '重置', 'cancel': '取消', 'confirm': '确认', + 'loading': '载入中…', 'edit': '编辑', 'delete': '删除', 'page': '页面', @@ -191,6 +195,8 @@ class SolianMessages extends Translations { 'channelType': '频道类型', 'channelTypeCommon': '普通频道', 'channelTypeDirect': '私信聊天', + 'messageDecoding': '解码信息中…', + 'messageDecodeFailed': '解码信息失败:@message', } }; } diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart new file mode 100644 index 0000000..06269c5 --- /dev/null +++ b/lib/widgets/chat/chat_message.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:timeago/timeago.dart' show format; + +class ChatMessage extends StatelessWidget { + final Message item; + final bool isCompact; + + const ChatMessage({super.key, required this.item, required this.isCompact}); + + Future decodeContent(Map content) async { + String? text; + if (item.type == 'm.text') { + switch (content['algorithm']) { + case 'plain': + text = content['value']; + default: + throw Exception('Unsupported algorithm'); + // TODO Impl AES algorithm + } + } + + return text; + } + + @override + Widget build(BuildContext context) { + final hasAttachment = item.attachments?.isNotEmpty ?? false; + + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AccountAvatar(content: item.sender.account.avatar), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + item.sender.account.nick, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + Text(format(item.createdAt, locale: 'en_short')) + ], + ).paddingSymmetric(horizontal: 12), + FutureBuilder( + future: decodeContent(item.content), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Opacity( + opacity: 0.8, + child: Row( + children: [ + const Icon(Icons.more_horiz), + Text('messageDecoding'.tr) + ], + ), + ) + .animate(onPlay: (c) => c.repeat()) + .fade(begin: 0, end: 1); + } else if (snapshot.hasError) { + return Opacity( + opacity: 0.9, + child: Row( + children: [ + const Icon(Icons.close), + Text( + 'messageDecodeFailed'.trParams( + {'message': snapshot.error.toString()}), + ) + ], + ), + ); + } + + return Markdown( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + data: snapshot.data ?? '', + padding: const EdgeInsets.all(0), + ).paddingOnly( + left: 12, + right: 12, + top: 2, + bottom: hasAttachment ? 4 : 0, + ); + }), + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/navigation/app_navigation_bottom_bar.dart b/lib/widgets/navigation/app_navigation_bottom_bar.dart index 2647741..8f6e814 100644 --- a/lib/widgets/navigation/app_navigation_bottom_bar.dart +++ b/lib/widgets/navigation/app_navigation_bottom_bar.dart @@ -15,18 +15,21 @@ class _AppNavigationBottomBarState extends State { @override Widget build(BuildContext context) { return BottomNavigationBar( - items: AppNavigation.destinations.map( - (e) => BottomNavigationBarItem( - icon: e.icon, - label: e.label, - ), - ).toList(), + items: AppNavigation.destinations + .map( + (e) => BottomNavigationBarItem( + icon: e.icon, + label: e.label, + ), + ) + .toList(), landscapeLayout: BottomNavigationBarLandscapeLayout.centered, currentIndex: _selectedIndex, showUnselectedLabels: false, onTap: (idx) { setState(() => _selectedIndex = idx); - AppRouter.instance.goNamed(AppNavigation.destinations[idx].page); + AppRouter.instance + .pushReplacementNamed(AppNavigation.destinations[idx].page); }, ); } diff --git a/lib/widgets/navigation/app_navigation_rail.dart b/lib/widgets/navigation/app_navigation_rail.dart index c573b31..ea7c96d 100644 --- a/lib/widgets/navigation/app_navigation_rail.dart +++ b/lib/widgets/navigation/app_navigation_rail.dart @@ -27,7 +27,8 @@ class _AppNavigationRailState extends State { selectedIndex: _selectedIndex, onDestinationSelected: (idx) { setState(() => _selectedIndex = idx); - AppRouter.instance.pushNamed(AppNavigation.destinations[idx].page); + AppRouter.instance + .pushReplacementNamed(AppNavigation.destinations[idx].page); }, ); } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index c329e5e..efb5b01 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,6 +1,8 @@ PODS: - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -12,6 +14,7 @@ PODS: DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) @@ -20,6 +23,8 @@ DEPENDENCIES: EXTERNAL SOURCES: file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -31,6 +36,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2 + flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 31b2947..1de7093 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ 1B3B8DF79807852D828EBE0C /* Pods-RunnerTests.release.xcconfig */, BA5247A2B03173FDFDFCFF93 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -724,13 +723,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = W7HPZ53V6B; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..7c2fa32 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -22,6 +22,11 @@ $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSExceptionDomains + + NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..b217e31 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,13 @@ com.apple.security.app-sandbox + com.apple.security.device.bluetooth + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + com.apple.security.network.server +