Skip to content

Commit

Permalink
Merge pull request #2529 from gtalha07/chat_ng-message-actions
Browse files Browse the repository at this point in the history
Chat-NG: Message Actions
  • Loading branch information
gtalha07 authored Feb 5, 2025
2 parents 3a5aa68 + 804afd5 commit 509e976
Show file tree
Hide file tree
Showing 28 changed files with 1,145 additions and 124 deletions.
3 changes: 3 additions & 0 deletions .changes/2529-chat_ng-message-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[Labs] Chat-NG:

- [Feature] Now you can do message actions on message .i.e. edit/reply/copy/delete/report.
9 changes: 9 additions & 0 deletions app/lib/common/providers/chat_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,12 @@ final selectedChatIdProvider =
NotifierProvider<SelectedChatIdNotifier, String?>(
() => SelectedChatIdNotifier(),
);

final chatComposerDraftProvider = FutureProvider.autoDispose
.family<ComposeDraft?, String>((ref, roomId) async {
final chat = await ref.watch(chatProvider(roomId).future);
if (chat == null) {
return null;
}
return (await chat.msgDraft().then((val) => val.draft()));
});
21 changes: 20 additions & 1 deletion app/lib/common/widgets/html_editor/html_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ extension ActerEditorStateHelpers on EditorState {
String intoMarkdown({AppFlowyEditorMarkdownCodec? codec}) {
return (codec ?? defaultMarkdownCodec).encode(document);
}

/// clear the editor text with selection
void clear() async {
if (!document.isEmpty) {
final transaction = this.transaction;
final selection = this.selection;
final node = transaction.document.root.children.last;
transaction.deleteNode(node);
transaction.insertNode([0], paragraphNode(text: ''));

updateSelectionWithReason(
selection,
reason: SelectionUpdateReason.transaction,
);
apply(transaction);
}
}
}

extension ActerDocumentHelpers on Document {
Expand Down Expand Up @@ -80,7 +97,7 @@ extension ActerDocumentHelpers on Document {
}) {
if (htmlContent != null) {
final document = ActerDocumentHelpers._fromHtml(htmlContent);
if (document != null) {
if (document != null && !document.isEmpty) {
return document;
}
}
Expand Down Expand Up @@ -206,6 +223,7 @@ class HtmlEditorState extends State<HtmlEditor> {
void _triggerExport(ExportCallback exportFn) {
final plain = editorState.intoMarkdown();
final htmlBody = editorState.intoHtml();

exportFn(plain, htmlBody != plain ? htmlBody : null);
}

Expand Down Expand Up @@ -378,6 +396,7 @@ class HtmlEditorState extends State<HtmlEditor> {
...standardCharacterShortcutEvents,
if (roomId != null) ...mentionShortcuts(context, roomId),
],
disableAutoScroll: true,
),
),
),
Expand Down
9 changes: 0 additions & 9 deletions app/lib/features/chat/providers/chat_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,6 @@ final chatStateProvider =
(ref, roomId) => ChatRoomNotifier(ref: ref, roomId: roomId),
);

final chatComposerDraftProvider = FutureProvider.autoDispose
.family<ComposeDraft?, String>((ref, roomId) async {
final chat = await ref.watch(chatProvider(roomId).future);
if (chat == null) {
return null;
}
return (await chat.msgDraft().then((val) => val.draft()));
});

final chatTopic =
FutureProvider.autoDispose.family<String?, String>((ref, roomId) async {
final c = await ref.watch(chatProvider(roomId).future);
Expand Down
18 changes: 18 additions & 0 deletions app/lib/features/chat_ng/actions/copy_message_action.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

Future<void> copyMessageAction(
BuildContext context,
String body,
) async {
String msg = body.trim();
await Clipboard.setData(
ClipboardData(text: msg),
);
if (context.mounted) {
EasyLoading.showToast(L10n.of(context).messageCopiedToClipboard);
Navigator.pop(context);
}
}
65 changes: 65 additions & 0 deletions app/lib/features/chat_ng/actions/redact_message_action.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'package:acter/common/providers/chat_providers.dart';
import 'package:acter/common/toolkit/buttons/primary_action_button.dart';
import 'package:acter/common/widgets/default_dialog.dart';
import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'
show RoomEventItem;
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:logging/logging.dart';

final _log = Logger('a3::chat::message_actions_redact');

Future<void> redactMessageAction(
BuildContext context,
WidgetRef ref,
RoomEventItem item,
String messageId,
String roomId,
) async {
final chatEditorNotifier = ref.watch(chatEditorStateProvider.notifier);
final lang = L10n.of(context);
final senderId = item.sender();
// pop message action options
Navigator.pop(context);
await showAdaptiveDialog(
context: context,
builder: (context) => DefaultDialog(
title: Text(lang.areYouSureYouWantToDeleteThisMessage),
actions: <Widget>[
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: Text(lang.no),
),
ActerPrimaryActionButton(
onPressed: () async {
try {
final convo = await ref.read(chatProvider(roomId).future);
await convo?.redactMessage(
messageId,
senderId,
null,
null,
);
chatEditorNotifier.unsetActions();
if (context.mounted) {
Navigator.pop(context);
}
} catch (e, s) {
_log.severe('Redacting message failed', e, s);
if (!context.mounted) return;
EasyLoading.showError(
lang.redactionFailed(e),
duration: const Duration(seconds: 3),
);
Navigator.pop(context);
}
},
child: Text(lang.yes),
),
],
),
);
}
25 changes: 25 additions & 0 deletions app/lib/features/chat_ng/actions/report_message_action.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:acter/common/actions/report_content.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'
show RoomEventItem;
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

Future<void> reportMessageAction(
BuildContext context,
RoomEventItem item,
String messageId,
String roomId,
) async {
final lang = L10n.of(context);
final senderId = item.sender();
// pop message action options
Navigator.pop(context);
await openReportContentDialog(
context,
title: lang.reportThisMessage,
description: lang.reportMessageContent,
senderId: senderId,
roomId: roomId,
eventId: messageId,
);
}
25 changes: 9 additions & 16 deletions app/lib/features/chat_ng/actions/send_message_action.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:acter/common/providers/chat_providers.dart';
import 'package:acter/common/widgets/html_editor/html_editor.dart';
import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart';
import 'package:acter/features/chat/providers/chat_providers.dart';
import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
import 'package:acter/features/home/providers/client_providers.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft;
import 'package:appflowy_editor/appflowy_editor.dart';
Expand Down Expand Up @@ -40,35 +40,28 @@ Future<void> sendMessageAction({
}

// actually send it out
final inputState = ref.read(chatInputProvider);
final chatEditorState = ref.read(chatEditorStateProvider);
final stream = await ref.read(timelineStreamProvider(roomId).future);

if (inputState.selectedMessageState == SelectedMessageState.replyTo) {
final remoteId = inputState.selectedMessage?.remoteId;
if (chatEditorState.isReplying) {
final remoteId = chatEditorState.selectedMsgItem?.eventId();
if (remoteId == null) throw 'remote id of sel msg not available';
await stream.replyMessage(remoteId, draft);
} else if (inputState.selectedMessageState == SelectedMessageState.edit) {
final remoteId = inputState.selectedMessage?.remoteId;
} else if (chatEditorState.isEditing) {
final remoteId = chatEditorState.selectedMsgItem?.eventId();
if (remoteId == null) throw 'remote id of sel msg not available';
await stream.editMessage(remoteId, draft);
} else {
await stream.sendMessage(draft);
}

ref.read(chatInputProvider.notifier).messageSent();
final transaction = textEditorState.transaction;
final nodes = transaction.document.root.children;
// delete all nodes of document (reset)
transaction.document.delete([0], nodes.length);
final delta = Delta()..insert('');
// insert empty text node
transaction.document.insert([0], [paragraphNode(delta: delta)]);
await textEditorState.apply(transaction, withUpdateSelection: false);
// FIXME: works for single line text, but doesn't get focus on multi-line (iOS)
textEditorState.moveCursorForward(SelectionMoveRange.line);
textEditorState.clear();

// also clear composed state
final convo = await ref.read(chatProvider(roomId).future);
final notifier = ref.read(chatEditorStateProvider.notifier);
notifier.unsetActions();
if (convo != null) {
await convo.saveMsgDraft(
textEditorState.intoMarkdown(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import 'dart:ui';

import 'package:acter/features/chat_ng/widgets/message_actions_widget.dart';
import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector.dart';
import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'
show RoomEventItem;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// reaction selector action on chat message
void reactionSelectionAction({
// message actions on chat message
void messageActions({
required BuildContext context,
required Widget messageWidget,
required bool isMe,
required String roomId,
required bool canRedact,
required RoomEventItem item,
required String messageId,
}) {
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset.zero);
final messageSize = box.size;
required String roomId,
}) async {
// trigger vibration haptic
await HapticFeedback.heavyImpact();
if (!context.mounted) return;

showGeneralDialog(
context: context,
Expand All @@ -22,26 +28,38 @@ void reactionSelectionAction({
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (context, animation, secondaryAnimation) {
return Stack(
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ReactionOverlay(
_BlurOverlay(
animation: animation,
child: const SizedBox.expand(),
),
Positioned(
left: position.dx,
top: position.dy,
width: messageSize.width,
child: messageWidget,
child: const SizedBox.shrink(),
),
Positioned(
left: position.dx,
top: position.dy - 60,
child: _AnimatedReactionSelector(
// Reaction Row
Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: _AnimatedActionsContainer(
animation: animation,
messageId: messageId,
tagId: messageId,
child: ReactionSelector(
isMe: isMe,
messageId: '$messageId-reactions',
roomId: roomId,
),
),
),
// Message
Center(child: messageWidget),
// Message actions
Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: _AnimatedActionsContainer(
animation: animation,
tagId: '$messageId-actions',
child: MessageActionsWidget(
isMe: isMe,
canRedact: canRedact,
item: item,
messageId: messageId,
roomId: roomId,
),
Expand All @@ -53,11 +71,11 @@ void reactionSelectionAction({
);
}

class _ReactionOverlay extends StatelessWidget {
class _BlurOverlay extends StatelessWidget {
final Animation<double> animation;
final Widget child;

const _ReactionOverlay({
const _BlurOverlay({
required this.animation,
required this.child,
});
Expand Down Expand Up @@ -86,21 +104,21 @@ class _ReactionOverlay extends StatelessWidget {
}
}

class _AnimatedReactionSelector extends StatelessWidget {
class _AnimatedActionsContainer extends StatelessWidget {
final Animation<double> animation;
final Widget child;
final String messageId;
final String tagId;

const _AnimatedReactionSelector({
const _AnimatedActionsContainer({
required this.animation,
required this.child,
required this.messageId,
required this.tagId,
});

@override
Widget build(BuildContext context) {
return Hero(
tag: messageId,
tag: tagId,
child: Material(
color: Colors.transparent,
child: AnimatedBuilder(
Expand Down
Loading

0 comments on commit 509e976

Please sign in to comment.