Reply / forward mesage

This commit is contained in:
LittleSheep 2025-05-04 01:18:26 +08:00
parent 7edfd56bf7
commit 6fd61b547e
6 changed files with 462 additions and 176 deletions

View File

@ -87,5 +87,7 @@
},
"permissionOwner": "Owner",
"permissionModerator": "Moderator",
"permissionMember": "Member"
"permissionMember": "Member",
"reply": "Reply",
"forward": "Forward"
}

View File

@ -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<LocalChatMessage> sendMessage(
String atk,
String baseUrl,
int roomId,
String content,
String nonce, {
List<SnCloudFile>? attachments,
required List<UniversalFile> attachments,
Map<String, dynamic>? 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<LocalChatMessage?> 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;
}
}
}

View File

@ -43,9 +43,7 @@ abstract class SnChatMessage with _$SnChatMessage {
@Default([]) List<SnCloudFile> attachments,
@Default([]) List<SnChatReaction> reactions,
String? repliedMessageId,
SnChatMessage? repliedMessage,
String? forwardedMessageId,
SnChatMessage? forwardedMessage,
required String senderId,
required SnChatMember sender,
required int chatRoomId,

View File

@ -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<String, dynamic> get meta; List<String> get membersMetioned; DateTime? get editedAt; List<SnCloudFile> get attachments; List<SnChatReaction> 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<String, dynamic> get meta; List<String> get membersMetioned; DateTime? get editedAt; List<SnCloudFile> get attachments; List<SnChatReaction> 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<SnChatMessage> 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<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> 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<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> 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<String>,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ign
as DateTime?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<SnCloudFile>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable
as List<SnChatReaction>,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<String, dynamic> meta = const {}, final List<String> membersMetioned = const [], this.editedAt, final List<SnCloudFile> attachments = const [], final List<SnChatReaction> 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<String, dynamic> meta = const {}, final List<String> membersMetioned = const [], this.editedAt, final List<SnCloudFile> attachments = const [], final List<SnChatReaction> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> 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<String, dynamic> meta, List<String> membersMetioned, DateTime? editedAt, List<SnCloudFile> attachments, List<SnChatReaction> 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<String>,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ign
as DateTime?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<SnCloudFile>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable
as List<SnChatReaction>,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) {

View File

@ -84,19 +84,7 @@ _SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
.toList() ??
const [],
repliedMessageId: json['replied_message_id'] as String?,
repliedMessage:
json['replied_message'] == null
? null
: SnChatMessage.fromJson(
json['replied_message'] as Map<String, dynamic>,
),
forwardedMessageId: json['forwarded_message_id'] as String?,
forwardedMessage:
json['forwarded_message'] == null
? null
: SnChatMessage.fromJson(
json['forwarded_message'] as Map<String, dynamic>,
),
senderId: json['sender_id'] as String,
sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>),
chatRoomId: (json['chat_room_id'] as num).toInt(),
@ -116,9 +104,7 @@ Map<String, dynamic> _$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,

View File

@ -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<void> sendMessage(String content) async {
Future<void> sendMessage(
String content,
List<UniversalFile> 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<LocalChatMessage?> 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<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(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<List<UniversalFile>>([]);
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,7 +558,52 @@ class _ChatInput extends StatelessWidget {
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
child: Padding(
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,
),
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(),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Row(
children: [
@ -498,19 +633,80 @@ class _ChatInput extends StatelessWidget {
],
).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(
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);
},
),
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:
@ -519,28 +715,48 @@ class _MessageBubble extends StatelessWidget {
if (!isCurrentUser)
ProfilePictureWidget(
fileId:
message.toRemoteMessage().sender.account.profile.pictureId,
message
.toRemoteMessage()
.sender
.account
.profile
.pictureId,
radius: 18,
),
const Gap(8),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color:
isCurrentUser
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.8)
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: isCurrentUser ? Colors.white : Colors.black,
),
style: TextStyle(color: textColor),
),
const Gap(4),
Row(
@ -548,11 +764,7 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
DateFormat.Hm().format(message.createdAt.toLocal()),
style: TextStyle(
fontSize: 10,
color:
isCurrentUser ? Colors.white70 : Colors.black54,
),
style: TextStyle(fontSize: 10, color: textColor),
),
const Gap(4),
if (isCurrentUser)
@ -567,11 +779,18 @@ class _MessageBubble extends StatelessWidget {
if (isCurrentUser)
ProfilePictureWidget(
fileId:
message.toRemoteMessage().sender.account.profile.pictureId,
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<LocalChatMessage?>(
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();
}
},
);
}
}