diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 6c94bcb..87b26b3 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -87,5 +87,7 @@ }, "permissionOwner": "Owner", "permissionModerator": "Moderator", - "permissionMember": "Member" + "permissionMember": "Member", + "reply": "Reply", + "forward": "Forward" } diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 97b1db4..3e65440 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -3,6 +3,8 @@ 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/pods/network.dart'; +import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; import 'package:uuid/uuid.dart'; @@ -169,11 +171,16 @@ class MessageRepository { } Future sendMessage( + String atk, + String baseUrl, int roomId, String content, String nonce, { - List? attachments, + required List attachments, Map? meta, + SnChatMessage? replyingTo, + SnChatMessage? forwardingTo, + SnChatMessage? editingTo, }) async { // Generate a unique nonce for this message final nonce = const Uuid().v4(); @@ -200,15 +207,44 @@ class MessageRepository { await _database.saveMessage(_database.messageToCompanion(localMessage)); try { + var cloudAttachments = List.empty(growable: true); + // Upload files + for (var idx = 0; idx < attachments.length; idx++) { + final cloudFile = + await putMediaToCloud( + fileData: attachments[idx].data, + atk: atk, + baseUrl: baseUrl, + filename: attachments[idx].data.name ?? 'Post media', + mimetype: + attachments[idx].data.mimeType ?? + switch (attachments[idx].type) { + UniversalFileType.image => 'image/unknown', + UniversalFileType.video => 'video/unknown', + UniversalFileType.audio => 'audio/unknown', + UniversalFileType.file => 'application/octet-stream', + }, + ).future; + if (cloudFile == null) { + throw ArgumentError('Failed to upload the file...'); + } + cloudAttachments.add(cloudFile); + } + // Send to server - final response = await _apiClient.post( - '/chat/$roomId/messages', + final response = await _apiClient.request( + editingTo == null + ? '/chat/$roomId/messages' + : '/chat/$roomId/messages/${editingTo.id}', data: { 'content': content, - 'attachments_id': attachments, + 'attachments_id': cloudAttachments.map((e) => e.id).toList(), + 'replied_message_id': replyingTo?.id, + 'forwarded_message_id': forwardingTo?.id, 'meta': meta, 'nonce': nonce, }, + options: Options(method: editingTo == null ? 'POST' : 'PATCH'), ); // Update with server response @@ -380,4 +416,33 @@ class MessageRepository { rethrow; } } + + Future getMessageById(String messageId) async { + try { + // Attempt to get the message from the local database + final localMessage = + await (_database.select(_database.chatMessages) + ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); + if (localMessage != null) { + return _database.companionToMessage(localMessage); + } + + // If not found locally, fetch from the server + final response = await _apiClient.get( + '/chat/${room.id}/messages/$messageId', + ); + final remoteMessage = SnChatMessage.fromJson(response.data); + final message = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + // Save the fetched message to the local database + await _database.saveMessage(_database.messageToCompanion(message)); + return message; + } catch (e) { + // Handle errors + rethrow; + } + } } diff --git a/lib/models/chat.dart b/lib/models/chat.dart index 1a0e020..37da82b 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -43,9 +43,7 @@ abstract class SnChatMessage with _$SnChatMessage { @Default([]) List attachments, @Default([]) List reactions, String? repliedMessageId, - SnChatMessage? repliedMessage, String? forwardedMessageId, - SnChatMessage? forwardedMessage, required String senderId, required SnChatMember sender, required int chatRoomId, diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index 1f541d6..ce0355c 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -260,7 +260,7 @@ $SnRealmCopyWith<$Res>? get realm { /// @nodoc mixin _$SnChatMessage { - DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String? get content; String? get nonce; Map get meta; List get membersMetioned; DateTime? get editedAt; List get attachments; List get reactions; String? get repliedMessageId; SnChatMessage? get repliedMessage; String? get forwardedMessageId; SnChatMessage? get forwardedMessage; String get senderId; SnChatMember get sender; int get chatRoomId; + DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String? get content; String? get nonce; Map get meta; List get membersMetioned; DateTime? get editedAt; List get attachments; List get reactions; String? get repliedMessageId; String? get forwardedMessageId; String get senderId; SnChatMember get sender; int get chatRoomId; /// Create a copy of SnChatMessage /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -273,16 +273,16 @@ $SnChatMessageCopyWith get copyWith => _$SnChatMessageCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other.meta, meta)&&const DeepCollectionEquality().equals(other.membersMetioned, membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.repliedMessage, repliedMessage) || other.repliedMessage == repliedMessage)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.forwardedMessage, forwardedMessage) || other.forwardedMessage == forwardedMessage)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other.meta, meta)&&const DeepCollectionEquality().equals(other.membersMetioned, membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,content,nonce,const DeepCollectionEquality().hash(meta),const DeepCollectionEquality().hash(membersMetioned),editedAt,const DeepCollectionEquality().hash(attachments),const DeepCollectionEquality().hash(reactions),repliedMessageId,repliedMessage,forwardedMessageId,forwardedMessage,senderId,sender,chatRoomId); +int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,content,nonce,const DeepCollectionEquality().hash(meta),const DeepCollectionEquality().hash(membersMetioned),editedAt,const DeepCollectionEquality().hash(attachments),const DeepCollectionEquality().hash(reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); @override String toString() { - return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, repliedMessage: $repliedMessage, forwardedMessageId: $forwardedMessageId, forwardedMessage: $forwardedMessage, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; + return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; } @@ -293,11 +293,11 @@ abstract mixin class $SnChatMessageCopyWith<$Res> { factory $SnChatMessageCopyWith(SnChatMessage value, $Res Function(SnChatMessage) _then) = _$SnChatMessageCopyWithImpl; @useResult $Res call({ - DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String? content, String? nonce, Map meta, List membersMetioned, DateTime? editedAt, List attachments, List reactions, String? repliedMessageId, SnChatMessage? repliedMessage, String? forwardedMessageId, SnChatMessage? forwardedMessage, String senderId, SnChatMember sender, int chatRoomId + DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String? content, String? nonce, Map meta, List membersMetioned, DateTime? editedAt, List attachments, List reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, int chatRoomId }); -$SnChatMessageCopyWith<$Res>? get repliedMessage;$SnChatMessageCopyWith<$Res>? get forwardedMessage;$SnChatMemberCopyWith<$Res> get sender; +$SnChatMemberCopyWith<$Res> get sender; } /// @nodoc @@ -310,7 +310,7 @@ class _$SnChatMessageCopyWithImpl<$Res> /// Create a copy of SnChatMessage /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? repliedMessage = freezed,Object? forwardedMessageId = freezed,Object? forwardedMessage = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { return _then(_self.copyWith( createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable @@ -324,10 +324,8 @@ as List,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ign as DateTime?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as List,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable as List,repliedMessageId: freezed == repliedMessageId ? _self.repliedMessageId : repliedMessageId // ignore: cast_nullable_to_non_nullable -as String?,repliedMessage: freezed == repliedMessage ? _self.repliedMessage : repliedMessage // ignore: cast_nullable_to_non_nullable -as SnChatMessage?,forwardedMessageId: freezed == forwardedMessageId ? _self.forwardedMessageId : forwardedMessageId // ignore: cast_nullable_to_non_nullable -as String?,forwardedMessage: freezed == forwardedMessage ? _self.forwardedMessage : forwardedMessage // ignore: cast_nullable_to_non_nullable -as SnChatMessage?,senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable +as String?,forwardedMessageId: freezed == forwardedMessageId ? _self.forwardedMessageId : forwardedMessageId // ignore: cast_nullable_to_non_nullable +as String?,senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable as String,sender: null == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable as SnChatMember,chatRoomId: null == chatRoomId ? _self.chatRoomId : chatRoomId // ignore: cast_nullable_to_non_nullable as int, @@ -337,30 +335,6 @@ as int, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$SnChatMessageCopyWith<$Res>? get repliedMessage { - if (_self.repliedMessage == null) { - return null; - } - - return $SnChatMessageCopyWith<$Res>(_self.repliedMessage!, (value) { - return _then(_self.copyWith(repliedMessage: value)); - }); -}/// Create a copy of SnChatMessage -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnChatMessageCopyWith<$Res>? get forwardedMessage { - if (_self.forwardedMessage == null) { - return null; - } - - return $SnChatMessageCopyWith<$Res>(_self.forwardedMessage!, (value) { - return _then(_self.copyWith(forwardedMessage: value)); - }); -}/// Create a copy of SnChatMessage -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') $SnChatMemberCopyWith<$Res> get sender { return $SnChatMemberCopyWith<$Res>(_self.sender, (value) { @@ -374,7 +348,7 @@ $SnChatMemberCopyWith<$Res> get sender { @JsonSerializable() class _SnChatMessage implements SnChatMessage { - const _SnChatMessage({required this.createdAt, required this.updatedAt, this.deletedAt, required this.id, this.content, this.nonce, final Map meta = const {}, final List membersMetioned = const [], this.editedAt, final List attachments = const [], final List reactions = const [], this.repliedMessageId, this.repliedMessage, this.forwardedMessageId, this.forwardedMessage, required this.senderId, required this.sender, required this.chatRoomId}): _meta = meta,_membersMetioned = membersMetioned,_attachments = attachments,_reactions = reactions; + const _SnChatMessage({required this.createdAt, required this.updatedAt, this.deletedAt, required this.id, this.content, this.nonce, final Map meta = const {}, final List membersMetioned = const [], this.editedAt, final List attachments = const [], final List reactions = const [], this.repliedMessageId, this.forwardedMessageId, required this.senderId, required this.sender, required this.chatRoomId}): _meta = meta,_membersMetioned = membersMetioned,_attachments = attachments,_reactions = reactions; factory _SnChatMessage.fromJson(Map json) => _$SnChatMessageFromJson(json); @override final DateTime createdAt; @@ -413,9 +387,7 @@ class _SnChatMessage implements SnChatMessage { } @override final String? repliedMessageId; -@override final SnChatMessage? repliedMessage; @override final String? forwardedMessageId; -@override final SnChatMessage? forwardedMessage; @override final String senderId; @override final SnChatMember sender; @override final int chatRoomId; @@ -433,16 +405,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other._meta, _meta)&&const DeepCollectionEquality().equals(other._membersMetioned, _membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.repliedMessage, repliedMessage) || other.repliedMessage == repliedMessage)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.forwardedMessage, forwardedMessage) || other.forwardedMessage == forwardedMessage)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMessage&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.content, content) || other.content == content)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&const DeepCollectionEquality().equals(other._meta, _meta)&&const DeepCollectionEquality().equals(other._membersMetioned, _membersMetioned)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.repliedMessageId, repliedMessageId) || other.repliedMessageId == repliedMessageId)&&(identical(other.forwardedMessageId, forwardedMessageId) || other.forwardedMessageId == forwardedMessageId)&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,content,nonce,const DeepCollectionEquality().hash(_meta),const DeepCollectionEquality().hash(_membersMetioned),editedAt,const DeepCollectionEquality().hash(_attachments),const DeepCollectionEquality().hash(_reactions),repliedMessageId,repliedMessage,forwardedMessageId,forwardedMessage,senderId,sender,chatRoomId); +int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,content,nonce,const DeepCollectionEquality().hash(_meta),const DeepCollectionEquality().hash(_membersMetioned),editedAt,const DeepCollectionEquality().hash(_attachments),const DeepCollectionEquality().hash(_reactions),repliedMessageId,forwardedMessageId,senderId,sender,chatRoomId); @override String toString() { - return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, repliedMessage: $repliedMessage, forwardedMessageId: $forwardedMessageId, forwardedMessage: $forwardedMessage, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; + return 'SnChatMessage(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, content: $content, nonce: $nonce, meta: $meta, membersMetioned: $membersMetioned, editedAt: $editedAt, attachments: $attachments, reactions: $reactions, repliedMessageId: $repliedMessageId, forwardedMessageId: $forwardedMessageId, senderId: $senderId, sender: $sender, chatRoomId: $chatRoomId)'; } @@ -453,11 +425,11 @@ abstract mixin class _$SnChatMessageCopyWith<$Res> implements $SnChatMessageCopy factory _$SnChatMessageCopyWith(_SnChatMessage value, $Res Function(_SnChatMessage) _then) = __$SnChatMessageCopyWithImpl; @override @useResult $Res call({ - DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String? content, String? nonce, Map meta, List membersMetioned, DateTime? editedAt, List attachments, List reactions, String? repliedMessageId, SnChatMessage? repliedMessage, String? forwardedMessageId, SnChatMessage? forwardedMessage, String senderId, SnChatMember sender, int chatRoomId + DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String? content, String? nonce, Map meta, List membersMetioned, DateTime? editedAt, List attachments, List reactions, String? repliedMessageId, String? forwardedMessageId, String senderId, SnChatMember sender, int chatRoomId }); -@override $SnChatMessageCopyWith<$Res>? get repliedMessage;@override $SnChatMessageCopyWith<$Res>? get forwardedMessage;@override $SnChatMemberCopyWith<$Res> get sender; +@override $SnChatMemberCopyWith<$Res> get sender; } /// @nodoc @@ -470,7 +442,7 @@ class __$SnChatMessageCopyWithImpl<$Res> /// Create a copy of SnChatMessage /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? repliedMessage = freezed,Object? forwardedMessageId = freezed,Object? forwardedMessage = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? content = freezed,Object? nonce = freezed,Object? meta = null,Object? membersMetioned = null,Object? editedAt = freezed,Object? attachments = null,Object? reactions = null,Object? repliedMessageId = freezed,Object? forwardedMessageId = freezed,Object? senderId = null,Object? sender = null,Object? chatRoomId = null,}) { return _then(_SnChatMessage( createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable @@ -484,10 +456,8 @@ as List,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ign as DateTime?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable as List,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable as List,repliedMessageId: freezed == repliedMessageId ? _self.repliedMessageId : repliedMessageId // ignore: cast_nullable_to_non_nullable -as String?,repliedMessage: freezed == repliedMessage ? _self.repliedMessage : repliedMessage // ignore: cast_nullable_to_non_nullable -as SnChatMessage?,forwardedMessageId: freezed == forwardedMessageId ? _self.forwardedMessageId : forwardedMessageId // ignore: cast_nullable_to_non_nullable -as String?,forwardedMessage: freezed == forwardedMessage ? _self.forwardedMessage : forwardedMessage // ignore: cast_nullable_to_non_nullable -as SnChatMessage?,senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable +as String?,forwardedMessageId: freezed == forwardedMessageId ? _self.forwardedMessageId : forwardedMessageId // ignore: cast_nullable_to_non_nullable +as String?,senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable as String,sender: null == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable as SnChatMember,chatRoomId: null == chatRoomId ? _self.chatRoomId : chatRoomId // ignore: cast_nullable_to_non_nullable as int, @@ -498,30 +468,6 @@ as int, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$SnChatMessageCopyWith<$Res>? get repliedMessage { - if (_self.repliedMessage == null) { - return null; - } - - return $SnChatMessageCopyWith<$Res>(_self.repliedMessage!, (value) { - return _then(_self.copyWith(repliedMessage: value)); - }); -}/// Create a copy of SnChatMessage -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnChatMessageCopyWith<$Res>? get forwardedMessage { - if (_self.forwardedMessage == null) { - return null; - } - - return $SnChatMessageCopyWith<$Res>(_self.forwardedMessage!, (value) { - return _then(_self.copyWith(forwardedMessage: value)); - }); -}/// Create a copy of SnChatMessage -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') $SnChatMemberCopyWith<$Res> get sender { return $SnChatMemberCopyWith<$Res>(_self.sender, (value) { diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index e51f72a..55b72d1 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -84,19 +84,7 @@ _SnChatMessage _$SnChatMessageFromJson(Map json) => .toList() ?? const [], repliedMessageId: json['replied_message_id'] as String?, - repliedMessage: - json['replied_message'] == null - ? null - : SnChatMessage.fromJson( - json['replied_message'] as Map, - ), forwardedMessageId: json['forwarded_message_id'] as String?, - forwardedMessage: - json['forwarded_message'] == null - ? null - : SnChatMessage.fromJson( - json['forwarded_message'] as Map, - ), senderId: json['sender_id'] as String, sender: SnChatMember.fromJson(json['sender'] as Map), chatRoomId: (json['chat_room_id'] as num).toInt(), @@ -116,9 +104,7 @@ Map _$SnChatMessageToJson(_SnChatMessage instance) => 'attachments': instance.attachments.map((e) => e.toJson()).toList(), 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'replied_message_id': instance.repliedMessageId, - 'replied_message': instance.repliedMessage?.toJson(), 'forwarded_message_id': instance.forwardedMessageId, - 'forwarded_message': instance.forwardedMessage?.toJson(), 'sender_id': instance.senderId, 'sender': instance.sender.toJson(), 'chat_room_id': instance.chatRoomId, diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 5bfb77e..dbea7d1 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -9,6 +9,7 @@ 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/config.dart'; import 'package:island/pods/message.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/websocket.dart'; @@ -17,6 +18,7 @@ import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:super_context_menu/super_context_menu.dart'; import 'package:uuid/uuid.dart'; import 'chat.dart'; @@ -94,7 +96,13 @@ class MessagesNotifier } } - Future sendMessage(String content) async { + Future sendMessage( + String content, + List attachments, { + SnChatMessage? replyingTo, + SnChatMessage? forwardingTo, + SnChatMessage? editingTo, + }) async { try { final repository = await _ref.read( messageRepositoryProvider(_roomId).future, @@ -102,7 +110,28 @@ class MessagesNotifier final nonce = const Uuid().v4(); - final messageTask = repository.sendMessage(_roomId, content, nonce); + final baseUrl = _ref.read(serverUrlProvider); + final atk = await getFreshAtk( + _ref.watch(tokenPairProvider), + baseUrl, + onRefreshed: (atk, rtk) { + setTokenPair(_ref.watch(sharedPreferencesProvider), atk, rtk); + _ref.invalidate(tokenPairProvider); + }, + ); + if (atk == null) throw Exception("Unauthorized"); + + final messageTask = repository.sendMessage( + atk, + baseUrl, + _roomId, + content, + nonce, + attachments: attachments, + replyingTo: replyingTo, + forwardingTo: forwardingTo, + editingTo: editingTo, + ); final pendingMessage = repository.pendingMessages.values.firstWhereOrNull( (m) => m.roomId == _roomId && m.nonce == nonce, ); @@ -288,6 +317,18 @@ class MessagesNotifier showErrorAlert(err); } } + + Future fetchMessageById(String messageId) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + return await repository.getMessageById(messageId); + } catch (err) { + showErrorAlert(err); + return null; + } + } } @RoutePage() @@ -306,6 +347,10 @@ class ChatRoomScreen extends HookConsumerWidget { final messageController = useTextEditingController(); final scrollController = useScrollController(); + final messageReplyingTo = useState(null); + final messageForwardingTo = useState(null); + final messageEditingTo = useState(null); + // Add scroll listener for pagination useEffect(() { void onScroll() { @@ -340,9 +385,17 @@ class ChatRoomScreen extends HookConsumerWidget { return () => subscription.cancel(); }, [ws, chatRoom]); + final attachments = useState>([]); + void sendMessage() { if (messageController.text.trim().isNotEmpty) { - messagesNotifier.sendMessage(messageController.text.trim()); + messagesNotifier.sendMessage( + messageController.text.trim(), + attachments.value, + editingTo: messageEditingTo.value, + forwardingTo: messageForwardingTo.value, + replyingTo: messageReplyingTo.value, + ); messageController.clear(); } } @@ -410,11 +463,29 @@ class ChatRoomScreen extends HookConsumerWidget { message: message, isCurrentUser: identity?.id == message.senderId, + onAction: (action) { + switch (action) { + case _MessageBubbleAction.delete: + messagesNotifier.deleteMessage( + message.id, + ); + case _MessageBubbleAction.edit: + messageEditingTo.value = + message.toRemoteMessage(); + case _MessageBubbleAction.forward: + messageForwardingTo.value = + message.toRemoteMessage(); + case _MessageBubbleAction.reply: + messageReplyingTo.value = + message.toRemoteMessage(); + } + }, ), loading: () => _MessageBubble( message: message, isCurrentUser: false, + onAction: null, ), error: (_, __) => const SizedBox.shrink(), ); @@ -442,6 +513,17 @@ class ChatRoomScreen extends HookConsumerWidget { messageController: messageController, chatRoom: room!, onSend: sendMessage, + onClear: () { + if (messageEditingTo.value != null) { + messageController.clear(); + } + messageEditingTo.value = null; + messageReplyingTo.value = null; + messageForwardingTo.value = null; + }, + messageEditingTo: messageEditingTo.value, + messageReplyingTo: messageReplyingTo.value, + messageForwardingTo: messageForwardingTo.value, ), error: (_, __) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), @@ -456,11 +538,19 @@ class _ChatInput extends StatelessWidget { final TextEditingController messageController; final SnChat chatRoom; final VoidCallback onSend; + final VoidCallback onClear; + final SnChatMessage? messageReplyingTo; + final SnChatMessage? messageForwardingTo; + final SnChatMessage? messageEditingTo; const _ChatInput({ required this.messageController, required this.chatRoom, required this.onSend, + required this.onClear, + required this.messageReplyingTo, + required this.messageForwardingTo, + required this.messageEditingTo, }); @override @@ -468,109 +558,238 @@ class _ChatInput extends StatelessWidget { 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, + child: Column( + children: [ + if (messageReplyingTo != null || + messageForwardingTo != null || + messageEditingTo != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + ), + margin: const EdgeInsets.only(left: 8, right: 8, top: 8), + child: Row( + children: [ + Icon( + messageReplyingTo != null + ? Symbols.reply + : messageForwardingTo != null + ? Symbols.forward + : Symbols.edit, + size: 20, + color: Theme.of(context).colorScheme.primary, ), - ), - maxLines: null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - onSubmitted: (_) => onSend(), + const Gap(8), + Expanded( + child: Text( + messageReplyingTo != null + ? 'Replying to ${messageReplyingTo?.sender.account.nick}' + : messageForwardingTo != null + ? 'Forwarding message' + : 'Editing message', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onClear, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), ), - IconButton( - icon: const Icon(Icons.send), - color: Theme.of(context).colorScheme.primary, - onPressed: onSend, - ), - ], - ).padding(bottom: MediaQuery.of(context).padding.bottom), + 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 { +class _MessageBubbleAction { + static const String edit = "edit"; + static const String delete = "delete"; + static const String reply = "reply"; + static const String forward = "forward"; +} + +class _MessageBubble extends HookConsumerWidget { final LocalChatMessage message; final bool isCurrentUser; + final Function(String action)? onAction; - const _MessageBubble({required this.message, required this.isCurrentUser}); + const _MessageBubble({ + required this.message, + required this.isCurrentUser, + required this.onAction, + }); @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - mainAxisAlignment: - isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [ - if (!isCurrentUser) - ProfilePictureWidget( - fileId: - message.toRemoteMessage().sender.account.profile.pictureId, - radius: 18, - ), - const Gap(8), - Flexible( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: - isCurrentUser - ? Theme.of(context).colorScheme.primary.withOpacity(0.8) - : Colors.grey.shade200, - borderRadius: BorderRadius.circular(16), + Widget build(BuildContext context, WidgetRef ref) { + final messagesNotifier = ref.watch( + messagesProvider(message.roomId).notifier, + ); + + final textColor = isCurrentUser ? Colors.white : Colors.black; + + return ContextMenuWidget( + menuProvider: (_) { + if (onAction == null) return Menu(children: []); + return Menu( + children: [ + if (isCurrentUser) + MenuAction( + title: 'edit'.tr(), + image: MenuImage.icon(Symbols.edit), + callback: () { + onAction!.call(_MessageBubbleAction.edit); + }, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - message.toRemoteMessage().content ?? '', - style: TextStyle( - color: isCurrentUser ? Colors.white : Colors.black, - ), + if (isCurrentUser) + MenuAction( + title: 'delete'.tr(), + image: MenuImage.icon(Symbols.delete), + callback: () { + onAction!.call(_MessageBubbleAction.delete); + }, + ), + if (isCurrentUser) MenuSeparator(), + MenuAction( + title: 'reply'.tr(), + image: MenuImage.icon(Symbols.reply), + callback: () { + onAction!.call(_MessageBubbleAction.reply); + }, + ), + MenuAction( + title: 'forward'.tr(), + image: MenuImage.icon(Symbols.forward), + callback: () { + onAction!.call(_MessageBubbleAction.forward); + }, + ), + ], + ); + }, + child: Material( + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisAlignment: + isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!isCurrentUser) + ProfilePictureWidget( + fileId: + message + .toRemoteMessage() + .sender + .account + .profile + .pictureId, + radius: 18, + ), + const Gap(8), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - const Gap(4), - Row( - mainAxisSize: MainAxisSize.min, + decoration: BoxDecoration( + color: + isCurrentUser + ? Theme.of( + context, + ).colorScheme.primary.withOpacity(0.8) + : Colors.grey.shade200, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - DateFormat.Hm().format(message.createdAt.toLocal()), - style: TextStyle( - fontSize: 10, - color: - isCurrentUser ? Colors.white70 : Colors.black54, + if (message.toRemoteMessage().repliedMessageId != null) + _MessageQuoteWidget( + message: message, + textColor: textColor, + isReply: true, ), + if (message.toRemoteMessage().forwardedMessageId != null) + _MessageQuoteWidget( + message: message, + textColor: textColor, + isReply: false, + ), + Text( + message.toRemoteMessage().content ?? '', + style: TextStyle(color: textColor), ), const Gap(4), - if (isCurrentUser) - _buildStatusIcon(context, message.status), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateFormat.Hm().format(message.createdAt.toLocal()), + style: TextStyle(fontSize: 10, color: textColor), + ), + const Gap(4), + if (isCurrentUser) + _buildStatusIcon(context, message.status), + ], + ), ], ), - ], + ), ), - ), + const Gap(8), + if (isCurrentUser) + ProfilePictureWidget( + fileId: + message + .toRemoteMessage() + .sender + .account + .profile + .pictureId, + radius: 18, + ), + ], ), - const Gap(8), - if (isCurrentUser) - ProfilePictureWidget( - fileId: - message.toRemoteMessage().sender.account.profile.pictureId, - radius: 18, - ), - ], + ), ), ); } @@ -600,3 +819,73 @@ class _MessageBubble extends StatelessWidget { } } } + +class _MessageQuoteWidget extends HookConsumerWidget { + final LocalChatMessage message; + final Color textColor; + final bool isReply; + + const _MessageQuoteWidget({ + Key? key, + required this.message, + required this.textColor, + required this.isReply, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messagesNotifier = ref.watch( + messagesProvider(message.roomId).notifier, + ); + + return FutureBuilder( + future: messagesNotifier.fetchMessageById( + isReply + ? message.toRemoteMessage().repliedMessageId! + : message.toRemoteMessage().forwardedMessageId!, + ), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Container( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6), + color: Theme.of(context).colorScheme.surface.withOpacity(0.2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isReply) + Row( + spacing: 4, + children: [ + Icon(Symbols.reply, size: 16, color: textColor), + Text( + 'Replying to ${snapshot.data!.toRemoteMessage().sender.account.nick}', + ).textColor(textColor).bold(), + ], + ) + else + Row( + spacing: 4, + children: [ + Icon(Symbols.forward, size: 16, color: textColor), + Text( + 'Forwarded from ${snapshot.data!.toRemoteMessage().sender.account.nick}', + ).textColor(textColor).bold(), + ], + ), + Text( + snapshot.data!.toRemoteMessage().content ?? "", + style: TextStyle(color: textColor), + ), + ], + ), + ), + ).padding(bottom: 4); + } else { + return SizedBox.shrink(); + } + }, + ); + } +}