Chat quote and reply

This commit is contained in:
LittleSheep 2024-11-18 22:33:03 +08:00
parent 9f7a3082cb
commit 5032cccf38
9 changed files with 279 additions and 116 deletions

View File

@ -119,12 +119,20 @@ class ChatMessageController extends ChangeNotifier {
}
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.body['quote_event'] != null) {
quoteEvent = await getMessage(message.body['quote_event'] as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(attachments: attachments),
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
messages.insert(0, message);
@ -133,12 +141,20 @@ class ChatMessageController extends ChangeNotifier {
}
Future<void> _addMessage(SnChatMessage message) async {
SnChatMessage? quoteEvent;
if (message.body['quote_event'] != null) {
quoteEvent = await getMessage(message.body['quote_event'] as int);
}
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(attachments: attachments),
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments,
),
);
final idx = messages.indexWhere((e) => e.uuid == message.uuid);
@ -199,8 +215,8 @@ class ChatMessageController extends ChangeNotifier {
final body = {
'text': content,
'algorithm': 'plain',
if (quoteId != null) 'quote_id': quoteId,
if (relatedId != null) 'related_id': relatedId,
if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'quote_event': relatedId,
if (attachments != null && attachments.isNotEmpty)
'attachments': attachments,
};
@ -269,6 +285,42 @@ class ChatMessageController extends ChangeNotifier {
}
}
/// Get a single event from the current channel
/// If it was not found in local storage we will look it up in remote
Future<SnChatMessage?> getMessage(int id) async {
SnChatMessage? out;
if (_box != null && _box!.containsKey(id)) {
out = _box!.get(id);
}
if (out == null) {
try {
final resp = await _sn.client
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
out = SnChatMessage.fromJson(resp.data);
_saveMessageToLocal([out]);
} catch (_) {
// ignore, maybe not found
}
}
// Preload some related things if found
if (out != null) {
await _ud.listAccount([out.sender.accountId]);
final attachments = await _attach.getMultiple(
out.body['attachments']?.cast<String>() ?? [],
);
out = out.copyWith(
preload: SnChatMessagePreload(
attachments: attachments,
),
);
}
return out;
}
/// Get message from local storage first, then from the server.
/// Will not check local storage is up to date with the server.
/// If you need to do the sync, do the `checkUpdate` instead.
@ -300,9 +352,18 @@ class ChatMessageController extends ChangeNotifier {
out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
);
final attachments = await _attach.getMultiple(attachmentRid);
// Putting preload back to data
for (var i = 0; i < out.length; i++) {
// Preload related events (quoted)
SnChatMessage? quoteEvent;
if (out[i].body['quote_event'] != null) {
quoteEvent = await getMessage(out[i].body['quote_event'] as int);
}
out[i] = out[i].copyWith(
preload: SnChatMessagePreload(
quoteEvent: quoteEvent,
attachments: attachments
.where(
(ele) =>

View File

@ -25,6 +25,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
SnChannel? _channel;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
Future<void> _fetchChannel() async {
@ -117,6 +118,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
hasMerged: canMergePrevious,
isPending: _messageController.unconfirmedMessages
.contains(message.uuid),
onReply: () {
_inputGlobalKey.currentState?.setReply(message);
},
);
},
),
@ -124,8 +128,10 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
if (!_messageController.isPending)
Material(
elevation: 2,
child: ChatMessageInput(controller: _messageController)
.padding(bottom: MediaQuery.of(context).padding.bottom),
child: ChatMessageInput(
key: _inputGlobalKey,
controller: _messageController,
).padding(bottom: MediaQuery.of(context).padding.bottom),
),
],
);

View File

@ -91,9 +91,9 @@ class SnChatMessage with _$SnChatMessage {
class SnChatMessagePreload with _$SnChatMessagePreload {
const SnChatMessagePreload._();
@HiveType(typeId: 5)
const factory SnChatMessagePreload({
@HiveField(0) List<SnAttachment?>? attachments,
List<SnAttachment?>? attachments,
SnChatMessage? quoteEvent,
}) = _SnChatMessagePreload;
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>

View File

@ -1540,8 +1540,8 @@ SnChatMessagePreload _$SnChatMessagePreloadFromJson(Map<String, dynamic> json) {
/// @nodoc
mixin _$SnChatMessagePreload {
@HiveField(0)
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
SnChatMessage? get quoteEvent => throw _privateConstructorUsedError;
/// Serializes this SnChatMessagePreload to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -1559,7 +1559,9 @@ abstract class $SnChatMessagePreloadCopyWith<$Res> {
$Res Function(SnChatMessagePreload) then) =
_$SnChatMessagePreloadCopyWithImpl<$Res, SnChatMessagePreload>;
@useResult
$Res call({@HiveField(0) List<SnAttachment?>? attachments});
$Res call({List<SnAttachment?>? attachments, SnChatMessage? quoteEvent});
$SnChatMessageCopyWith<$Res>? get quoteEvent;
}
/// @nodoc
@ -1579,14 +1581,33 @@ class _$SnChatMessagePreloadCopyWithImpl<$Res,
@override
$Res call({
Object? attachments = freezed,
Object? quoteEvent = freezed,
}) {
return _then(_value.copyWith(
attachments: freezed == attachments
? _value.attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment?>?,
quoteEvent: freezed == quoteEvent
? _value.quoteEvent
: quoteEvent // ignore: cast_nullable_to_non_nullable
as SnChatMessage?,
) as $Val);
}
/// Create a copy of SnChatMessagePreload
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnChatMessageCopyWith<$Res>? get quoteEvent {
if (_value.quoteEvent == null) {
return null;
}
return $SnChatMessageCopyWith<$Res>(_value.quoteEvent!, (value) {
return _then(_value.copyWith(quoteEvent: value) as $Val);
});
}
}
/// @nodoc
@ -1597,7 +1618,10 @@ abstract class _$$SnChatMessagePreloadImplCopyWith<$Res>
__$$SnChatMessagePreloadImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({@HiveField(0) List<SnAttachment?>? attachments});
$Res call({List<SnAttachment?>? attachments, SnChatMessage? quoteEvent});
@override
$SnChatMessageCopyWith<$Res>? get quoteEvent;
}
/// @nodoc
@ -1614,22 +1638,26 @@ class __$$SnChatMessagePreloadImplCopyWithImpl<$Res>
@override
$Res call({
Object? attachments = freezed,
Object? quoteEvent = freezed,
}) {
return _then(_$SnChatMessagePreloadImpl(
attachments: freezed == attachments
? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment?>?,
quoteEvent: freezed == quoteEvent
? _value.quoteEvent
: quoteEvent // ignore: cast_nullable_to_non_nullable
as SnChatMessage?,
));
}
}
/// @nodoc
@JsonSerializable()
@HiveType(typeId: 5)
class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
const _$SnChatMessagePreloadImpl(
{@HiveField(0) final List<SnAttachment?>? attachments})
{final List<SnAttachment?>? attachments, this.quoteEvent})
: _attachments = attachments,
super._();
@ -1638,7 +1666,6 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
final List<SnAttachment?>? _attachments;
@override
@HiveField(0)
List<SnAttachment?>? get attachments {
final value = _attachments;
if (value == null) return null;
@ -1647,9 +1674,12 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
return EqualUnmodifiableListView(value);
}
@override
final SnChatMessage? quoteEvent;
@override
String toString() {
return 'SnChatMessagePreload(attachments: $attachments)';
return 'SnChatMessagePreload(attachments: $attachments, quoteEvent: $quoteEvent)';
}
@override
@ -1658,13 +1688,15 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
(other.runtimeType == runtimeType &&
other is _$SnChatMessagePreloadImpl &&
const DeepCollectionEquality()
.equals(other._attachments, _attachments));
.equals(other._attachments, _attachments) &&
(identical(other.quoteEvent, quoteEvent) ||
other.quoteEvent == quoteEvent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, const DeepCollectionEquality().hash(_attachments));
int get hashCode => Object.hash(runtimeType,
const DeepCollectionEquality().hash(_attachments), quoteEvent);
/// Create a copy of SnChatMessagePreload
/// with the given fields replaced by the non-null parameter values.
@ -1686,16 +1718,17 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
abstract class _SnChatMessagePreload extends SnChatMessagePreload {
const factory _SnChatMessagePreload(
{@HiveField(0) final List<SnAttachment?>? attachments}) =
_$SnChatMessagePreloadImpl;
{final List<SnAttachment?>? attachments,
final SnChatMessage? quoteEvent}) = _$SnChatMessagePreloadImpl;
const _SnChatMessagePreload._() : super._();
factory _SnChatMessagePreload.fromJson(Map<String, dynamic> json) =
_$SnChatMessagePreloadImpl.fromJson;
@override
@HiveField(0)
List<SnAttachment?>? get attachments;
@override
SnChatMessage? get quoteEvent;
/// Create a copy of SnChatMessagePreload
/// with the given fields replaced by the non-null parameter values.

View File

@ -204,41 +204,6 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> {
typeId == other.typeId;
}
class SnChatMessagePreloadImplAdapter
extends TypeAdapter<_$SnChatMessagePreloadImpl> {
@override
final int typeId = 5;
@override
_$SnChatMessagePreloadImpl read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnChatMessagePreloadImpl(
attachments: (fields[0] as List?)?.cast<SnAttachment?>(),
);
}
@override
void write(BinaryWriter writer, _$SnChatMessagePreloadImpl obj) {
writer
..writeByte(1)
..writeByte(0)
..write(obj.attachments);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SnChatMessagePreloadImplAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
@ -374,10 +339,14 @@ _$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
? null
: SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(),
quoteEvent: json['quote_event'] == null
? null
: SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
_$SnChatMessagePreloadImpl instance) =>
<String, dynamic>{
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'quote_event': instance.quoteEvent?.toJson(),
};

View File

@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/user_directory.dart';
@ -8,18 +9,23 @@ import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:swipe_to/swipe_to.dart';
class ChatMessage extends StatelessWidget {
final SnChatMessage data;
final bool isCompact;
final bool isMerged;
final bool hasMerged;
final bool isPending;
final Function()? onReply;
const ChatMessage({
super.key,
required this.data,
this.isCompact = false,
this.isMerged = false,
this.hasMerged = false,
this.isPending = false,
this.onReply,
});
@override
@ -29,17 +35,22 @@ class ChatMessage extends StatelessWidget {
final dateFormatter = DateFormat('MM/dd HH:mm');
return Column(
return SwipeTo(
key: Key('chat-message-${data.id}'),
iconOnLeftSwipe: Symbols.reply,
swipeSensitivity: 20,
onLeftSwipe: onReply != null ? (_) => onReply!() : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMerged)
if (!isMerged && !isCompact)
AccountImage(
content: user?.avatar,
)
else
else if (isMerged)
const Gap(40),
const Gap(8),
Expanded(
@ -51,6 +62,11 @@ class ChatMessage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
if (isCompact)
AccountImage(
content: user?.avatar,
radius: 12,
).padding(right: 6),
Text(
(data.sender.nick?.isNotEmpty ?? false)
? data.sender.nick!
@ -62,6 +78,28 @@ class ChatMessage extends StatelessWidget {
).fontSize(13),
],
),
if (isCompact) const Gap(4),
if (data.preload?.quoteEvent != null)
StyledWidget(Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
padding: const EdgeInsets.only(
left: 4,
right: 4,
top: 8,
bottom: 6,
),
child: ChatMessage(
data: data.preload!.quoteEvent!,
isCompact: true,
),
)).padding(bottom: 4, top: isMerged ? 4 : 2),
if (data.body['text'] != null)
MarkdownTextContent(
content: data.body['text'],
@ -80,8 +118,9 @@ class ChatMessage extends StatelessWidget {
maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8),
),
if (!hasMerged) const Gap(8),
if (!hasMerged && !isCompact) const Gap(12),
],
),
);
}
}

View File

@ -8,7 +8,9 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart';
class ChatMessageInput extends StatefulWidget {
@ -16,16 +18,22 @@ class ChatMessageInput extends StatefulWidget {
const ChatMessageInput({super.key, required this.controller});
@override
State<ChatMessageInput> createState() => _ChatMessageInputState();
State<ChatMessageInput> createState() => ChatMessageInputState();
}
class _ChatMessageInputState extends State<ChatMessageInput> {
class ChatMessageInputState extends State<ChatMessageInput> {
bool _isBusy = false;
double? _progress;
SnChatMessage? _replyingMessage;
final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode();
void setReply(SnChatMessage? value) {
setState(() => _replyingMessage = value);
}
Future<void> _sendMessage() async {
if (_isBusy) return;
@ -69,6 +77,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
attach.putCache(
_attachments.where((e) => e.attachment != null).map((e) => e.attachment!),
noCheck: true,
);
// Send the message
@ -80,9 +89,11 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
quoteId: _replyingMessage?.id,
);
_contentController.clear();
_attachments.clear();
_replyingMessage = null;
setState(() => _isBusy = false);
}
@ -134,10 +145,45 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
setState(() => _attachments.removeAt(idx));
},
onUpdateBusy: (state) => setState(() => _isBusy = state),
).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.fastEaseInToSlowEaseOut),
),
).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
padding: _replyingMessage != null
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: _replyingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.reply),
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_replyingMessage?.body['text'] != null)
MarkdownTextContent(
content: _replyingMessage?.body['text'],
),
],
),
),
actions: [
TextButton(
child: Text('cancel'.tr()),
onPressed: () {
setState(() => _replyingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
).height(_replyingMessage != null ? 54 + 8 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SizedBox(
height: 56,
child: Row(

View File

@ -516,10 +516,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: ee5c9bd2b74ea8676442fd4ab876b5d41681df49276488854d6c81a5377c0ef1
sha256: "1152ab0067ca5a2ebeb862fe0a762057202cceb22b7e62692dcbabf6483891bb"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.3"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -1343,6 +1343,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.1"
swipe_to:
dependency: "direct main"
description:
name: swipe_to
sha256: "58f61031803ece9b0efe09006809e78904c640c6d42d48715d1d1c3c28f8499a"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
synchronized:
dependency: transitive
description:

View File

@ -78,6 +78,7 @@ dependencies:
isar_flutter_libs: ^3.1.0+1
hive: ^2.2.3
hive_flutter: ^1.1.0
swipe_to: ^1.0.6
dev_dependencies:
flutter_test: