♻️ 使用 SQLITE 来存储本地消息记录 #1

Merged
LittleSheep merged 6 commits from features/local-message-history into master 2024-06-23 11:13:42 +00:00
14 changed files with 262 additions and 221 deletions
Showing only changes of commit aa8eec1a5a - Show all commits

View File

@ -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<LocalMessage> currentHistory = RxList.empty(growable: true);
final RxInt totalHistoryCount = 0.obs;
initialize() async {
database = await createHistoryDb();
currentHistory.clear();
}
Future<void> getMessages(Channel channel, String scope) async {
totalHistoryCount.value = await database.syncMessages(channel, scope: scope);
await syncHistory(channel);
}
Future<void> 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);
}
}

View File

@ -81,24 +81,24 @@ class AccountBadge {
});
factory AccountBadge.fromJson(Map<String, dynamic> 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<String, dynamic> 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,
};
}

View File

@ -38,40 +38,40 @@ class Attachment {
});
factory Attachment.fromJson(Map<String, dynamic> 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<String, dynamic> 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,
};
}

View File

@ -44,8 +44,8 @@ class Message {
deletedAt: json['deleted_at'],
content: json['content'],
type: json['type'],
attachments: json["attachments"] != null
? List<int>.from(json["attachments"])
attachments: json['attachments'] != null
? List<int>.from(json['attachments'])
: null,
channel:
json['channel'] != null ? Channel.fromJson(json['channel']) : null,

View File

@ -53,36 +53,36 @@ class Post {
});
factory Post.fromJson(Map<String, dynamic> 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<int>.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<int>.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<String, dynamic> 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,
};
}

View File

@ -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();
}

View File

@ -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()}');
}
}

View File

@ -13,19 +13,23 @@ Future<MessageHistoryDb> 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<List<Message>> _getRemoteMessages(
Future<(List<Message>, 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<List<LocalMessage>> listMessages(Channel channel) async {

View File

@ -27,9 +27,9 @@ class _CallScreenState extends State<CallScreen> {
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<CallScreen> {
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,

View File

@ -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<ChannelChatScreen> {
final _chatScrollController = ScrollController();
bool _isBusy = false;
bool _isLoadingMore = false;
int? _accountId;
String? _overrideAlias;
@ -54,9 +49,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
ChannelMember? _channelProfile;
StreamSubscription<NetworkPackage>? _subscription;
int _nextHistorySyncOffset = 0;
MessageHistoryDb? _db;
List<LocalMessage> _currentHistory = List.empty();
late final ChatHistoryController _chatController;
getProfile() async {
final AuthProvider auth = Get.find();
@ -111,22 +104,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false);
}
Future<void> getMessages() async {
await _db!.syncMessages(
_channel!,
scope: widget.realm,
offset: _nextHistorySyncOffset,
);
await syncHistory();
}
Future<void> 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<ChannelChatScreen> {
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<ChannelChatScreen> {
_ongoingCall = null;
break;
}
syncHistory();
});
}
@ -211,18 +187,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<ChannelChatScreen> {
@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<ChannelChatScreen> {
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(

View File

@ -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 发信息',

View File

@ -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),
);
}
}

View File

@ -65,7 +65,7 @@ class _ChatCallButtonState extends State<ChatCallButton> {
? 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 {

View File

@ -98,7 +98,7 @@ class _PostItemState extends State<PostItem> {
if (labels.isNotEmpty) {
return Text(
labels.join(" · "),
labels.join(' · '),
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 12,