Message sync & realtime

This commit is contained in:
LittleSheep 2025-05-03 23:02:44 +08:00
parent efdddf72e4
commit 7edfd56bf7
7 changed files with 761 additions and 66 deletions

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -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

View File

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

View File

@ -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,
),
],
),