From 93267eb3279f6ce31a8031b517f73ee51b3a39be Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 18 May 2025 05:36:20 +0800 Subject: [PATCH] :sparkles: Mark message as read --- lib/database/drift_db.dart | 23 ++++++++- lib/database/drift_db.g.dart | 71 +++++++++++++++++++++++++++- lib/database/message.dart | 5 ++ lib/database/message_repository.dart | 8 ++++ lib/pods/websocket.dart | 6 ++- lib/pods/websocket.freezed.dart | 2 +- lib/screens/chat/room.dart | 51 +++++++++++++++----- 7 files changed, 147 insertions(+), 19 deletions(-) diff --git a/lib/database/drift_db.dart b/lib/database/drift_db.dart index d87c4c0..436477b 100644 --- a/lib/database/drift_db.dart +++ b/lib/database/drift_db.dart @@ -10,7 +10,20 @@ class AppDatabase extends _$AppDatabase { AppDatabase(super.e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + }, + onUpgrade: (Migrator m, int from, int to) async { + if (from < 2) { + // Add isRead column with default value false + await m.addColumn(chatMessages, chatMessages.isRead); + } + }, + ); // Methods for chat messages Future> getMessagesForRoom( @@ -40,6 +53,12 @@ class AppDatabase extends _$AppDatabase { )).write(ChatMessagesCompanion(status: Value(status))); } + Future markMessageAsRead(String id) { + return (update(chatMessages)..where( + (m) => m.id.equals(id), + )).write(ChatMessagesCompanion(isRead: const Value(true))); + } + Future deleteMessage(String id) { return (delete(chatMessages)..where((m) => m.id.equals(id))).go(); } @@ -55,6 +74,7 @@ class AppDatabase extends _$AppDatabase { data: Value(jsonEncode(message.data)), createdAt: Value(message.createdAt), status: Value(message.status), + isRead: Value(message.isRead), ); } @@ -68,6 +88,7 @@ class AppDatabase extends _$AppDatabase { createdAt: dbMessage.createdAt, status: dbMessage.status, nonce: dbMessage.nonce, + isRead: dbMessage.isRead, ); } } diff --git a/lib/database/drift_db.g.dart b/lib/database/drift_db.g.dart index feb1d11..dd298e5 100644 --- a/lib/database/drift_db.g.dart +++ b/lib/database/drift_db.g.dart @@ -87,6 +87,19 @@ class $ChatMessagesTable extends ChatMessages type: DriftSqlType.int, requiredDuringInsert: true, ).withConverter($ChatMessagesTable.$converterstatus); + static const VerificationMeta _isReadMeta = const VerificationMeta('isRead'); + @override + late final GeneratedColumn isRead = GeneratedColumn( + 'is_read', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_read" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -97,6 +110,7 @@ class $ChatMessagesTable extends ChatMessages data, createdAt, status, + isRead, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -159,6 +173,12 @@ class $ChatMessagesTable extends ChatMessages } else if (isInserting) { context.missing(_createdAtMeta); } + if (data.containsKey('is_read')) { + context.handle( + _isReadMeta, + isRead.isAcceptableOrUnknown(data['is_read']!, _isReadMeta), + ); + } return context; } @@ -207,6 +227,11 @@ class $ChatMessagesTable extends ChatMessages data['${effectivePrefix}status'], )!, ), + isRead: + attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_read'], + )!, ); } @@ -228,6 +253,7 @@ class ChatMessage extends DataClass implements Insertable { final String data; final DateTime createdAt; final MessageStatus status; + final bool isRead; const ChatMessage({ required this.id, required this.roomId, @@ -237,6 +263,7 @@ class ChatMessage extends DataClass implements Insertable { required this.data, required this.createdAt, required this.status, + required this.isRead, }); @override Map toColumns(bool nullToAbsent) { @@ -257,6 +284,7 @@ class ChatMessage extends DataClass implements Insertable { $ChatMessagesTable.$converterstatus.toSql(status), ); } + map['is_read'] = Variable(isRead); return map; } @@ -274,6 +302,7 @@ class ChatMessage extends DataClass implements Insertable { data: Value(data), createdAt: Value(createdAt), status: Value(status), + isRead: Value(isRead), ); } @@ -293,6 +322,7 @@ class ChatMessage extends DataClass implements Insertable { status: $ChatMessagesTable.$converterstatus.fromJson( serializer.fromJson(json['status']), ), + isRead: serializer.fromJson(json['isRead']), ); } @override @@ -309,6 +339,7 @@ class ChatMessage extends DataClass implements Insertable { 'status': serializer.toJson( $ChatMessagesTable.$converterstatus.toJson(status), ), + 'isRead': serializer.toJson(isRead), }; } @@ -321,6 +352,7 @@ class ChatMessage extends DataClass implements Insertable { String? data, DateTime? createdAt, MessageStatus? status, + bool? isRead, }) => ChatMessage( id: id ?? this.id, roomId: roomId ?? this.roomId, @@ -330,6 +362,7 @@ class ChatMessage extends DataClass implements Insertable { data: data ?? this.data, createdAt: createdAt ?? this.createdAt, status: status ?? this.status, + isRead: isRead ?? this.isRead, ); ChatMessage copyWithCompanion(ChatMessagesCompanion data) { return ChatMessage( @@ -341,6 +374,7 @@ class ChatMessage extends DataClass implements Insertable { data: data.data.present ? data.data.value : this.data, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, status: data.status.present ? data.status.value : this.status, + isRead: data.isRead.present ? data.isRead.value : this.isRead, ); } @@ -354,7 +388,8 @@ class ChatMessage extends DataClass implements Insertable { ..write('nonce: $nonce, ') ..write('data: $data, ') ..write('createdAt: $createdAt, ') - ..write('status: $status') + ..write('status: $status, ') + ..write('isRead: $isRead') ..write(')')) .toString(); } @@ -369,6 +404,7 @@ class ChatMessage extends DataClass implements Insertable { data, createdAt, status, + isRead, ); @override bool operator ==(Object other) => @@ -381,7 +417,8 @@ class ChatMessage extends DataClass implements Insertable { other.nonce == this.nonce && other.data == this.data && other.createdAt == this.createdAt && - other.status == this.status); + other.status == this.status && + other.isRead == this.isRead); } class ChatMessagesCompanion extends UpdateCompanion { @@ -393,6 +430,7 @@ class ChatMessagesCompanion extends UpdateCompanion { final Value data; final Value createdAt; final Value status; + final Value isRead; final Value rowid; const ChatMessagesCompanion({ this.id = const Value.absent(), @@ -403,6 +441,7 @@ class ChatMessagesCompanion extends UpdateCompanion { this.data = const Value.absent(), this.createdAt = const Value.absent(), this.status = const Value.absent(), + this.isRead = const Value.absent(), this.rowid = const Value.absent(), }); ChatMessagesCompanion.insert({ @@ -414,6 +453,7 @@ class ChatMessagesCompanion extends UpdateCompanion { required String data, required DateTime createdAt, required MessageStatus status, + this.isRead = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), roomId = Value(roomId), @@ -430,6 +470,7 @@ class ChatMessagesCompanion extends UpdateCompanion { Expression? data, Expression? createdAt, Expression? status, + Expression? isRead, Expression? rowid, }) { return RawValuesInsertable({ @@ -441,6 +482,7 @@ class ChatMessagesCompanion extends UpdateCompanion { if (data != null) 'data': data, if (createdAt != null) 'created_at': createdAt, if (status != null) 'status': status, + if (isRead != null) 'is_read': isRead, if (rowid != null) 'rowid': rowid, }); } @@ -454,6 +496,7 @@ class ChatMessagesCompanion extends UpdateCompanion { Value? data, Value? createdAt, Value? status, + Value? isRead, Value? rowid, }) { return ChatMessagesCompanion( @@ -465,6 +508,7 @@ class ChatMessagesCompanion extends UpdateCompanion { data: data ?? this.data, createdAt: createdAt ?? this.createdAt, status: status ?? this.status, + isRead: isRead ?? this.isRead, rowid: rowid ?? this.rowid, ); } @@ -498,6 +542,9 @@ class ChatMessagesCompanion extends UpdateCompanion { $ChatMessagesTable.$converterstatus.toSql(status.value), ); } + if (isRead.present) { + map['is_read'] = Variable(isRead.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -515,6 +562,7 @@ class ChatMessagesCompanion extends UpdateCompanion { ..write('data: $data, ') ..write('createdAt: $createdAt, ') ..write('status: $status, ') + ..write('isRead: $isRead, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -542,6 +590,7 @@ typedef $$ChatMessagesTableCreateCompanionBuilder = required String data, required DateTime createdAt, required MessageStatus status, + Value isRead, Value rowid, }); typedef $$ChatMessagesTableUpdateCompanionBuilder = @@ -554,6 +603,7 @@ typedef $$ChatMessagesTableUpdateCompanionBuilder = Value data, Value createdAt, Value status, + Value isRead, Value rowid, }); @@ -606,6 +656,11 @@ class $$ChatMessagesTableFilterComposer column: $table.status, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnFilters get isRead => $composableBuilder( + column: $table.isRead, + builder: (column) => ColumnFilters(column), + ); } class $$ChatMessagesTableOrderingComposer @@ -656,6 +711,11 @@ class $$ChatMessagesTableOrderingComposer column: $table.status, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get isRead => $composableBuilder( + column: $table.isRead, + builder: (column) => ColumnOrderings(column), + ); } class $$ChatMessagesTableAnnotationComposer @@ -690,6 +750,9 @@ class $$ChatMessagesTableAnnotationComposer GeneratedColumnWithTypeConverter get status => $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get isRead => + $composableBuilder(column: $table.isRead, builder: (column) => column); } class $$ChatMessagesTableTableManager @@ -732,6 +795,7 @@ class $$ChatMessagesTableTableManager Value data = const Value.absent(), Value createdAt = const Value.absent(), Value status = const Value.absent(), + Value isRead = const Value.absent(), Value rowid = const Value.absent(), }) => ChatMessagesCompanion( id: id, @@ -742,6 +806,7 @@ class $$ChatMessagesTableTableManager data: data, createdAt: createdAt, status: status, + isRead: isRead, rowid: rowid, ), createCompanionCallback: @@ -754,6 +819,7 @@ class $$ChatMessagesTableTableManager required String data, required DateTime createdAt, required MessageStatus status, + Value isRead = const Value.absent(), Value rowid = const Value.absent(), }) => ChatMessagesCompanion.insert( id: id, @@ -764,6 +830,7 @@ class $$ChatMessagesTableTableManager data: data, createdAt: createdAt, status: status, + isRead: isRead, rowid: rowid, ), withReferenceMapper: diff --git a/lib/database/message.dart b/lib/database/message.dart index f5c9d77..76f7bba 100644 --- a/lib/database/message.dart +++ b/lib/database/message.dart @@ -11,6 +11,7 @@ class ChatMessages extends Table { TextColumn get data => text()(); DateTimeColumn get createdAt => dateTime()(); IntColumn get status => intEnum()(); + BoolColumn get isRead => boolean().withDefault(const Constant(false))(); @override Set get primaryKey => {id}; @@ -25,6 +26,7 @@ class LocalChatMessage { MessageStatus status; final String? nonce; List? localAttachments; + bool isRead; LocalChatMessage({ required this.id, @@ -35,6 +37,7 @@ class LocalChatMessage { required this.nonce, required this.status, this.localAttachments, + this.isRead = false, }); SnChatMessage toRemoteMessage() { @@ -45,6 +48,7 @@ class LocalChatMessage { SnChatMessage message, MessageStatus status, { String? nonce, + bool isRead = false, }) { return LocalChatMessage( id: message.id, @@ -54,6 +58,7 @@ class LocalChatMessage { createdAt: message.createdAt, status: status, nonce: nonce ?? message.nonce, + isRead: isRead, ); } } diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 9d75299..7ae52da 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -457,4 +457,12 @@ class MessageRepository { rethrow; } } + + Future markMessageAsRead(String messageId) async { + try { + await _database.markMessageAsRead(messageId); + } catch (e) { + showErrorAlert(e); + } + } } diff --git a/lib/pods/websocket.dart b/lib/pods/websocket.dart index ff1af27..14380fa 100644 --- a/lib/pods/websocket.dart +++ b/lib/pods/websocket.dart @@ -26,7 +26,7 @@ abstract class WebSocketPacket with _$WebSocketPacket { const factory WebSocketPacket({ required String type, required Map? data, - required String? errorMessage, + String? errorMessage, }) = _WebSocketPacket; factory WebSocketPacket.fromJson(Map json) => @@ -87,7 +87,9 @@ class WebSocketService { data is Uint8List ? utf8.decode(data) : data.toString(); final packet = WebSocketPacket.fromJson(jsonDecode(dataStr)); _streamController.sink.add(packet); - log("[WebSocket] Received packet: ${packet.type}"); + log( + "[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}", + ); }, onDone: () { log('[WebSocket] Connection closed, attempting to reconnect...'); diff --git a/lib/pods/websocket.freezed.dart b/lib/pods/websocket.freezed.dart index f05ed90..2136507 100644 --- a/lib/pods/websocket.freezed.dart +++ b/lib/pods/websocket.freezed.dart @@ -310,7 +310,7 @@ as String?, @JsonSerializable() class _WebSocketPacket with DiagnosticableTreeMixin implements WebSocketPacket { - const _WebSocketPacket({required this.type, required final Map? data, required this.errorMessage}): _data = data; + const _WebSocketPacket({required this.type, required final Map? data, this.errorMessage}): _data = data; factory _WebSocketPacket.fromJson(Map json) => _$WebSocketPacketFromJson(json); @override final String type; diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 4b1c844..f3b4d0f 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -19,6 +20,7 @@ import 'package:island/screens/posts/compose.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/chat/message_bubble.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:uuid/uuid.dart'; @@ -306,6 +308,30 @@ class ChatRoomScreen extends HookConsumerWidget { final attachments = useState>([]); final attachmentProgress = useState>>({}); + // Function to send read receipt + void sendReadReceipt(String messageId) async { + // Get message from repository to check read status + final repository = await ref.read(messageRepositoryProvider(id).future); + final message = await repository.getMessageById(messageId); + + // Skip if message is already marked as read + if (message?.isRead ?? false) return; + + // Send websocket packet + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.read', + data: {'chat_room_id': id, 'message_id': messageId}, + ), + ), + ); + + // Mark as read in local database + await repository.markMessageAsRead(messageId); + } + // Add scroll listener for pagination useEffect(() { void onScroll() { @@ -319,7 +345,6 @@ class ChatRoomScreen extends HookConsumerWidget { return () => scrollController.removeListener(onScroll); }, [scrollController]); - // Add websocket listener // Add websocket listener for new messages useEffect(() { void onMessage(WebSocketPacket pkt) { @@ -329,6 +354,8 @@ class ChatRoomScreen extends HookConsumerWidget { switch (pkt.type) { case 'messages.new': messagesNotifier.receiveMessage(message); + // Send read receipt for new message + sendReadReceipt(message.id); case 'messages.update': messagesNotifier.receiveMessageUpdate(message); case 'messages.delete': @@ -428,7 +455,11 @@ class ChatRoomScreen extends HookConsumerWidget { ], ), loading: () => const Text('Loading...'), - error: (_, __) => const Text('Error'), + error: + (err, __) => ResponseErrorWidget( + error: err, + onRetry: () => messagesNotifier.loadInitial(), + ), ), actions: [ IconButton( @@ -469,6 +500,8 @@ class ChatRoomScreen extends HookConsumerWidget { nextMessage == null || nextMessage.senderId != message.senderId; + sendReadReceipt(message.id); + return chatIdentity.when( skipError: true, data: @@ -527,17 +560,9 @@ class ChatRoomScreen extends HookConsumerWidget { ), loading: () => const Center(child: CircularProgressIndicator()), error: - (error, stack) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Error: $error'), - ElevatedButton( - onPressed: () => messagesNotifier.loadInitial(), - child: Text('Retry'.tr()), - ), - ], - ), + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => messagesNotifier.loadInitial(), ), ), ),