diff --git a/lib/controllers/chat_history_controller.dart b/lib/controllers/chat_history_controller.dart new file mode 100644 index 0000000..b7f69c8 --- /dev/null +++ b/lib/controllers/chat_history_controller.dart @@ -0,0 +1,48 @@ +import 'package:get/get.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/providers/message/helper.dart'; +import 'package:solian/providers/message/history.dart'; + +class ChatHistoryController { + late final MessageHistoryDb database; + + final RxList currentHistory = RxList.empty(growable: true); + final RxInt totalHistoryCount = 0.obs; + + initialize() async { + database = await createHistoryDb(); + currentHistory.clear(); + } + + Future getMessages(Channel channel, String scope) async { + totalHistoryCount.value = await database.syncMessages(channel, scope: scope); + await syncHistory(channel); + } + + Future syncHistory(Channel channel) async { + currentHistory.replaceRange(0, currentHistory.length, + await database.localMessages.findAllByChannel(channel.id)); + } + + receiveMessage(Message remote) async { + final entry = await database.receiveMessage(remote); + totalHistoryCount.value++; + currentHistory.add(entry); + } + + void replaceMessage(Message remote) async { + final entry = await database.replaceMessage(remote); + currentHistory.replaceRange( + 0, + currentHistory.length, + currentHistory.map((x) => x.id == entry.id ? entry : x), + ); + } + + void burnMessage(int id) async { + await database.burnMessage(id); + totalHistoryCount.value--; + currentHistory.removeWhere((x) => x.id == id); + } +} diff --git a/lib/models/account.dart b/lib/models/account.dart index 3363a33..36f607b 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -81,24 +81,24 @@ class AccountBadge { }); factory AccountBadge.fromJson(Map json) => AccountBadge( - id: json["id"], - accountId: json["account_id"], - updatedAt: DateTime.parse(json["updated_at"]), - createdAt: DateTime.parse(json["created_at"]), - deletedAt: json["deleted_at"] != null - ? DateTime.parse(json["deleted_at"]) + id: json['id'], + accountId: json['account_id'], + updatedAt: DateTime.parse(json['updated_at']), + createdAt: DateTime.parse(json['created_at']), + deletedAt: json['deleted_at'] != null + ? DateTime.parse(json['deleted_at']) : null, - metadata: json["metadata"], - type: json["type"], + metadata: json['metadata'], + type: json['type'], ); Map toJson() => { - "id": id, - "account_id": accountId, - "created_at": createdAt.toIso8601String(), - "updated_at": updatedAt.toIso8601String(), - "deleted_at": deletedAt?.toIso8601String(), - "metadata": metadata, - "type": type, + 'id': id, + 'account_id': accountId, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'deleted_at': deletedAt?.toIso8601String(), + 'metadata': metadata, + 'type': type, }; } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index c4c4a90..be9807d 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -38,40 +38,40 @@ class Attachment { }); factory Attachment.fromJson(Map json) => Attachment( - id: json["id"], - createdAt: DateTime.parse(json["created_at"]), - updatedAt: DateTime.parse(json["updated_at"]), - deletedAt: json["deleted_at"], - uuid: json["uuid"], - size: json["size"], - name: json["name"], - alt: json["alt"], - usage: json["usage"], - mimetype: json["mimetype"], - hash: json["hash"], - destination: json["destination"], - metadata: json["metadata"], - isMature: json["is_mature"], - account: Account.fromJson(json["account"]), - accountId: json["account_id"], + id: json['id'], + createdAt: DateTime.parse(json['created_at']), + updatedAt: DateTime.parse(json['updated_at']), + deletedAt: json['deleted_at'], + uuid: json['uuid'], + size: json['size'], + name: json['name'], + alt: json['alt'], + usage: json['usage'], + mimetype: json['mimetype'], + hash: json['hash'], + destination: json['destination'], + metadata: json['metadata'], + isMature: json['is_mature'], + account: Account.fromJson(json['account']), + accountId: json['account_id'], ); Map toJson() => { - "id": id, - "created_at": createdAt.toIso8601String(), - "updated_at": updatedAt.toIso8601String(), - "deleted_at": deletedAt, - "uuid": uuid, - "size": size, - "name": name, - "alt": alt, - "usage": usage, - "mimetype": mimetype, - "hash": hash, - "destination": destination, - "metadata": metadata, - "is_mature": isMature, - "account": account.toJson(), - "account_id": accountId, + 'id': id, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'deleted_at': deletedAt, + 'uuid': uuid, + 'size': size, + 'name': name, + 'alt': alt, + 'usage': usage, + 'mimetype': mimetype, + 'hash': hash, + 'destination': destination, + 'metadata': metadata, + 'is_mature': isMature, + 'account': account.toJson(), + 'account_id': accountId, }; } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 9164fe0..18b736c 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -44,8 +44,8 @@ class Message { deletedAt: json['deleted_at'], content: json['content'], type: json['type'], - attachments: json["attachments"] != null - ? List.from(json["attachments"]) + attachments: json['attachments'] != null + ? List.from(json['attachments']) : null, channel: json['channel'] != null ? Channel.fromJson(json['channel']) : null, diff --git a/lib/models/post.dart b/lib/models/post.dart index 69e31df..e70cb59 100755 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -53,36 +53,36 @@ class Post { }); factory Post.fromJson(Map json) => Post( - id: json["id"], - createdAt: DateTime.parse(json["created_at"]), - updatedAt: DateTime.parse(json["updated_at"]), - deletedAt: json["deleted_at"] != null + 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, - alias: json["alias"], - content: json["content"], - tags: json["tags"], - categories: json["categories"], - reactions: json["reactions"], - replies: json["replies"], - attachments: json["attachments"] != null - ? List.from(json["attachments"]) + alias: json['alias'], + content: json['content'], + tags: json['tags'], + categories: json['categories'], + reactions: json['reactions'], + replies: json['replies'], + attachments: json['attachments'] != null + ? List.from(json['attachments']) : null, - replyId: json["reply_id"], - repostId: json["repost_id"], - realmId: json["realm_id"], + replyId: json['reply_id'], + repostId: json['repost_id'], + realmId: json['realm_id'], replyTo: - json["reply_to"] != null ? Post.fromJson(json["reply_to"]) : null, + json['reply_to'] != null ? Post.fromJson(json['reply_to']) : null, repostTo: - json["repost_to"] != null ? Post.fromJson(json["repost_to"]) : null, - realm: json["realm"] != null ? Realm.fromJson(json["realm"]) : null, - publishedAt: json["published_at"] != null ? DateTime.parse(json["published_at"]) : null, - authorId: json["author_id"], - author: Account.fromJson(json["author"]), - replyCount: json["reply_count"], - reactionCount: json["reaction_count"], - reactionList: json["reaction_list"] != null - ? json["reaction_list"] + json['repost_to'] != null ? Post.fromJson(json['repost_to']) : null, + realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null, + publishedAt: json['published_at'] != null ? DateTime.parse(json['published_at']) : null, + authorId: json['author_id'], + author: Account.fromJson(json['author']), + replyCount: json['reply_count'], + reactionCount: json['reaction_count'], + reactionList: json['reaction_list'] != null + ? json['reaction_list'] .map((key, value) => MapEntry( key, int.tryParse(value.toString()) ?? @@ -92,28 +92,28 @@ class Post { ); Map toJson() => { - "id": id, - "created_at": createdAt.toIso8601String(), - "updated_at": updatedAt.toIso8601String(), - "deleted_at": deletedAt, - "alias": alias, - "content": content, - "tags": tags, - "categories": categories, - "reactions": reactions, - "replies": replies, - "attachments": attachments, - "reply_id": replyId, - "repost_id": repostId, - "realm_id": realmId, - "reply_to": replyTo?.toJson(), - "repost_to": repostTo?.toJson(), - "realm": realm?.toJson(), - "published_at": publishedAt?.toIso8601String(), - "author_id": authorId, - "author": author.toJson(), - "reply_count": replyCount, - "reaction_count": reactionCount, - "reaction_list": reactionList, + 'id': id, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'deleted_at': deletedAt, + 'alias': alias, + 'content': content, + 'tags': tags, + 'categories': categories, + 'reactions': reactions, + 'replies': replies, + 'attachments': attachments, + 'reply_id': replyId, + 'repost_id': repostId, + 'realm_id': realmId, + 'reply_to': replyTo?.toJson(), + 'repost_to': repostTo?.toJson(), + 'realm': realm?.toJson(), + 'published_at': publishedAt?.toIso8601String(), + 'author_id': authorId, + 'author': author.toJson(), + 'reply_count': replyCount, + 'reaction_count': reactionCount, + 'reaction_list': reactionList, }; } diff --git a/lib/providers/account.dart b/lib/providers/account.dart index 322eb8d..65c438f 100644 --- a/lib/providers/account.dart +++ b/lib/providers/account.dart @@ -190,10 +190,10 @@ class AccountProvider extends GetxController { } if (PlatformInfo.isIOS || PlatformInfo.isMacOS) { - provider = "apple"; + provider = 'apple'; token = await FirebaseMessaging.instance.getAPNSToken(); } else { - provider = "firebase"; + provider = 'firebase'; token = await FirebaseMessaging.instance.getToken(); } diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index deec081..d58ba99 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -79,7 +79,7 @@ class AuthProvider extends GetConnect { if (credentials!.isExpired) { await refreshCredentials(); - log("Refreshed credentials at ${DateTime.now()}"); + log('Refreshed credentials at ${DateTime.now()}'); } } diff --git a/lib/providers/message/helper.dart b/lib/providers/message/helper.dart index d35c904..f397e13 100644 --- a/lib/providers/message/helper.dart +++ b/lib/providers/message/helper.dart @@ -13,19 +13,23 @@ Future createHistoryDb() async { extension MessageHistoryHelper on MessageHistoryDb { receiveMessage(Message remote) async { - await localMessages.insert(LocalMessage( + final entry = LocalMessage( remote.id, remote, remote.channelId, - )); + ); + await localMessages.insert(entry); + return entry; } replaceMessage(Message remote) async { - await localMessages.update(LocalMessage( + final entry = LocalMessage( remote.id, remote, remote.channelId, - )); + ); + await localMessages.update(entry); + return entry; } burnMessage(int id) async { @@ -38,18 +42,22 @@ extension MessageHistoryHelper on MessageHistoryDb { final data = await _getRemoteMessages( channel, scope, - remainBreath: 3, + remainBreath: 10, offset: offset, onBrake: (items) { return items.any((x) => x.id == lastOne?.id); }, ); - await localMessages.insertBulk( - data.map((x) => LocalMessage(x.id, x, x.channelId)).toList(), - ); + if (data != null) { + await localMessages.insertBulk( + data.$1.map((x) => LocalMessage(x.id, x, x.channelId)).toList(), + ); + } + + return data?.$2 ?? 0; } - Future> _getRemoteMessages( + Future<(List, int)?> _getRemoteMessages( Channel channel, String scope, { required int remainBreath, @@ -58,11 +66,11 @@ extension MessageHistoryHelper on MessageHistoryDb { offset = 0, }) async { if (remainBreath <= 0) { - return List.empty(); + return null; } final AuthProvider auth = Get.find(); - if (!await auth.isAuthorized) return List.empty(); + if (!await auth.isAuthorized) return null; final client = auth.configureClient('messaging'); @@ -78,18 +86,20 @@ extension MessageHistoryHelper on MessageHistoryDb { response.data?.map((e) => Message.fromJson(e)).toList() ?? List.empty(); if (onBrake != null && onBrake(result)) { - return result; + return (result, response.count); } - final expandResult = await _getRemoteMessages( - channel, - scope, - remainBreath: remainBreath - 1, - take: take, - offset: offset + result.length, - ); + final expandResult = (await _getRemoteMessages( + channel, + scope, + remainBreath: remainBreath - 1, + take: take, + offset: offset + result.length, + )) + ?.$1 ?? + List.empty(); - return [...result, ...expandResult]; + return ([...result, ...expandResult], response.count); } Future> listMessages(Channel channel) async { diff --git a/lib/screens/channel/call/call.dart b/lib/screens/channel/call/call.dart index 4aed7be..d9eb481 100644 --- a/lib/screens/channel/call/call.dart +++ b/lib/screens/channel/call/call.dart @@ -27,9 +27,9 @@ class _CallScreenState extends State { DateTime.now().difference(provider.current.value!.createdAt); String twoDigits(int n) => n.toString().padLeft(2, '0'); - String formattedTime = "${twoDigits(duration.inHours)}:" - "${twoDigits(duration.inMinutes.remainder(60))}:" - "${twoDigits(duration.inSeconds.remainder(60))}"; + String formattedTime = '${twoDigits(duration.inHours)}:' + '${twoDigits(duration.inMinutes.remainder(60))}:' + '${twoDigits(duration.inSeconds.remainder(60))}'; return formattedTime; } @@ -66,7 +66,7 @@ class _CallScreenState extends State { text: 'call'.tr, style: Theme.of(context).textTheme.titleLarge, ), - const TextSpan(text: "\n"), + const TextSpan(text: '\n'), TextSpan( text: currentDuration, style: Theme.of(context).textTheme.bodySmall, diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index c254cc2..840e0af 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; +import 'package:solian/controllers/chat_history_controller.dart'; import 'package:solian/exts.dart'; import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; @@ -13,8 +13,6 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/providers/chat.dart'; import 'package:solian/providers/content/call.dart'; import 'package:solian/providers/content/channel.dart'; -import 'package:solian/providers/message/helper.dart'; -import 'package:solian/providers/message/history.dart'; import 'package:solian/router.dart'; import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/theme.dart'; @@ -41,10 +39,7 @@ class ChannelChatScreen extends StatefulWidget { } class _ChannelChatScreenState extends State { - final _chatScrollController = ScrollController(); - bool _isBusy = false; - bool _isLoadingMore = false; int? _accountId; String? _overrideAlias; @@ -54,9 +49,7 @@ class _ChannelChatScreenState extends State { ChannelMember? _channelProfile; StreamSubscription? _subscription; - int _nextHistorySyncOffset = 0; - MessageHistoryDb? _db; - List _currentHistory = List.empty(); + late final ChatHistoryController _chatController; getProfile() async { final AuthProvider auth = Get.find(); @@ -111,22 +104,6 @@ class _ChannelChatScreenState extends State { setState(() => _isBusy = false); } - Future getMessages() async { - await _db!.syncMessages( - _channel!, - scope: widget.realm, - offset: _nextHistorySyncOffset, - ); - await syncHistory(); - } - - Future syncHistory() async { - final data = await _db!.localMessages.findAllByChannel(_channel!.id); - setState(() { - _currentHistory = data; - }); - } - void listenMessages() { final ChatProvider provider = Get.find(); _subscription = provider.stream.stream.listen((event) { @@ -134,19 +111,19 @@ class _ChannelChatScreenState extends State { case 'messages.new': final payload = Message.fromJson(event.payload!); if (payload.channelId == _channel?.id) { - _db?.receiveMessage(payload); + _chatController.receiveMessage(payload); } break; case 'messages.update': final payload = Message.fromJson(event.payload!); if (payload.channelId == _channel?.id) { - _db?.replaceMessage(payload); + _chatController.replaceMessage(payload); } break; case 'messages.burnt': final payload = Message.fromJson(event.payload!); if (payload.channelId == _channel?.id) { - _db?.burnMessage(payload.id); + _chatController.burnMessage(payload.id); } break; case 'calls.new': @@ -157,7 +134,6 @@ class _ChannelChatScreenState extends State { _ongoingCall = null; break; } - syncHistory(); }); } @@ -211,18 +187,18 @@ class _ChannelChatScreenState extends State { bool isMerged = false, hasMerged = false; if (index > 0) { hasMerged = checkMessageMergeable( - _currentHistory[index - 1].data, - _currentHistory[index].data, + _chatController.currentHistory[index - 1].data, + _chatController.currentHistory[index].data, ); } - if (index + 1 < _currentHistory.length) { + if (index + 1 < _chatController.currentHistory.length) { isMerged = checkMessageMergeable( - _currentHistory[index].data, - _currentHistory[index + 1].data, + _chatController.currentHistory[index].data, + _chatController.currentHistory[index + 1].data, ); } - final item = _currentHistory[index].data; + final item = _chatController.currentHistory[index].data; return InkWell( child: Container( @@ -253,28 +229,17 @@ class _ChannelChatScreenState extends State { @override void initState() { - _chatScrollController.addListener(() async { - if (_chatScrollController.position.pixels == - _chatScrollController.position.maxScrollExtent) { - setState(() => _isLoadingMore = true); - _nextHistorySyncOffset = _currentHistory.length; - await getMessages(); - setState(() => _isLoadingMore = false); - } + _chatController = ChatHistoryController(); + _chatController.initialize(); + + getChannel().then((_) { + _chatController.getMessages(_channel!, widget.realm); }); - createHistoryDb().then((db) async { - _db = db; + getProfile(); + getOngoingCall(); - await getChannel(); - await syncHistory(); - - getProfile(); - getOngoingCall(); - getMessages(); - - listenMessages(); - }); + listenMessages(); super.initState(); } @@ -352,52 +317,66 @@ class _ChannelChatScreenState extends State { children: [ Column( children: [ - if (_isLoadingMore) - const LinearProgressIndicator() - .paddingOnly(bottom: 4) - .animate() - .slideY(), Expanded( - child: ListView.builder( - controller: _chatScrollController, - itemCount: _currentHistory.length, - clipBehavior: Clip.none, + child: CustomScrollView( reverse: true, - itemBuilder: buildHistory, - ).paddingOnly(bottom: 56), + slivers: [ + Obx(() { + return SliverList.builder( + itemCount: _chatController.currentHistory.length, + itemBuilder: buildHistory, + ); + }), + Obx(() { + final amount = _chatController.totalHistoryCount - + _chatController.currentHistory.length; + if (amount > 0) { + return SliverToBoxAdapter( + child: ListTile( + tileColor: Theme.of(context) + .colorScheme + .surfaceContainerLow, + leading: const Icon(Icons.sync_disabled), + title: Text('messageUnsync'.tr), + subtitle: Text('messageUnsyncCaption'.trParams({ + 'count': amount.string, + })), + onTap: () {}, + ), + ); + } else { + return const SliverToBoxAdapter(child: SizedBox()); + } + }), + ], + ), ), - ], - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), - child: SafeArea( - child: ChatMessageInput( - edit: _messageToEditing, - reply: _messageToReplying, - realm: widget.realm, - placeholder: placeholder, - channel: _channel!, - onSent: (Message item) { - setState(() { - _db?.receiveMessage(item); - syncHistory(); - }); - }, - onReset: () { - setState(() { - _messageToReplying = null; - _messageToEditing = null; - }); - }, + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), + child: SafeArea( + child: ChatMessageInput( + edit: _messageToEditing, + reply: _messageToReplying, + realm: widget.realm, + placeholder: placeholder, + channel: _channel!, + onSent: (Message item) { + setState(() { + _chatController.receiveMessage(item); + }); + }, + onReset: () { + setState(() { + _messageToReplying = null; + _messageToEditing = null; + }); + }, + ), ), ), ), - ), + ], ), if (_ongoingCall != null) Positioned( diff --git a/lib/translations.dart b/lib/translations.dart index fb78b7d..e8ec8c4 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -164,6 +164,8 @@ class SolianMessages extends Translations { 'channelNotifyLevelMentioned': 'Only mentioned', 'channelNotifyLevelNone': 'Ignore all', 'channelNotifyLevelApplied': 'Your notification settings has been applied.', + 'messageUnsync': 'Messages Un-synced', + 'messageUnsyncCaption': '@count message(s) still in un-synced.', 'messageDecoding': 'Decoding...', 'messageDecodeFailed': 'Unable to decode: @message', 'messageInputPlaceholder': 'Message @channel', @@ -364,6 +366,8 @@ class SolianMessages extends Translations { 'channelNotifyLevelMentioned': '仅提及', 'channelNotifyLevelNone': '忽略一切', 'channelNotifyLevelApplied': '你的通知设置已经应用。', + 'messageUnsync': '消息未同步', + 'messageUnsyncCaption': '还有 @count 条消息未同步', 'messageDecoding': '解码信息中…', 'messageDecodeFailed': '解码信息失败:@message', 'messageInputPlaceholder': '在 @channel 发信息', diff --git a/lib/widgets/account/friend_list.dart b/lib/widgets/account/friend_list.dart index af2f6e1..7d8fce7 100644 --- a/lib/widgets/account/friend_list.dart +++ b/lib/widgets/account/friend_list.dart @@ -67,7 +67,7 @@ class SliverFriendList extends StatelessWidget { Widget build(BuildContext context) { return SliverList.builder( itemCount: items.length, - itemBuilder: (_, __) => buildItem(_, __), + itemBuilder: (context, idx) => buildItem(context, idx), ); } } diff --git a/lib/widgets/chat/call/chat_call_action.dart b/lib/widgets/chat/call/chat_call_action.dart index dc6acfb..704651b 100644 --- a/lib/widgets/chat/call/chat_call_action.dart +++ b/lib/widgets/chat/call/chat_call_action.dart @@ -65,7 +65,7 @@ class _ChatCallButtonState extends State { ? widget.realm?.alias : 'global'; final resp = await client - .delete('/api/channels/${scope}/${widget.channel.alias}/calls/ongoing'); + .delete('/api/channels/$scope/${widget.channel.alias}/calls/ongoing'); if (resp.statusCode == 200) { if (widget.onEnded != null) widget.onEnded!(); } else { diff --git a/lib/widgets/posts/post_item.dart b/lib/widgets/posts/post_item.dart index bf9b8e6..7918e4d 100644 --- a/lib/widgets/posts/post_item.dart +++ b/lib/widgets/posts/post_item.dart @@ -98,7 +98,7 @@ class _PostItemState extends State { if (labels.isNotEmpty) { return Text( - labels.join(" · "), + labels.join(' · '), textAlign: TextAlign.left, style: TextStyle( fontSize: 12,