✨ Message sync & realtime
This commit is contained in:
parent
efdddf72e4
commit
7edfd56bf7
@ -33,6 +33,11 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return into(chatMessages).insert(message, mode: InsertMode.insertOrReplace);
|
return into(chatMessages).insert(message, mode: InsertMode.insertOrReplace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> updateMessage(ChatMessagesCompanion message) {
|
||||||
|
return (update(chatMessages)
|
||||||
|
..where((m) => m.id.equals(message.id.value))).write(message);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> updateMessageStatus(String id, MessageStatus status) {
|
Future<int> updateMessageStatus(String id, MessageStatus status) {
|
||||||
return (update(chatMessages)..where(
|
return (update(chatMessages)..where(
|
||||||
(m) => m.id.equals(id),
|
(m) => m.id.equals(id),
|
||||||
|
@ -3,6 +3,7 @@ import 'package:island/database/drift_db.dart';
|
|||||||
import 'package:island/database/message.dart';
|
import 'package:island/database/message.dart';
|
||||||
import 'package:island/models/chat.dart';
|
import 'package:island/models/chat.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class MessageRepository {
|
class MessageRepository {
|
||||||
@ -15,9 +16,56 @@ class MessageRepository {
|
|||||||
|
|
||||||
MessageRepository(this.room, this.identity, this._apiClient, this._database);
|
MessageRepository(this.room, this.identity, this._apiClient, this._database);
|
||||||
|
|
||||||
|
Future<LocalChatMessage?> getLastMessages() async {
|
||||||
|
final dbMessages = await _database.getMessagesForRoom(
|
||||||
|
room.id,
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dbMessages.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _database.companionToMessage(dbMessages.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> syncMessages() async {
|
||||||
|
final lastMessage = await getLastMessages();
|
||||||
|
if (lastMessage == null) return false;
|
||||||
|
try {
|
||||||
|
final resp = await _apiClient.post(
|
||||||
|
'/chat/${room.id}/sync',
|
||||||
|
data: {
|
||||||
|
'last_sync_timestamp':
|
||||||
|
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = MessageSyncResponse.fromJson(resp.data);
|
||||||
|
for (final change in response.changes) {
|
||||||
|
switch (change.action) {
|
||||||
|
case MessageChangeAction.create:
|
||||||
|
await receiveMessage(change.message!);
|
||||||
|
break;
|
||||||
|
case MessageChangeAction.update:
|
||||||
|
await receiveMessageUpdate(change.message!);
|
||||||
|
break;
|
||||||
|
case MessageChangeAction.delete:
|
||||||
|
await receiveMessageDeletion(change.messageId.toString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<LocalChatMessage>> listMessages({
|
Future<List<LocalChatMessage>> listMessages({
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
int take = 20,
|
int take = 20,
|
||||||
|
bool synced = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final localMessages = await _getCachedMessages(
|
final localMessages = await _getCachedMessages(
|
||||||
@ -26,8 +74,9 @@ class MessageRepository {
|
|||||||
take: take,
|
take: take,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (offset == 0) {
|
// If it already synced with the remote, skip this
|
||||||
// Always fetch latest messages in background if we're loading the first page
|
if (offset == 0 && !synced) {
|
||||||
|
// Fetch latest messages
|
||||||
_fetchAndCacheMessages(room.id, offset: offset, take: take);
|
_fetchAndCacheMessages(room.id, offset: offset, take: take);
|
||||||
|
|
||||||
if (localMessages.isNotEmpty) {
|
if (localMessages.isNotEmpty) {
|
||||||
@ -238,4 +287,97 @@ class MessageRepository {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LocalChatMessage> receiveMessage(SnChatMessage remoteMessage) async {
|
||||||
|
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteMessage.nonce != null) {
|
||||||
|
pendingMessages.removeWhere(
|
||||||
|
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||||
|
return localMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LocalChatMessage> receiveMessageUpdate(
|
||||||
|
SnChatMessage remoteMessage,
|
||||||
|
) async {
|
||||||
|
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _database.updateMessage(_database.messageToCompanion(localMessage));
|
||||||
|
return localMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessageDeletion(String messageId) async {
|
||||||
|
// Remove from pending messages if exists
|
||||||
|
pendingMessages.remove(messageId);
|
||||||
|
|
||||||
|
// Delete from local database
|
||||||
|
await _database.deleteMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LocalChatMessage> updateMessage(
|
||||||
|
String messageId,
|
||||||
|
String content, {
|
||||||
|
List<SnCloudFile>? attachments,
|
||||||
|
Map<String, dynamic>? meta,
|
||||||
|
}) async {
|
||||||
|
final message = pendingMessages[messageId];
|
||||||
|
if (message != null) {
|
||||||
|
// Update pending message
|
||||||
|
final rmMessage = message.toRemoteMessage();
|
||||||
|
final updatedRemoteMessage = rmMessage.copyWith(
|
||||||
|
content: content,
|
||||||
|
meta: meta ?? rmMessage.meta,
|
||||||
|
);
|
||||||
|
final updatedLocalMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
updatedRemoteMessage,
|
||||||
|
MessageStatus.pending,
|
||||||
|
);
|
||||||
|
pendingMessages[messageId] = updatedLocalMessage;
|
||||||
|
await _database.updateMessage(
|
||||||
|
_database.messageToCompanion(updatedLocalMessage),
|
||||||
|
);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update on server
|
||||||
|
final response = await _apiClient.put(
|
||||||
|
'/chat/${room.id}/messages/$messageId',
|
||||||
|
data: {'content': content, 'attachments': attachments, 'meta': meta},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update local copy
|
||||||
|
final remoteMessage = SnChatMessage.fromJson(response.data);
|
||||||
|
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
await _database.updateMessage(
|
||||||
|
_database.messageToCompanion(updatedMessage),
|
||||||
|
);
|
||||||
|
return updatedMessage;
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMessage(String messageId) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.delete('/chat/${room.id}/messages/$messageId');
|
||||||
|
pendingMessages.remove(messageId);
|
||||||
|
await _database.deleteMessage(messageId);
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ void main() async {
|
|||||||
|
|
||||||
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
||||||
doWhenWindowReady(() {
|
doWhenWindowReady(() {
|
||||||
const initialSize = Size(600, 450);
|
const initialSize = Size(360, 640);
|
||||||
appWindow.minSize = initialSize;
|
appWindow.minSize = initialSize;
|
||||||
appWindow.size = initialSize;
|
appWindow.size = initialSize;
|
||||||
appWindow.alignment = Alignment.center;
|
appWindow.alignment = Alignment.center;
|
||||||
|
@ -94,3 +94,33 @@ abstract class SnChatMember with _$SnChatMember {
|
|||||||
factory SnChatMember.fromJson(Map<String, dynamic> json) =>
|
factory SnChatMember.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnChatMemberFromJson(json);
|
_$SnChatMemberFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MessageChangeAction {
|
||||||
|
static const String create = "create";
|
||||||
|
static const String update = "update";
|
||||||
|
static const String delete = "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MessageChange with _$MessageChange {
|
||||||
|
const factory MessageChange({
|
||||||
|
required String messageId,
|
||||||
|
required String action,
|
||||||
|
SnChatMessage? message,
|
||||||
|
required DateTime timestamp,
|
||||||
|
}) = _MessageChange;
|
||||||
|
|
||||||
|
factory MessageChange.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MessageChangeFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MessageSyncResponse with _$MessageSyncResponse {
|
||||||
|
const factory MessageSyncResponse({
|
||||||
|
@Default([]) List<MessageChange> changes,
|
||||||
|
required DateTime currentTimestamp,
|
||||||
|
}) = _MessageSyncResponse;
|
||||||
|
|
||||||
|
factory MessageSyncResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MessageSyncResponseFromJson(json);
|
||||||
|
}
|
||||||
|
@ -916,4 +916,312 @@ $SnAccountCopyWith<$Res> get account {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$MessageChange {
|
||||||
|
|
||||||
|
String get messageId; String get action; SnChatMessage? get message; DateTime get timestamp;
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$MessageChangeCopyWith<MessageChange> get copyWith => _$MessageChangeCopyWithImpl<MessageChange>(this as MessageChange, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this MessageChange to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $MessageChangeCopyWith<$Res> {
|
||||||
|
factory $MessageChangeCopyWith(MessageChange value, $Res Function(MessageChange) _then) = _$MessageChangeCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String messageId, String action, SnChatMessage? message, DateTime timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnChatMessageCopyWith<$Res>? get message;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$MessageChangeCopyWithImpl<$Res>
|
||||||
|
implements $MessageChangeCopyWith<$Res> {
|
||||||
|
_$MessageChangeCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final MessageChange _self;
|
||||||
|
final $Res Function(MessageChange) _then;
|
||||||
|
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnChatMessageCopyWith<$Res>? get message {
|
||||||
|
if (_self.message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $SnChatMessageCopyWith<$Res>(_self.message!, (value) {
|
||||||
|
return _then(_self.copyWith(message: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _MessageChange implements MessageChange {
|
||||||
|
const _MessageChange({required this.messageId, required this.action, this.message, required this.timestamp});
|
||||||
|
factory _MessageChange.fromJson(Map<String, dynamic> json) => _$MessageChangeFromJson(json);
|
||||||
|
|
||||||
|
@override final String messageId;
|
||||||
|
@override final String action;
|
||||||
|
@override final SnChatMessage? message;
|
||||||
|
@override final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$MessageChangeCopyWith<_MessageChange> get copyWith => __$MessageChangeCopyWithImpl<_MessageChange>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$MessageChangeToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$MessageChangeCopyWith<$Res> implements $MessageChangeCopyWith<$Res> {
|
||||||
|
factory _$MessageChangeCopyWith(_MessageChange value, $Res Function(_MessageChange) _then) = __$MessageChangeCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String messageId, String action, SnChatMessage? message, DateTime timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@override $SnChatMessageCopyWith<$Res>? get message;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$MessageChangeCopyWithImpl<$Res>
|
||||||
|
implements _$MessageChangeCopyWith<$Res> {
|
||||||
|
__$MessageChangeCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _MessageChange _self;
|
||||||
|
final $Res Function(_MessageChange) _then;
|
||||||
|
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) {
|
||||||
|
return _then(_MessageChange(
|
||||||
|
messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of MessageChange
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnChatMessageCopyWith<$Res>? get message {
|
||||||
|
if (_self.message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $SnChatMessageCopyWith<$Res>(_self.message!, (value) {
|
||||||
|
return _then(_self.copyWith(message: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$MessageSyncResponse {
|
||||||
|
|
||||||
|
List<MessageChange> get changes; DateTime get currentTimestamp;
|
||||||
|
/// Create a copy of MessageSyncResponse
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$MessageSyncResponseCopyWith<MessageSyncResponse> get copyWith => _$MessageSyncResponseCopyWithImpl<MessageSyncResponse>(this as MessageSyncResponse, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this MessageSyncResponse to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageSyncResponse&&const DeepCollectionEquality().equals(other.changes, changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(changes),currentTimestamp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $MessageSyncResponseCopyWith<$Res> {
|
||||||
|
factory $MessageSyncResponseCopyWith(MessageSyncResponse value, $Res Function(MessageSyncResponse) _then) = _$MessageSyncResponseCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
List<MessageChange> changes, DateTime currentTimestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$MessageSyncResponseCopyWithImpl<$Res>
|
||||||
|
implements $MessageSyncResponseCopyWith<$Res> {
|
||||||
|
_$MessageSyncResponseCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final MessageSyncResponse _self;
|
||||||
|
final $Res Function(MessageSyncResponse) _then;
|
||||||
|
|
||||||
|
/// Create a copy of MessageSyncResponse
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? changes = null,Object? currentTimestamp = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
changes: null == changes ? _self.changes : changes // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<MessageChange>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _MessageSyncResponse implements MessageSyncResponse {
|
||||||
|
const _MessageSyncResponse({final List<MessageChange> changes = const [], required this.currentTimestamp}): _changes = changes;
|
||||||
|
factory _MessageSyncResponse.fromJson(Map<String, dynamic> json) => _$MessageSyncResponseFromJson(json);
|
||||||
|
|
||||||
|
final List<MessageChange> _changes;
|
||||||
|
@override@JsonKey() List<MessageChange> get changes {
|
||||||
|
if (_changes is EqualUnmodifiableListView) return _changes;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override final DateTime currentTimestamp;
|
||||||
|
|
||||||
|
/// Create a copy of MessageSyncResponse
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$MessageSyncResponseCopyWith<_MessageSyncResponse> get copyWith => __$MessageSyncResponseCopyWithImpl<_MessageSyncResponse>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$MessageSyncResponseToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageSyncResponse&&const DeepCollectionEquality().equals(other._changes, _changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_changes),currentTimestamp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$MessageSyncResponseCopyWith<$Res> implements $MessageSyncResponseCopyWith<$Res> {
|
||||||
|
factory _$MessageSyncResponseCopyWith(_MessageSyncResponse value, $Res Function(_MessageSyncResponse) _then) = __$MessageSyncResponseCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
List<MessageChange> changes, DateTime currentTimestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$MessageSyncResponseCopyWithImpl<$Res>
|
||||||
|
implements _$MessageSyncResponseCopyWith<$Res> {
|
||||||
|
__$MessageSyncResponseCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _MessageSyncResponse _self;
|
||||||
|
final $Res Function(_MessageSyncResponse) _then;
|
||||||
|
|
||||||
|
/// Create a copy of MessageSyncResponse
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? changes = null,Object? currentTimestamp = null,}) {
|
||||||
|
return _then(_MessageSyncResponse(
|
||||||
|
changes: null == changes ? _self._changes : changes // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<MessageChange>,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// dart format on
|
// dart format on
|
||||||
|
@ -195,3 +195,39 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
|
|||||||
'joined_at': instance.joinedAt?.toIso8601String(),
|
'joined_at': instance.joinedAt?.toIso8601String(),
|
||||||
'is_bot': instance.isBot,
|
'is_bot': instance.isBot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_MessageChange _$MessageChangeFromJson(Map<String, dynamic> json) =>
|
||||||
|
_MessageChange(
|
||||||
|
messageId: json['message_id'] as String,
|
||||||
|
action: json['action'] as String,
|
||||||
|
message:
|
||||||
|
json['message'] == null
|
||||||
|
? null
|
||||||
|
: SnChatMessage.fromJson(json['message'] as Map<String, dynamic>),
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$MessageChangeToJson(_MessageChange instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'message_id': instance.messageId,
|
||||||
|
'action': instance.action,
|
||||||
|
'message': instance.message?.toJson(),
|
||||||
|
'timestamp': instance.timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_MessageSyncResponse _$MessageSyncResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
_MessageSyncResponse(
|
||||||
|
changes:
|
||||||
|
(json['changes'] as List<dynamic>?)
|
||||||
|
?.map((e) => MessageChange.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
currentTimestamp: DateTime.parse(json['current_timestamp'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$MessageSyncResponseToJson(
|
||||||
|
_MessageSyncResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'changes': instance.changes.map((e) => e.toJson()).toList(),
|
||||||
|
'current_timestamp': instance.currentTimestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
@ -7,8 +7,11 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/database/message.dart';
|
import 'package:island/database/message.dart';
|
||||||
import 'package:island/database/message_repository.dart';
|
import 'package:island/database/message_repository.dart';
|
||||||
|
import 'package:island/models/chat.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/message.dart';
|
import 'package:island/pods/message.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/route.gr.dart';
|
import 'package:island/route.gr.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
@ -52,9 +55,11 @@ class MessagesNotifier
|
|||||||
final repository = await _ref.read(
|
final repository = await _ref.read(
|
||||||
messageRepositoryProvider(_roomId).future,
|
messageRepositoryProvider(_roomId).future,
|
||||||
);
|
);
|
||||||
|
final synced = await repository.syncMessages();
|
||||||
final messages = await repository.listMessages(
|
final messages = await repository.listMessages(
|
||||||
offset: 0,
|
offset: 0,
|
||||||
take: _pageSize,
|
take: _pageSize,
|
||||||
|
synced: synced,
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(messages);
|
state = AsyncValue.data(messages);
|
||||||
_currentPage = 0;
|
_currentPage = 0;
|
||||||
@ -145,6 +150,144 @@ class MessagesNotifier
|
|||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
||||||
|
try {
|
||||||
|
final repository = await _ref.read(
|
||||||
|
messageRepositoryProvider(_roomId).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip if this message is not for this room
|
||||||
|
if (remoteMessage.chatRoomId != _roomId) return;
|
||||||
|
|
||||||
|
final localMessage = await repository.receiveMessage(remoteMessage);
|
||||||
|
|
||||||
|
// Add the new message to the state
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
|
||||||
|
// Check if the message already exists (by id or nonce)
|
||||||
|
final existingIndex = currentMessages.indexWhere(
|
||||||
|
(m) =>
|
||||||
|
m.id == localMessage.id ||
|
||||||
|
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Replace existing message
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[existingIndex] = localMessage;
|
||||||
|
state = AsyncValue.data(newList);
|
||||||
|
} else {
|
||||||
|
// Add new message at the beginning (newest first)
|
||||||
|
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
||||||
|
try {
|
||||||
|
final repository = await _ref.read(
|
||||||
|
messageRepositoryProvider(_roomId).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip if this message is not for this room
|
||||||
|
if (remoteMessage.chatRoomId != _roomId) return;
|
||||||
|
|
||||||
|
final updatedMessage = await repository.receiveMessageUpdate(
|
||||||
|
remoteMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the message in the list
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final index = currentMessages.indexWhere(
|
||||||
|
(m) => m.id == updatedMessage.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[index] = updatedMessage;
|
||||||
|
state = AsyncValue.data(newList);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessageDeletion(String messageId) async {
|
||||||
|
try {
|
||||||
|
final repository = await _ref.read(
|
||||||
|
messageRepositoryProvider(_roomId).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.receiveMessageDeletion(messageId);
|
||||||
|
|
||||||
|
// Remove the message from the list
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final filteredMessages =
|
||||||
|
currentMessages.where((m) => m.id != messageId).toList();
|
||||||
|
|
||||||
|
if (filteredMessages.length != currentMessages.length) {
|
||||||
|
state = AsyncValue.data(filteredMessages);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateMessage(
|
||||||
|
String messageId,
|
||||||
|
String content, {
|
||||||
|
List<SnCloudFile>? attachments,
|
||||||
|
Map<String, dynamic>? meta,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final repository = await _ref.read(
|
||||||
|
messageRepositoryProvider(_roomId).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedMessage = await repository.updateMessage(
|
||||||
|
messageId,
|
||||||
|
content,
|
||||||
|
attachments: attachments,
|
||||||
|
meta: meta,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the message in the list
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final index = currentMessages.indexWhere((m) => m.id == messageId);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[index] = updatedMessage;
|
||||||
|
state = AsyncValue.data(newList);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMessage(String messageId) async {
|
||||||
|
try {
|
||||||
|
final repository = await _ref.read(
|
||||||
|
messageRepositoryProvider(_roomId).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.deleteMessage(messageId);
|
||||||
|
|
||||||
|
// Remove the message from the list
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final filteredMessages =
|
||||||
|
currentMessages.where((m) => m.id != messageId).toList();
|
||||||
|
|
||||||
|
if (filteredMessages.length != currentMessages.length) {
|
||||||
|
state = AsyncValue.data(filteredMessages);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
@ -158,7 +301,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||||
final messages = ref.watch(messagesProvider(id));
|
final messages = ref.watch(messagesProvider(id));
|
||||||
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
||||||
final messagesRepo = ref.watch(messageRepositoryProvider(id));
|
final ws = ref.watch(websocketProvider);
|
||||||
|
|
||||||
final messageController = useTextEditingController();
|
final messageController = useTextEditingController();
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
@ -176,6 +319,34 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
return () => scrollController.removeListener(onScroll);
|
return () => scrollController.removeListener(onScroll);
|
||||||
}, [scrollController]);
|
}, [scrollController]);
|
||||||
|
|
||||||
|
// Add websocket listener
|
||||||
|
// Add websocket listener for new messages
|
||||||
|
useEffect(() {
|
||||||
|
void onMessage(WebSocketPacket pkt) {
|
||||||
|
if (!pkt.type.startsWith('messages')) return;
|
||||||
|
final message = SnChatMessage.fromJson(pkt.data!);
|
||||||
|
if (message.chatRoomId != chatRoom.value?.id) return;
|
||||||
|
switch (pkt.type) {
|
||||||
|
case 'messages.new':
|
||||||
|
messagesNotifier.receiveMessage(message);
|
||||||
|
case 'messages.update':
|
||||||
|
messagesNotifier.receiveMessageUpdate(message);
|
||||||
|
case 'messages.delete':
|
||||||
|
messagesNotifier.receiveMessageDeletion(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final subscription = ws.dataStream.listen(onMessage);
|
||||||
|
return () => subscription.cancel();
|
||||||
|
}, [ws, chatRoom]);
|
||||||
|
|
||||||
|
void sendMessage() {
|
||||||
|
if (messageController.text.trim().isNotEmpty) {
|
||||||
|
messagesNotifier.sendMessage(messageController.text.trim());
|
||||||
|
messageController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: chatRoom.when(
|
title: chatRoom.when(
|
||||||
@ -235,13 +406,13 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
return chatIdentity.when(
|
return chatIdentity.when(
|
||||||
skipError: true,
|
skipError: true,
|
||||||
data:
|
data:
|
||||||
(identity) => MessageBubble(
|
(identity) => _MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser:
|
isCurrentUser:
|
||||||
identity?.id == message.senderId,
|
identity?.id == message.senderId,
|
||||||
),
|
),
|
||||||
loading:
|
loading:
|
||||||
() => MessageBubble(
|
() => _MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser: false,
|
isCurrentUser: false,
|
||||||
),
|
),
|
||||||
@ -265,58 +436,15 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Material(
|
chatRoom.when(
|
||||||
elevation: 2,
|
data:
|
||||||
child: Container(
|
(room) => _ChatInput(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
messageController: messageController,
|
||||||
decoration: BoxDecoration(
|
chatRoom: room!,
|
||||||
color: Colors.white,
|
onSend: sendMessage,
|
||||||
boxShadow: [
|
),
|
||||||
BoxShadow(
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
color: Colors.grey.withOpacity(0.2),
|
loading: () => const SizedBox.shrink(),
|
||||||
spreadRadius: 1,
|
|
||||||
blurRadius: 3,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(icon: const Icon(Icons.add), onPressed: () {}),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: messageController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'chatMessageHint'.tr(
|
|
||||||
args: [chatRoom.value?.name ?? 'unknown'.tr()],
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
onTapOutside:
|
|
||||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
onPressed: () {
|
|
||||||
if (messageController.text.trim().isNotEmpty) {
|
|
||||||
messagesNotifier.sendMessage(
|
|
||||||
messageController.text.trim(),
|
|
||||||
);
|
|
||||||
messageController.clear();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -324,15 +452,61 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class _ChatInput extends StatelessWidget {
|
||||||
|
final TextEditingController messageController;
|
||||||
|
final SnChat chatRoom;
|
||||||
|
final VoidCallback onSend;
|
||||||
|
|
||||||
|
const _ChatInput({
|
||||||
|
required this.messageController,
|
||||||
|
required this.chatRoom,
|
||||||
|
required this.onSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
elevation: 8,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: messageController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'chatMessageHint'.tr(args: [chatRoom.name]),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: (_) => onSend(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
onPressed: onSend,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageBubble extends StatelessWidget {
|
||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
|
|
||||||
const MessageBubble({
|
const _MessageBubble({required this.message, required this.isCurrentUser});
|
||||||
super.key,
|
|
||||||
required this.message,
|
|
||||||
required this.isCurrentUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -346,7 +520,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
ProfilePictureWidget(
|
ProfilePictureWidget(
|
||||||
fileId:
|
fileId:
|
||||||
message.toRemoteMessage().sender.account.profile.pictureId,
|
message.toRemoteMessage().sender.account.profile.pictureId,
|
||||||
radius: 16,
|
radius: 18,
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Flexible(
|
Flexible(
|
||||||
@ -394,7 +568,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
ProfilePictureWidget(
|
ProfilePictureWidget(
|
||||||
fileId:
|
fileId:
|
||||||
message.toRemoteMessage().sender.account.profile.pictureId,
|
message.toRemoteMessage().sender.account.profile.pictureId,
|
||||||
radius: 16,
|
radius: 18,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user