🔀 Merge pull request '⬆️ 升级支持服务器的 Event Based Messages' (#2) from experimental/event-based-messages into master

Reviewed-on: #2
This commit is contained in:
LittleSheep 2024-06-27 20:37:32 +00:00
commit cb99fa3444
18 changed files with 853 additions and 573 deletions

View File

@ -0,0 +1,129 @@
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/message/helper.dart';
import 'package:solian/providers/message/events.dart';
class ChatEventController {
late final MessageHistoryDb database;
final RxList<LocalEvent> currentEvents = RxList.empty(growable: true);
final RxInt totalEvents = 0.obs;
final RxBool isLoading = false.obs;
Channel? channel;
String? scope;
initialize() async {
database = await createHistoryDb();
currentEvents.clear();
}
Future<LocalEvent?> getEvent(int id) async {
if(channel == null || scope == null) return null;
return await database.getEvent(id, channel!, scope: scope!);
}
Future<void> getEvents(Channel channel, String scope) async {
this.channel = channel;
this.scope = scope;
syncLocal(channel);
isLoading.value = true;
final result = await database.syncEvents(
channel,
scope: scope,
);
totalEvents.value = result?.$2 ?? 0;
if (!await syncLocal(channel) && result != null) {
currentEvents.addAll(result.$1.map(
(x) => LocalEvent(
x.id,
x,
x.channelId,
x.createdAt,
),
));
}
isLoading.value = false;
}
Future<void> loadEvents(Channel channel, String scope) async {
isLoading.value = true;
final result = await database.syncEvents(
channel,
depth: 3,
scope: scope,
offset: currentEvents.length,
);
totalEvents.value = result?.$2 ?? 0;
if (!await syncLocal(channel) && result != null) {
currentEvents.addAll(result.$1.map(
(x) => LocalEvent(
x.id,
x,
x.channelId,
x.createdAt,
),
));
}
isLoading.value = false;
}
Future<bool> syncLocal(Channel channel) async {
if (PlatformInfo.isWeb) return false;
currentEvents.replaceRange(
0,
currentEvents.length,
await database.localEvents.findAllByChannel(channel.id),
);
return true;
}
receiveEvent(Event remote) async {
final entry = await database.receiveEvent(remote);
if (remote.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == remote.uuid);
if (idx != -1) {
currentEvents[idx] = entry;
} else {
currentEvents.insert(0, entry);
}
switch (remote.type) {
case 'messages.edit':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
final idx =
currentEvents.indexWhere((x) => x.data.id == body.relatedEvent);
if (idx != -1) {
currentEvents[idx].data.body = remote.body;
currentEvents[idx].data.updatedAt = remote.updatedAt;
}
}
case 'messages.delete':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
currentEvents.removeWhere((x) => x.id == body.relatedEvent);
}
}
}
addPendingEvent(Event info) async {
currentEvents.insert(
0,
LocalEvent(
info.id,
info,
info.channelId,
DateTime.now(),
),
);
}
}

View File

@ -1,83 +0,0 @@
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;
final RxBool isLoading = false.obs;
initialize() async {
database = await createHistoryDb();
currentHistory.clear();
}
Future<void> getMessages(Channel channel, String scope) async {
syncHistory(channel);
isLoading.value = true;
totalHistoryCount.value = await database.syncMessages(
channel,
scope: scope,
);
await syncHistory(channel);
isLoading.value = false;
}
Future<void> getMoreMessages(Channel channel, String scope) async {
isLoading.value = true;
totalHistoryCount.value = await database.syncMessages(
channel,
breath: 3,
scope: scope,
offset: currentHistory.length,
);
await syncHistory(channel);
isLoading.value = false;
}
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);
final idx = currentHistory.indexWhere((x) => x.data.uuid == remote.uuid);
if (idx != -1) {
currentHistory[idx] = entry;
} else {
currentHistory.insert(0, entry);
}
}
addTemporaryMessage(Message info) async {
currentHistory.insert(
0,
LocalMessage(
info.id,
info,
info.channelId,
),
);
}
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);
currentHistory.removeWhere((x) => x.id == id);
}
}

View File

@ -1,59 +1,46 @@
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
class Message { class Event {
int id; int id;
String uuid; String uuid;
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
DateTime? deletedAt; DateTime? deletedAt;
Map<String, dynamic> content; Map<String, dynamic> body;
String type; String type;
List<int>? attachments;
Channel? channel; Channel? channel;
Sender sender; Sender sender;
int? replyId;
Message? replyTo;
int channelId; int channelId;
int senderId; int senderId;
bool isSending = false; bool isPending = false;
Message({ Event({
required this.id, required this.id,
required this.uuid, required this.uuid,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.deletedAt, this.deletedAt,
required this.content, required this.body,
required this.type, required this.type,
this.attachments,
this.channel, this.channel,
required this.sender, required this.sender,
required this.replyId,
required this.replyTo,
required this.channelId, required this.channelId,
required this.senderId, required this.senderId,
}); });
factory Message.fromJson(Map<String, dynamic> json) => Message( factory Event.fromJson(Map<String, dynamic> json) => Event(
id: json['id'], id: json['id'],
uuid: json['uuid'], uuid: json['uuid'],
createdAt: DateTime.parse(json['created_at']), createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']), updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'], deletedAt: json['deleted_at'],
content: json['content'], body: json['body'],
type: json['type'], type: json['type'],
attachments: json['attachments'] != null
? List<int>.from(json['attachments'])
: null,
channel: channel:
json['channel'] != null ? Channel.fromJson(json['channel']) : null, json['channel'] != null ? Channel.fromJson(json['channel']) : null,
sender: Sender.fromJson(json['sender']), sender: Sender.fromJson(json['sender']),
replyId: json['reply_id'],
replyTo: json['reply_to'] != null
? Message.fromJson(json['reply_to'])
: null,
channelId: json['channel_id'], channelId: json['channel_id'],
senderId: json['sender_id'], senderId: json['sender_id'],
); );
@ -64,18 +51,56 @@ class Message {
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt, 'deleted_at': deletedAt,
'content': content, 'body': body,
'type': type, 'type': type,
'attachments': attachments,
'channel': channel?.toJson(), 'channel': channel?.toJson(),
'sender': sender.toJson(), 'sender': sender.toJson(),
'reply_id': replyId,
'reply_to': replyTo?.toJson(),
'channel_id': channelId, 'channel_id': channelId,
'sender_id': senderId, 'sender_id': senderId,
}; };
} }
class EventMessageBody {
String text;
String algorithm;
List<int>? attachments;
int? quoteEvent;
int? relatedEvent;
List<int>? relatedUsers;
EventMessageBody({
required this.text,
required this.algorithm,
required this.attachments,
required this.quoteEvent,
required this.relatedEvent,
required this.relatedUsers,
});
factory EventMessageBody.fromJson(Map<String, dynamic> json) =>
EventMessageBody(
text: json['text'],
algorithm: json['algorithm'],
attachments: json['attachments'] != null
? List<int>.from(json['attachments'].map((x) => x))
: null,
quoteEvent: json['quote_event'],
relatedEvent: json['related_event'],
relatedUsers: json['related_users'] != null
? List<int>.from(json['related_users'].map((x) => x))
: null,
);
Map<String, dynamic> toJson() => {
'text': text,
'algorithm': algorithm,
'attachments': attachments?.cast<dynamic>(),
'quote_event': quoteEvent,
'related_event': relatedEvent,
'related_users': relatedUsers?.cast<dynamic>(),
};
}
class Sender { class Sender {
int id; int id;
DateTime createdAt; DateTime createdAt;

View File

@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart'; import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/controllers/chat_history_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/providers/account.dart'; import 'package:solian/providers/account.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -136,9 +136,9 @@ class AuthProvider extends GetConnect {
Get.find<AccountProvider>().notifications.clear(); Get.find<AccountProvider>().notifications.clear();
Get.find<AccountProvider>().notificationUnread.value = 0; Get.find<AccountProvider>().notificationUnread.value = 0;
final chatHistory = ChatHistoryController(); final chatHistory = ChatEventController();
chatHistory.initialize().then((_) async { chatHistory.initialize().then((_) async {
await chatHistory.database.localMessages.wipeLocalMessages(); await chatHistory.database.localEvents.wipeLocalEvents();
}); });
storage.deleteAll(); storage.deleteAll();

View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'dart:convert';
import 'package:floor/floor.dart';
import 'package:solian/models/event.dart';
import 'package:sqflite/sqflite.dart' as sqflite;
part 'events.g.dart';
@entity
class LocalEvent {
@primaryKey
final int id;
final Event data;
final int channelId;
final DateTime createdAt;
LocalEvent(this.id, this.data, this.channelId, this.createdAt);
}
class DateTimeConverter extends TypeConverter<DateTime, int> {
@override
DateTime decode(int databaseValue) {
return DateTime.fromMillisecondsSinceEpoch(databaseValue);
}
@override
int encode(DateTime value) {
return value.millisecondsSinceEpoch;
}
}
class RemoteEventConverter extends TypeConverter<Event, String> {
@override
Event decode(String databaseValue) {
return Event.fromJson(jsonDecode(databaseValue));
}
@override
String encode(Event value) {
return jsonEncode(value.toJson());
}
}
@dao
abstract class LocalEventDao {
@Query('SELECT COUNT(id) FROM LocalEvent WHERE channelId = :channelId')
Future<int?> countByChannel(int channelId);
@Query('SELECT * FROM LocalEvent WHERE id = :id')
Future<LocalEvent?> findById(int id);
@Query('SELECT * FROM LocalEvent WHERE channelId = :channelId ORDER BY createdAt DESC')
Future<List<LocalEvent>> findAllByChannel(int channelId);
@Query('SELECT * FROM LocalEvent WHERE channelId = :channelId ORDER BY createdAt DESC LIMIT 1')
Future<LocalEvent?> findLastByChannel(int channelId);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insert(LocalEvent m);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insertBulk(List<LocalEvent> m);
@Update(onConflict: OnConflictStrategy.replace)
Future<void> update(LocalEvent m);
@Query('DELETE FROM LocalEvent WHERE id = :id')
Future<void> delete(int id);
@Query('DELETE FROM LocalEvent WHERE channelId = :channelId')
Future<List<LocalEvent>> deleteByChannel(int channelId);
@Query('DELETE FROM LocalEvent')
Future<void> wipeLocalEvents();
}
@TypeConverters([DateTimeConverter, RemoteEventConverter])
@Database(version: 2, entities: [LocalEvent])
abstract class MessageHistoryDb extends FloorDatabase {
LocalEventDao get localEvents;
}

View File

@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'history.dart'; part of 'events.dart';
// ************************************************************************** // **************************************************************************
// FloorGenerator // FloorGenerator
@ -72,7 +72,7 @@ class _$MessageHistoryDb extends MessageHistoryDb {
changeListener = listener ?? StreamController<String>.broadcast(); changeListener = listener ?? StreamController<String>.broadcast();
} }
LocalMessageDao? _localMessagesInstance; LocalEventDao? _localEventsInstance;
Future<sqflite.Database> open( Future<sqflite.Database> open(
String path, String path,
@ -80,7 +80,7 @@ class _$MessageHistoryDb extends MessageHistoryDb {
Callback? callback, Callback? callback,
]) async { ]) async {
final databaseOptions = sqflite.OpenDatabaseOptions( final databaseOptions = sqflite.OpenDatabaseOptions(
version: 1, version: 2,
onConfigure: (database) async { onConfigure: (database) async {
await database.execute('PRAGMA foreign_keys = ON'); await database.execute('PRAGMA foreign_keys = ON');
await callback?.onConfigure?.call(database); await callback?.onConfigure?.call(database);
@ -96,7 +96,7 @@ class _$MessageHistoryDb extends MessageHistoryDb {
}, },
onCreate: (database, version) async { onCreate: (database, version) async {
await database.execute( await database.execute(
'CREATE TABLE IF NOT EXISTS `LocalMessage` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `channelId` INTEGER NOT NULL, PRIMARY KEY (`id`))'); 'CREATE TABLE IF NOT EXISTS `LocalEvent` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `channelId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY (`id`))');
await callback?.onCreate?.call(database, version); await callback?.onCreate?.call(database, version);
}, },
@ -105,33 +105,34 @@ class _$MessageHistoryDb extends MessageHistoryDb {
} }
@override @override
LocalMessageDao get localMessages { LocalEventDao get localEvents {
return _localMessagesInstance ??= return _localEventsInstance ??= _$LocalEventDao(database, changeListener);
_$LocalMessageDao(database, changeListener);
} }
} }
class _$LocalMessageDao extends LocalMessageDao { class _$LocalEventDao extends LocalEventDao {
_$LocalMessageDao( _$LocalEventDao(
this.database, this.database,
this.changeListener, this.changeListener,
) : _queryAdapter = QueryAdapter(database), ) : _queryAdapter = QueryAdapter(database),
_localMessageInsertionAdapter = InsertionAdapter( _localEventInsertionAdapter = InsertionAdapter(
database, database,
'LocalMessage', 'LocalEvent',
(LocalMessage item) => <String, Object?>{ (LocalEvent item) => <String, Object?>{
'id': item.id, 'id': item.id,
'data': _remoteMessageConverter.encode(item.data), 'data': _remoteEventConverter.encode(item.data),
'channelId': item.channelId 'channelId': item.channelId,
'createdAt': _dateTimeConverter.encode(item.createdAt)
}), }),
_localMessageUpdateAdapter = UpdateAdapter( _localEventUpdateAdapter = UpdateAdapter(
database, database,
'LocalMessage', 'LocalEvent',
['id'], ['id'],
(LocalMessage item) => <String, Object?>{ (LocalEvent item) => <String, Object?>{
'id': item.id, 'id': item.id,
'data': _remoteMessageConverter.encode(item.data), 'data': _remoteEventConverter.encode(item.data),
'channelId': item.channelId 'channelId': item.channelId,
'createdAt': _dateTimeConverter.encode(item.createdAt)
}); });
final sqflite.DatabaseExecutor database; final sqflite.DatabaseExecutor database;
@ -140,75 +141,88 @@ class _$LocalMessageDao extends LocalMessageDao {
final QueryAdapter _queryAdapter; final QueryAdapter _queryAdapter;
final InsertionAdapter<LocalMessage> _localMessageInsertionAdapter; final InsertionAdapter<LocalEvent> _localEventInsertionAdapter;
final UpdateAdapter<LocalMessage> _localMessageUpdateAdapter; final UpdateAdapter<LocalEvent> _localEventUpdateAdapter;
@override @override
Future<int?> countByChannel(int channelId) async { Future<int?> countByChannel(int channelId) async {
return _queryAdapter.query( return _queryAdapter.query(
'SELECT COUNT(id) FROM LocalMessage WHERE channelId = ?1', 'SELECT COUNT(id) FROM LocalEvent WHERE channelId = ?1',
mapper: (Map<String, Object?> row) => row.values.first as int, mapper: (Map<String, Object?> row) => row.values.first as int,
arguments: [channelId]); arguments: [channelId]);
} }
@override @override
Future<List<LocalMessage>> findAllByChannel(int channelId) async { Future<LocalEvent?> findById(int id) async {
return _queryAdapter.queryList( return _queryAdapter.query('SELECT * FROM LocalEvent WHERE id = ?1',
'SELECT * FROM LocalMessage WHERE channelId = ?1 ORDER BY id DESC', mapper: (Map<String, Object?> row) => LocalEvent(
mapper: (Map<String, Object?> row) => LocalMessage(
row['id'] as int, row['id'] as int,
_remoteMessageConverter.decode(row['data'] as String), _remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int), row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [id]);
}
@override
Future<List<LocalEvent>> findAllByChannel(int channelId) async {
return _queryAdapter.queryList(
'SELECT * FROM LocalEvent WHERE channelId = ?1 ORDER BY createdAt DESC',
mapper: (Map<String, Object?> row) => LocalEvent(
row['id'] as int,
_remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]); arguments: [channelId]);
} }
@override @override
Future<LocalMessage?> findLastByChannel(int channelId) async { Future<LocalEvent?> findLastByChannel(int channelId) async {
return _queryAdapter.query( return _queryAdapter.query(
'SELECT * FROM LocalMessage WHERE channelId = ?1 ORDER BY id DESC LIMIT 1', 'SELECT * FROM LocalEvent WHERE channelId = ?1 ORDER BY createdAt DESC LIMIT 1',
mapper: (Map<String, Object?> row) => LocalMessage(row['id'] as int, _remoteMessageConverter.decode(row['data'] as String), row['channelId'] as int), mapper: (Map<String, Object?> row) => LocalEvent(row['id'] as int, _remoteEventConverter.decode(row['data'] as String), row['channelId'] as int, _dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]); arguments: [channelId]);
} }
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _queryAdapter.queryNoReturn('DELETE FROM LocalMessage WHERE id = ?1', await _queryAdapter
arguments: [id]); .queryNoReturn('DELETE FROM LocalEvent WHERE id = ?1', arguments: [id]);
} }
@override @override
Future<List<LocalMessage>> deleteByChannel(int channelId) async { Future<List<LocalEvent>> deleteByChannel(int channelId) async {
return _queryAdapter.queryList( return _queryAdapter.queryList(
'DELETE FROM LocalMessage WHERE channelId = ?1', 'DELETE FROM LocalEvent WHERE channelId = ?1',
mapper: (Map<String, Object?> row) => LocalMessage( mapper: (Map<String, Object?> row) => LocalEvent(
row['id'] as int, row['id'] as int,
_remoteMessageConverter.decode(row['data'] as String), _remoteEventConverter.decode(row['data'] as String),
row['channelId'] as int), row['channelId'] as int,
_dateTimeConverter.decode(row['createdAt'] as int)),
arguments: [channelId]); arguments: [channelId]);
} }
@override @override
Future<void> wipeLocalMessages() async { Future<void> wipeLocalEvents() async {
await _queryAdapter.queryNoReturn('DELETE FROM LocalMessage'); await _queryAdapter.queryNoReturn('DELETE FROM LocalEvent');
} }
@override @override
Future<void> insert(LocalMessage m) async { Future<void> insert(LocalEvent m) async {
await _localMessageInsertionAdapter.insert(m, OnConflictStrategy.replace); await _localEventInsertionAdapter.insert(m, OnConflictStrategy.replace);
} }
@override @override
Future<void> insertBulk(List<LocalMessage> m) async { Future<void> insertBulk(List<LocalEvent> m) async {
await _localMessageInsertionAdapter.insertList( await _localEventInsertionAdapter.insertList(m, OnConflictStrategy.replace);
m, OnConflictStrategy.replace);
} }
@override @override
Future<void> update(LocalMessage person) async { Future<void> update(LocalEvent m) async {
await _localMessageUpdateAdapter.update(person, OnConflictStrategy.replace); await _localEventUpdateAdapter.update(m, OnConflictStrategy.replace);
} }
} }
// ignore_for_file: unused_element // ignore_for_file: unused_element
final _remoteMessageConverter = RemoteMessageConverter(); final _dateTimeConverter = DateTimeConverter();
final _remoteEventConverter = RemoteEventConverter();

View File

@ -1,71 +1,114 @@
import 'package:floor/floor.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/message/history.dart'; import 'package:solian/providers/message/events.dart';
Future<MessageHistoryDb> createHistoryDb() async { Future<MessageHistoryDb> createHistoryDb() async {
final migration1to2 = Migration(1, 2, (database) async {
await database.execute('DROP TABLE IF EXISTS LocalMessage');
});
return await $FloorMessageHistoryDb return await $FloorMessageHistoryDb
.databaseBuilder('messaging_data.dart') .databaseBuilder('messaging_data.dart')
.build(); .addMigrations([migration1to2]).build();
} }
extension MessageHistoryHelper on MessageHistoryDb { extension MessageHistoryHelper on MessageHistoryDb {
receiveMessage(Message remote) async { Future<LocalEvent> receiveEvent(Event remote) async {
final entry = LocalMessage( final entry = LocalEvent(
remote.id, remote.id,
remote, remote,
remote.channelId, remote.channelId,
remote.createdAt,
); );
await localMessages.insert(entry); await localEvents.insert(entry);
switch (remote.type) {
case 'messages.edit':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
final target = await localEvents.findById(body.relatedEvent!);
if (target != null) {
target.data.body = remote.body;
target.data.updatedAt = remote.updatedAt;
await localEvents.update(target);
}
}
case 'messages.delete':
final body = EventMessageBody.fromJson(remote.body);
if (body.relatedEvent != null) {
await localEvents.delete(body.relatedEvent!);
}
}
return entry; return entry;
} }
replaceMessage(Message remote) async { Future<LocalEvent?> getEvent(int id, Channel channel,
final entry = LocalMessage( {String scope = 'global'}) async {
remote.id, final localRecord = await localEvents.findById(id);
remote, if (localRecord != null) return localRecord;
remote.channelId,
); final remoteRecord = await _getRemoteEvent(id, channel, scope);
await localMessages.update(entry); if (remoteRecord == null) return null;
return entry;
return await receiveEvent(remoteRecord);
} }
burnMessage(int id) async { Future<(List<Event>, int)?> syncEvents(Channel channel,
await localMessages.delete(id); {String scope = 'global', depth = 10, offset = 0}) async {
} final lastOne = await localEvents.findLastByChannel(channel.id);
syncMessages(Channel channel, {String scope = 'global', breath = 10, offset = 0}) async { final data = await _getRemoteEvents(
final lastOne = await localMessages.findLastByChannel(channel.id);
final data = await _getRemoteMessages(
channel, channel,
scope, scope,
remainBreath: breath, remainDepth: depth,
offset: offset, offset: offset,
onBrake: (items) { onBrake: (items) {
return items.any((x) => x.id == lastOne?.id); return items.any((x) => x.id == lastOne?.id);
}, },
); );
if (data != null) { if (data != null && !PlatformInfo.isWeb) {
await localMessages.insertBulk( await localEvents.insertBulk(
data.$1.map((x) => LocalMessage(x.id, x, x.channelId)).toList(), data.$1
.map((x) => LocalEvent(x.id, x, x.channelId, x.createdAt))
.toList(),
); );
} }
return data?.$2 ?? 0; return data;
} }
Future<(List<Message>, int)?> _getRemoteMessages( Future<Event?> _getRemoteEvent(int id, Channel channel, String scope) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return null;
final client = auth.configureClient('messaging');
final resp = await client.get(
'/api/channels/$scope/${channel.alias}/events/$id',
);
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return Event.fromJson(resp.body);
}
Future<(List<Event>, int)?> _getRemoteEvents(
Channel channel, Channel channel,
String scope, { String scope, {
required int remainBreath, required int remainDepth,
bool Function(List<Message> items)? onBrake, bool Function(List<Event> items)? onBrake,
take = 10, take = 10,
offset = 0, offset = 0,
}) async { }) async {
if (remainBreath <= 0) { if (remainDepth <= 0) {
return null; return null;
} }
@ -75,7 +118,8 @@ extension MessageHistoryHelper on MessageHistoryDb {
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get( final resp = await client.get(
'/api/channels/$scope/${channel.alias}/messages?take=$take&offset=$offset'); '/api/channels/$scope/${channel.alias}/events?take=$take&offset=$offset',
);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.bodyString);
@ -83,16 +127,16 @@ extension MessageHistoryHelper on MessageHistoryDb {
final PaginationResult response = PaginationResult.fromJson(resp.body); final PaginationResult response = PaginationResult.fromJson(resp.body);
final result = final result =
response.data?.map((e) => Message.fromJson(e)).toList() ?? List.empty(); response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
if (onBrake != null && onBrake(result)) { if (onBrake != null && onBrake(result)) {
return (result, response.count); return (result, response.count);
} }
final expandResult = (await _getRemoteMessages( final expandResult = (await _getRemoteEvents(
channel, channel,
scope, scope,
remainBreath: remainBreath - 1, remainDepth: remainDepth - 1,
take: take, take: take,
offset: offset + result.length, offset: offset + result.length,
)) ))
@ -102,7 +146,7 @@ extension MessageHistoryHelper on MessageHistoryDb {
return ([...result, ...expandResult], response.count); return ([...result, ...expandResult], response.count);
} }
Future<List<LocalMessage>> listMessages(Channel channel) async { Future<List<LocalEvent>> listMessages(Channel channel) async {
return await localMessages.findAllByChannel(channel.id); return await localEvents.findAllByChannel(channel.id);
} }
} }

View File

@ -1,66 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:floor/floor.dart';
import 'package:solian/models/message.dart';
import 'package:sqflite/sqflite.dart' as sqflite;
part 'history.g.dart';
@entity
class LocalMessage {
@primaryKey
final int id;
final Message data;
final int channelId;
LocalMessage(this.id, this.data, this.channelId);
}
class RemoteMessageConverter extends TypeConverter<Message, String> {
@override
Message decode(String databaseValue) {
return Message.fromJson(jsonDecode(databaseValue));
}
@override
String encode(Message value) {
return jsonEncode(value.toJson());
}
}
@dao
abstract class LocalMessageDao {
@Query('SELECT COUNT(id) FROM LocalMessage WHERE channelId = :channelId')
Future<int?> countByChannel(int channelId);
@Query('SELECT * FROM LocalMessage WHERE channelId = :channelId ORDER BY id DESC')
Future<List<LocalMessage>> findAllByChannel(int channelId);
@Query('SELECT * FROM LocalMessage WHERE channelId = :channelId ORDER BY id DESC LIMIT 1')
Future<LocalMessage?> findLastByChannel(int channelId);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insert(LocalMessage m);
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> insertBulk(List<LocalMessage> m);
@Update(onConflict: OnConflictStrategy.replace)
Future<void> update(LocalMessage person);
@Query('DELETE FROM LocalMessage WHERE id = :id')
Future<void> delete(int id);
@Query('DELETE FROM LocalMessage WHERE channelId = :channelId')
Future<List<LocalMessage>> deleteByChannel(int channelId);
@Query('DELETE FROM LocalMessage')
Future<void> wipeLocalMessages();
}
@TypeConverters([RemoteMessageConverter])
@Database(version: 1, entities: [LocalMessage])
abstract class MessageHistoryDb extends FloorDatabase {
LocalMessageDao get localMessages;
}

View File

@ -4,11 +4,11 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_history_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
@ -20,8 +20,8 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart'; import 'package:solian/widgets/chat/call/call_prejoin.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_message.dart'; import 'package:solian/widgets/chat/chat_event.dart';
import 'package:solian/widgets/chat/chat_message_action.dart'; import 'package:solian/widgets/chat/chat_event_action.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
@ -50,7 +50,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
ChannelMember? _channelProfile; ChannelMember? _channelProfile;
StreamSubscription<NetworkPackage>? _subscription; StreamSubscription<NetworkPackage>? _subscription;
late final ChatHistoryController _chatController; late final ChatEventController _chatController;
getProfile() async { getProfile() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@ -109,37 +109,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final ChatProvider provider = Get.find(); final ChatProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = provider.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'messages.new': case 'events.new':
final payload = Message.fromJson(event.payload!); final payload = Event.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { _chatController.receiveEvent(payload);
_chatController.receiveMessage(payload);
}
break;
case 'messages.update':
final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
_chatController.replaceMessage(payload);
}
break;
case 'messages.burnt':
final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) {
_chatController.burnMessage(payload.id);
}
break; break;
case 'calls.new': case 'calls.new':
final payload = Call.fromJson(event.payload!); final payload = Call.fromJson(event.payload!);
_ongoingCall = payload; setState(() => _ongoingCall = payload);
break; break;
case 'calls.end': case 'calls.end':
_ongoingCall = null; setState(() => _ongoingCall = null);
break; break;
} }
}); });
} }
bool checkMessageMergeable(Message? a, Message? b) { bool checkMessageMergeable(Event? a, Event? b) {
if (a?.replyTo != null) return false;
if (a == null || b == null) return false; if (a == null || b == null) return false;
if (a.sender.account.id != b.sender.account.id) return false; if (a.sender.account.id != b.sender.account.id) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3; return a.createdAt.difference(b.createdAt).inMinutes <= 3;
@ -156,31 +141,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
Message? _messageToReplying; Event? _messageToReplying;
Message? _messageToEditing; Event? _messageToEditing;
Widget buildHistoryBody(Message item, {bool isMerged = false}) { Widget buildHistoryBody(Event item, {bool isMerged = false}) {
if (item.replyTo != null) { return ChatEvent(
return Column(
children: [
ChatMessage(
key: Key('m${item.replyTo!.uuid}'),
item: item.replyTo!,
isReply: true,
).paddingOnly(left: 24, right: 4, bottom: 2),
ChatMessage(
key: Key('m${item.uuid}'),
item: item,
isMerged: isMerged,
),
],
);
}
return ChatMessage(
key: Key('m${item.uuid}'), key: Key('m${item.uuid}'),
item: item, item: item,
isMerged: isMerged, isMerged: isMerged,
chatController: _chatController,
); );
} }
@ -188,18 +157,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool isMerged = false, hasMerged = false; bool isMerged = false, hasMerged = false;
if (index > 0) { if (index > 0) {
hasMerged = checkMessageMergeable( hasMerged = checkMessageMergeable(
_chatController.currentHistory[index - 1].data, _chatController.currentEvents[index - 1].data,
_chatController.currentHistory[index].data, _chatController.currentEvents[index].data,
); );
} }
if (index + 1 < _chatController.currentHistory.length) { if (index + 1 < _chatController.currentEvents.length) {
isMerged = checkMessageMergeable( isMerged = checkMessageMergeable(
_chatController.currentHistory[index].data, _chatController.currentEvents[index].data,
_chatController.currentHistory[index + 1].data, _chatController.currentEvents[index + 1].data,
); );
} }
final item = _chatController.currentHistory[index].data; final item = _chatController.currentEvents[index].data;
return InkWell( return InkWell(
child: Container( child: Container(
@ -212,7 +181,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
context: context, context: context,
builder: (context) => ChatMessageAction( builder: (context) => ChatEventAction(
channel: _channel!, channel: _channel!,
realm: _channel!.realm, realm: _channel!.realm,
item: item, item: item,
@ -230,18 +199,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void initState() { void initState() {
_chatController = ChatHistoryController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
getChannel().then((_) { getChannel().then((_) {
_chatController.getMessages(_channel!, widget.realm); _chatController.getEvents(_channel!, widget.realm);
listenMessages();
}); });
getProfile(); getProfile();
getOngoingCall(); getOngoingCall();
listenMessages();
super.initState(); super.initState();
} }
@ -325,13 +294,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Obx(() { Obx(() {
return SliverList.builder( return SliverList.builder(
key: Key('chat-history#${_channel!.id}'), key: Key('chat-history#${_channel!.id}'),
itemCount: _chatController.currentHistory.length, itemCount: _chatController.currentEvents.length,
itemBuilder: buildHistory, itemBuilder: buildHistory,
); );
}), }),
Obx(() { Obx(() {
final amount = _chatController.totalHistoryCount - final amount = _chatController.totalEvents -
_chatController.currentHistory.length; _chatController.currentEvents.length;
if (amount.value <= 0 || if (amount.value <= 0 ||
_chatController.isLoading.isTrue) { _chatController.isLoading.isTrue) {
@ -348,7 +317,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
'count': amount.string, 'count': amount.string,
})), })),
onTap: () { onTap: () {
_chatController.getMoreMessages( _chatController.loadEvents(
_channel!, _channel!,
widget.realm, widget.realm,
); );
@ -378,9 +347,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
realm: widget.realm, realm: widget.realm,
placeholder: placeholder, placeholder: placeholder,
channel: _channel!, channel: _channel!,
onSent: (Message item) { onSent: (Event item) {
setState(() { setState(() {
_chatController.addTemporaryMessage(item); _chatController.addPendingEvent(item);
}); });
}, },
onReset: () { onReset: () {

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
abstract class ServiceFinder { abstract class ServiceFinder {
static const bool devFlag = false; static const bool devFlag = true;
static Map<String, String> services = { static Map<String, String> services = {
'paperclip': 'paperclip':

View File

@ -166,8 +166,11 @@ class SolianMessages extends Translations {
'channelNotifyLevelApplied': 'Your notification settings has been applied.', 'channelNotifyLevelApplied': 'Your notification settings has been applied.',
'messageUnsync': 'Messages Un-synced', 'messageUnsync': 'Messages Un-synced',
'messageUnsyncCaption': '@count message(s) still in un-synced.', 'messageUnsyncCaption': '@count message(s) still in un-synced.',
'messageDecoding': 'Decoding...', 'messageEditDesc': 'Edited message @id',
'messageDecodeFailed': 'Unable to decode: @message', 'messageDeleteDesc': 'Deleted message @id',
'messageCallStartDesc': '@user starts a call',
'messageCallEndDesc': 'Call last for @duration',
'messageTypeUnsupported': 'Unsupported Message: @type',
'messageInputPlaceholder': 'Message @channel', 'messageInputPlaceholder': 'Message @channel',
'messageActionList': 'Actions of Message', 'messageActionList': 'Actions of Message',
'messageDeletionConfirm': 'Confirm message deletion', 'messageDeletionConfirm': 'Confirm message deletion',
@ -382,8 +385,11 @@ class SolianMessages extends Translations {
'messageUnsync': '消息未同步', 'messageUnsync': '消息未同步',
'messageUnsyncCaption': '还有 @count 条消息未同步', 'messageUnsyncCaption': '还有 @count 条消息未同步',
'messageDecoding': '解码信息中…', 'messageDecoding': '解码信息中…',
'messageDecodeFailed': '解码信息失败:@message', 'messageEditDesc': '修改了消息 @id',
'messageInputPlaceholder': '在 @channel 发信息', 'messageDeleteDesc': '删除了消息 @id',
'messageCallStartDesc': '@user 发起了一次童话',
'messageCallEndDesc': '通话持续了 @duration',
'messageTypeUnsupported': '不支持的消息类型 @type',
'messageActionList': '消息的操作', 'messageActionList': '消息的操作',
'messageDeletionConfirm': '确认删除消息', 'messageDeletionConfirm': '确认删除消息',
'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。', 'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。',

View File

@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/event.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/chat/chat_event_action_log.dart';
import 'package:solian/widgets/chat/chat_event_message.dart';
import 'package:timeago/timeago.dart' show format;
class ChatEvent extends StatelessWidget {
final Event item;
final bool isContentPreviewing;
final bool isQuote;
final bool isMerged;
final bool isHasMerged;
final ChatEventController? chatController;
const ChatEvent({
super.key,
required this.item,
this.isContentPreviewing = false,
this.isMerged = false,
this.isHasMerged = false,
this.isQuote = false,
this.chatController,
});
String _formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : '';
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs());
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
Widget buildQuote() {
return FutureBuilder(
future: chatController!.getEvent(
item.body['quote_event'],
),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox();
}
return ChatEvent(
item: snapshot.data!.data,
isMerged: false,
isQuote: true,
).paddingOnly(left: isMerged ? 52 : 0);
},
);
}
Widget buildContent() {
switch (item.type) {
case 'messages.new':
return ChatEventMessage(
item: item,
isContentPreviewing: isContentPreviewing,
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
case 'messages.edit':
return ChatEventMessageActionLog(
icon: const Icon(Icons.edit_note, size: 16),
text: 'messageEditDesc'.trParams({'id': '#${item.id}'}),
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
case 'messages.delete':
return ChatEventMessageActionLog(
icon: const Icon(Icons.cancel_schedule_send, size: 16),
text: 'messageDeleteDesc'.trParams({'id': '#${item.id}'}),
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
case 'calls.start':
return ChatEventMessageActionLog(
icon: const Icon(Icons.call_made, size: 16),
text: 'messageCallStartDesc'
.trParams({'user': '@${item.sender.account.name}'}),
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
case 'calls.end':
return ChatEventMessageActionLog(
icon: const Icon(Icons.call_received, size: 16),
text: 'messageCallEndDesc'.trParams({
'duration': _formatDuration(
Duration(milliseconds: item.body['last']),
),
}),
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
case 'system.changes':
return ChatEventMessageActionLog(
icon: const Icon(Icons.manage_history, size: 16),
text: item.body['text'],
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
default:
return ChatEventMessageActionLog(
icon: const Icon(Icons.error, size: 16),
text: 'messageTypeUnsupported'.trParams({'type': item.type}),
isMerged: isMerged,
isHasMerged: isHasMerged,
isQuote: isQuote,
);
}
}
Widget buildBody(BuildContext context) {
if (isContentPreviewing || (isMerged && !isQuote)) {
return Column(
children: [
if (item.body['quote_event'] != null && chatController != null)
buildQuote(),
buildContent(),
],
);
} else if (isQuote) {
return Card(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Opacity(
opacity: 0.75,
child: FaIcon(FontAwesomeIcons.quoteLeft, size: 14),
).paddingOnly(bottom: 2.75),
const SizedBox(width: 4),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AccountAvatar(
content: item.sender.account.avatar, radius: 9),
const SizedBox(width: 5),
Text(
item.sender.account.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
Text(format(item.createdAt, locale: 'en_short')),
],
),
buildContent().paddingOnly(left: 0.5),
],
),
),
],
).paddingOnly(left: 12, right: 12, top: 8, bottom: 4),
).paddingOnly(left: isMerged ? 52 : 0, right: 4);
} else {
return Column(
key: Key('m${item.uuid}'),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountAvatar(content: item.sender.account.avatar),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: item.sender.account,
),
);
},
),
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),
if (item.body['quote_event'] != null &&
chatController != null)
buildQuote(),
buildContent(),
],
),
),
],
).paddingSymmetric(horizontal: 12),
],
);
}
}
@override
Widget build(BuildContext context) {
return buildBody(context);
}
}

View File

@ -3,19 +3,19 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/chat/chat_message_deletion.dart'; import 'package:solian/widgets/chat/chat_event_deletion.dart';
class ChatMessageAction extends StatefulWidget { class ChatEventAction extends StatefulWidget {
final Channel channel; final Channel channel;
final Realm? realm; final Realm? realm;
final Message item; final Event item;
final Function? onEdit; final Function? onEdit;
final Function? onReply; final Function? onReply;
const ChatMessageAction({ const ChatEventAction({
super.key, super.key,
required this.channel, required this.channel,
required this.realm, required this.realm,
@ -25,14 +25,16 @@ class ChatMessageAction extends StatefulWidget {
}); });
@override @override
State<ChatMessageAction> createState() => _ChatMessageActionState(); State<ChatEventAction> createState() => _ChatEventActionState();
} }
class _ChatMessageActionState extends State<ChatMessageAction> { class _ChatEventActionState extends State<ChatEventAction> {
bool _isBusy = false; bool _isBusy = false;
bool _canModifyContent = false; bool _canModifyContent = false;
void checkAbleToModifyContent() async { void checkAbleToModifyContent() async {
if (!['messages.new'].contains(widget.item.type)) return;
final AuthProvider provider = Get.find(); final AuthProvider provider = Get.find();
if (!await provider.isAuthorized) return; if (!await provider.isAuthorized) return;
@ -106,7 +108,7 @@ class _ChatMessageActionState extends State<ChatMessageAction> {
onTap: () async { onTap: () async {
final value = await showDialog( final value = await showDialog(
context: context, context: context,
builder: (context) => ChatMessageDeletionDialog( builder: (context) => ChatEventDeletionDialog(
channel: widget.channel, channel: widget.channel,
realm: widget.realm, realm: widget.realm,
item: widget.item, item: widget.item,

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class ChatEventMessageActionLog extends StatelessWidget {
final Widget icon;
final String text;
final bool isQuote;
final bool isMerged;
final bool isHasMerged;
const ChatEventMessageActionLog({
super.key,
required this.icon,
required this.text,
this.isMerged = false,
this.isHasMerged = false,
this.isQuote = false,
});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: 0.75,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
icon,
const SizedBox(width: 4),
Text(text),
],
).paddingOnly(
left: isQuote ? 0 : (isMerged ? 64 : 12),
top: 2,
bottom: isHasMerged ? 2 : 0,
),
);
}
}

View File

@ -2,16 +2,16 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
class ChatMessageDeletionDialog extends StatefulWidget { class ChatEventDeletionDialog extends StatefulWidget {
final Channel channel; final Channel channel;
final Realm? realm; final Realm? realm;
final Message item; final Event item;
const ChatMessageDeletionDialog({ const ChatEventDeletionDialog({
super.key, super.key,
required this.channel, required this.channel,
required this.realm, required this.realm,
@ -19,11 +19,11 @@ class ChatMessageDeletionDialog extends StatefulWidget {
}); });
@override @override
State<ChatMessageDeletionDialog> createState() => State<ChatEventDeletionDialog> createState() =>
_ChatMessageDeletionDialogState(); _ChatEventDeletionDialogState();
} }
class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> { class _ChatEventDeletionDialogState extends State<ChatEventDeletionDialog> {
bool _isBusy = false; bool _isBusy = false;
void performAction() async { void performAction() async {

View File

@ -0,0 +1,96 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:get/get.dart';
import 'package:solian/models/event.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ChatEventMessage extends StatelessWidget {
final Event item;
final bool isContentPreviewing;
final bool isQuote;
final bool isMerged;
final bool isHasMerged;
const ChatEventMessage({
super.key,
required this.item,
this.isContentPreviewing = false,
this.isMerged = false,
this.isHasMerged = false,
this.isQuote = false,
});
Widget buildAttachment(BuildContext context) {
final body = EventMessageBody.fromJson(item.body);
return SizedBox(
width: min(MediaQuery.of(context).size.width, 640),
child: AttachmentList(
key: Key('m${item.uuid}attachments'),
parentId: item.uuid,
attachmentsId: body.attachments ?? List.empty(),
divided: true,
viewport: 1,
),
);
}
Widget buildContent() {
final body = EventMessageBody.fromJson(item.body);
final hasAttachment = body.attachments?.isNotEmpty ?? false;
return Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: body.text,
selectable: true,
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
).paddingOnly(
left: isQuote ? 0 : 12,
right: isQuote ? 0 : 12,
top: body.quoteEvent == null ? 2 : 0,
bottom: hasAttachment ? 4 : (isHasMerged ? 2 : 0),
);
}
Widget buildBody(BuildContext context) {
final body = EventMessageBody.fromJson(item.body);
if (isContentPreviewing) {
return buildContent();
} else if (isMerged) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildContent().paddingOnly(left: 52),
if (body.attachments?.isNotEmpty ?? false)
buildAttachment(context).paddingOnly(left: 52, bottom: 4),
],
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildContent(),
if (body.attachments?.isNotEmpty ?? false)
buildAttachment(context).paddingOnly(bottom: 4),
],
);
}
}
@override
Widget build(BuildContext context) {
return buildBody(context);
}
}

View File

@ -1,198 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/models/message.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:timeago/timeago.dart' show format;
import 'package:url_launcher/url_launcher_string.dart';
class ChatMessage extends StatelessWidget {
final Message item;
final bool isContentPreviewing;
final bool isReply;
final bool isMerged;
final bool isHasMerged;
const ChatMessage({
super.key,
required this.item,
this.isContentPreviewing = false,
this.isMerged = false,
this.isHasMerged = false,
this.isReply = false,
});
Future<String?> decodeContent(Map<String, dynamic> content) async {
String? text;
if (item.type == 'm.text') {
switch (content['algorithm']) {
case 'plain':
text = content['value'];
default:
throw Exception('Unsupported algorithm');
}
}
return text;
}
Widget buildContent() {
final hasAttachment = item.attachments?.isNotEmpty ?? false;
return FutureBuilder(
future: decodeContent(item.content),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Opacity(
opacity: 0.8,
child: Row(
children: [
const Icon(Icons.more_horiz),
const SizedBox(width: 4),
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),
const SizedBox(width: 4),
Text(
'messageDecodeFailed'
.trParams({'message': snapshot.error.toString()}),
)
],
),
);
}
if (snapshot.data?.isNotEmpty ?? false) {
return Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: snapshot.data ?? '',
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
).paddingOnly(
left: 12,
right: 12,
top: 2,
bottom: hasAttachment ? 4 : 0,
);
} else {
return const SizedBox();
}
},
);
}
Widget buildBody(BuildContext context) {
if (isContentPreviewing) {
return buildContent();
} else if (isMerged) {
return Column(
children: [
buildContent().paddingOnly(left: 52),
if (item.attachments?.isNotEmpty ?? false)
AttachmentList(
key: Key('m${item.uuid}attachments'),
parentId: item.uuid,
attachmentsId: item.attachments ?? List.empty(),
).paddingSymmetric(vertical: 4),
],
);
} else if (isReply) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Transform.scale(
scaleX: -1,
child: const FaIcon(FontAwesomeIcons.reply, size: 14),
),
const SizedBox(width: 12),
AccountAvatar(content: item.sender.account.avatar, radius: 8),
const SizedBox(width: 4),
Text(
item.sender.account.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Expanded(child: buildContent()),
],
);
} else {
return Column(
key: Key('m${item.uuid}'),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountAvatar(content: item.sender.account.avatar),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: item.sender.account,
),
);
},
),
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),
buildContent(),
if (item.attachments?.isNotEmpty ?? false)
SizedBox(
width: min(MediaQuery.of(context).size.width, 640),
child: AttachmentList(
key: Key('m${item.uuid}attachments'),
parentId: item.uuid,
attachmentsId: item.attachments ?? List.empty(),
divided: true,
viewport: 1,
),
),
],
),
),
],
).paddingSymmetric(horizontal: 12),
],
);
}
}
@override
Widget build(BuildContext context) {
return buildBody(context);
}
}

View File

@ -4,19 +4,19 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/event.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/attachments/attachment_publish.dart'; import 'package:solian/widgets/attachments/attachment_publish.dart';
import 'package:solian/widgets/chat/chat_message.dart'; import 'package:solian/widgets/chat/chat_event.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChatMessageInput extends StatefulWidget { class ChatMessageInput extends StatefulWidget {
final Message? edit; final Event? edit;
final Message? reply; final Event? reply;
final String? placeholder; final String? placeholder;
final Channel channel; final Channel channel;
final String realm; final String realm;
final Function(Message) onSent; final Function(Event) onSent;
final Function()? onReset; final Function()? onReset;
const ChatMessageInput({ const ChatMessageInput({
@ -40,8 +40,8 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
List<int> _attachments = List.empty(growable: true); List<int> _attachments = List.empty(growable: true);
Message? _editTo; Event? _editTo;
Message? _replyTo; Event? _replyTo;
void showAttachments() { void showAttachments() {
showModalBottomSheet( showModalBottomSheet(
@ -54,14 +54,6 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
); );
} }
Map<String, dynamic> encodeMessage(String content) {
return {
'value': content.trim(),
'keypair_id': null,
'algorithm': 'plain',
};
}
Future<void> sendMessage() async { Future<void> sendMessage() async {
_focusNode.requestFocus(); _focusNode.requestFocus();
@ -71,15 +63,25 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
// TODO Deal with the @ ping (query uid with username), and then add into related_user and replace the @ with internal link in body
const uuid = Uuid();
final payload = { final payload = {
'uuid': const Uuid().v4(), 'uuid': uuid.v4(),
'type': 'm.text', 'type': _editTo == null ? 'messages.new' : 'messages.edit',
'content': encodeMessage(_textController.value.text), 'body': {
'attachments': List.from(_attachments), 'text': _textController.value.text,
'reply_to': _replyTo?.id, 'algorithm': 'plain',
'attachments': List.from(_attachments),
'related_users': [
if (_replyTo != null) _replyTo!.sender.accountId,
],
if (_replyTo != null) 'quote_event': _replyTo!.id,
if (_editTo != null) 'related_event': _editTo!.id,
}
}; };
// The mock data // The local mock data
final sender = Sender( final sender = Sender(
id: 0, id: 0,
createdAt: DateTime.now(), createdAt: DateTime.now(),
@ -89,23 +91,20 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
accountId: prof.body['id'], accountId: prof.body['id'],
notify: 0, notify: 0,
); );
final message = Message( final message = Event(
id: 0, id: 0,
uuid: payload['uuid'] as String, uuid: payload['uuid'] as String,
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
content: payload['content'] as Map<String, dynamic>, body: payload['body'] as Map<String, dynamic>,
type: payload['type'] as String, type: payload['type'] as String,
attachments: _attachments,
sender: sender, sender: sender,
replyId: _replyTo?.id,
replyTo: _replyTo,
channelId: widget.channel.id, channelId: widget.channel.id,
senderId: sender.id, senderId: sender.id,
); );
if (_editTo == null) { if (_editTo == null) {
message.isSending = true; message.isPending = true;
widget.onSent(message); widget.onSent(message);
} }
@ -139,9 +138,10 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
void syncWidget() { void syncWidget() {
if (widget.edit != null) { if (widget.edit != null && widget.edit!.type.startsWith('messages')) {
final body = EventMessageBody.fromJson(widget.edit!.body);
_editTo = widget.edit!; _editTo = widget.edit!;
_textController.text = widget.edit!.content['value']; _textController.text = body.text;
} }
if (widget.reply != null) { if (widget.reply != null) {
_replyTo = widget.reply!; _replyTo = widget.reply!;
@ -177,7 +177,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
.colorScheme .colorScheme
.surfaceContainerHighest .surfaceContainerHighest
.withOpacity(0.5), .withOpacity(0.5),
content: ChatMessage( content: ChatEvent(
item: _replyTo!, item: _replyTo!,
isContentPreviewing: true, isContentPreviewing: true,
), ),
@ -192,7 +192,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
.colorScheme .colorScheme
.surfaceContainerHighest .surfaceContainerHighest
.withOpacity(0.5), .withOpacity(0.5),
content: ChatMessage( content: ChatEvent(
item: _editTo!, item: _editTo!,
isContentPreviewing: true, isContentPreviewing: true,
), ),