✨ 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return (update(chatMessages)..where(
|
||||
(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/models/chat.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class MessageRepository {
|
||||
@ -15,9 +16,56 @@ class MessageRepository {
|
||||
|
||||
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({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
bool synced = false,
|
||||
}) async {
|
||||
try {
|
||||
final localMessages = await _getCachedMessages(
|
||||
@ -26,8 +74,9 @@ class MessageRepository {
|
||||
take: take,
|
||||
);
|
||||
|
||||
if (offset == 0) {
|
||||
// Always fetch latest messages in background if we're loading the first page
|
||||
// If it already synced with the remote, skip this
|
||||
if (offset == 0 && !synced) {
|
||||
// Fetch latest messages
|
||||
_fetchAndCacheMessages(room.id, offset: offset, take: take);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
@ -238,4 +287,97 @@ class MessageRepository {
|
||||
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)) {
|
||||
doWhenWindowReady(() {
|
||||
const initialSize = Size(600, 450);
|
||||
const initialSize = Size(360, 640);
|
||||
appWindow.minSize = initialSize;
|
||||
appWindow.size = initialSize;
|
||||
appWindow.alignment = Alignment.center;
|
||||
|
@ -94,3 +94,33 @@ abstract class SnChatMember with _$SnChatMember {
|
||||
factory SnChatMember.fromJson(Map<String, dynamic> 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
|
||||
|
@ -195,3 +195,39 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
|
||||
'joined_at': instance.joinedAt?.toIso8601String(),
|
||||
'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:island/database/message.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/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.gr.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@ -52,9 +55,11 @@ class MessagesNotifier
|
||||
final repository = await _ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
final synced = await repository.syncMessages();
|
||||
final messages = await repository.listMessages(
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
synced: synced,
|
||||
);
|
||||
state = AsyncValue.data(messages);
|
||||
_currentPage = 0;
|
||||
@ -145,6 +150,144 @@ class MessagesNotifier
|
||||
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()
|
||||
@ -158,7 +301,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||
final messages = ref.watch(messagesProvider(id));
|
||||
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
||||
final messagesRepo = ref.watch(messageRepositoryProvider(id));
|
||||
final ws = ref.watch(websocketProvider);
|
||||
|
||||
final messageController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
@ -176,6 +319,34 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [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(
|
||||
appBar: AppBar(
|
||||
title: chatRoom.when(
|
||||
@ -235,13 +406,13 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageBubble(
|
||||
(identity) => _MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser:
|
||||
identity?.id == message.senderId,
|
||||
),
|
||||
loading:
|
||||
() => MessageBubble(
|
||||
() => _MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
),
|
||||
@ -265,58 +436,15 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
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),
|
||||
chatRoom.when(
|
||||
data:
|
||||
(room) => _ChatInput(
|
||||
messageController: messageController,
|
||||
chatRoom: room!,
|
||||
onSend: sendMessage,
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -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 bool isCurrentUser;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isCurrentUser,
|
||||
});
|
||||
const _MessageBubble({required this.message, required this.isCurrentUser});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -346,7 +520,7 @@ class MessageBubble extends StatelessWidget {
|
||||
ProfilePictureWidget(
|
||||
fileId:
|
||||
message.toRemoteMessage().sender.account.profile.pictureId,
|
||||
radius: 16,
|
||||
radius: 18,
|
||||
),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
@ -394,7 +568,7 @@ class MessageBubble extends StatelessWidget {
|
||||
ProfilePictureWidget(
|
||||
fileId:
|
||||
message.toRemoteMessage().sender.account.profile.pictureId,
|
||||
radius: 16,
|
||||
radius: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user