Skip to content

Commit

Permalink
action_sheet: Add "Quote and reply" button
Browse files Browse the repository at this point in the history
Fixes: zulip#116
  • Loading branch information
chrisbobbe committed Jun 23, 2023
1 parent e49f47e commit 10953f1
Show file tree
Hide file tree
Showing 2 changed files with 207 additions and 4 deletions.
117 changes: 113 additions & 4 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,50 @@
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';

import '../api/exception.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
import 'compose_box.dart';
import 'dialog.dart';
import 'draggable_scrollable_modal_bottom_sheet.dart';
import 'message_list.dart';
import 'store.dart';

/// Show a sheet of actions you can take on a message in the message list.
///
/// Must have a [MessageListPage] ancestor.
void showMessageActionSheet({required BuildContext context, required Message message}) {
// The UI that's conditioned on this won't live-update during this appearance
// of the action sheet (we avoid calling composeBoxControllerOf in a build
// method; see its doc). But currently it will be constant through the life of
// any message list, so that's fine.
final isComposeBoxOffered = MessageListPage.composeBoxControllerOf(context) != null;
showDraggableScrollableModalBottomSheet(
context: context,
builder: (BuildContext innerContext) {
return Column(children: [
ShareButton(message: message),
ShareButton(message: message, messageListContext: context),
if (isComposeBoxOffered) QuoteAndReplyButton(
message: message,
messageListContext: context,
),
]);
});
}

abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
const MessageActionSheetMenuItemButton({
MessageActionSheetMenuItemButton({
super.key,
required this.message,
});
required this.messageListContext,
}) : assert(messageListContext.findAncestorWidgetOfExactType<MessageListPage>() != null);

IconData get icon;
String get label;
void Function(BuildContext) get onPressed;

final Message message;
final BuildContext messageListContext;

@override
Widget build(BuildContext context) {
Expand All @@ -36,9 +56,10 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
}

class ShareButton extends MessageActionSheetMenuItemButton {
const ShareButton({
ShareButton({
super.key,
required super.message,
required super.messageListContext,
});

@override get icon => Icons.adaptive.share;
Expand All @@ -65,3 +86,91 @@ class ShareButton extends MessageActionSheetMenuItemButton {
await Share.shareWithResult(message.content);
};
}

class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
QuoteAndReplyButton({
super.key,
required super.message,
required super.messageListContext,
});

@override get icon => Icons.format_quote_outlined;

@override get label => 'Quote and reply';

@override get onPressed => (BuildContext bottomSheetContext) async {
// Close the message action sheet. We'll show the request progress
// in the compose-box content input with a "[Quoting…]" placeholder.
Navigator.of(bottomSheetContext).pop();

// This will be null only if the compose box disappeared after the
// message action sheet opened, and before "Quote and reply" was pressed.
// Currently a compose box can't ever disappear, so this is impossible.
ComposeBoxController composeBoxController =
MessageListPage.composeBoxControllerOf(messageListContext)!;
final topicController = composeBoxController.topicController;
if (
topicController != null
&& topicController.textNormalized == kNoTopicTopic
&& message is StreamMessage
) {
topicController.value = TextEditingValue(text: message.subject);
}
final tag = composeBoxController.contentController
.registerQuoteAndReplyStart(PerAccountStoreWidget.of(messageListContext),
message: message,
);

Message? fetchedMessage;
String? errorMessage;
// TODO, supported by reusable code:
// - (?) Retry with backoff on plausibly transient errors.
// - If request(s) take(s) a long time, show snackbar with cancel
// button, like "Still working on quote-and-reply…".
// On final failure or success, auto-dismiss the snackbar.
try {
fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(messageListContext).connection,
messageId: message.id,
applyMarkdown: false,
);
if (fetchedMessage == null) {
errorMessage = 'That message does not seem to exist.';
}
} catch (e) {
switch (e) {
case ZulipApiException():
errorMessage = e.message;
// TODO specific messages for common errors, like network errors
// (support with reusable code)
default:
errorMessage = 'Could not fetch message source.';
}
}

if (!messageListContext.mounted) return;

if (fetchedMessage == null) {
assert(errorMessage != null);
// TODO(?) give no feedback on error conditions we expect to
// flag centrally in event polling, like invalid auth,
// user/realm deactivated. (Support with reusable code.)
await showErrorDialog(context: messageListContext,
title: 'Quotation failed', message: errorMessage);
}

if (!messageListContext.mounted) return;

// This will be null only if the compose box disappeared during the
// quotation request. Currently a compose box can't ever disappear,
// so this is impossible.
composeBoxController = MessageListPage.composeBoxControllerOf(messageListContext)!;
composeBoxController.contentController
.registerQuoteAndReplyEnd(PerAccountStoreWidget.of(messageListContext), tag,
message: message,
rawContent: fetchedMessage?.content,
);
if (!composeBoxController.contentFocusNode.hasFocus) {
composeBoxController.contentFocusNode.requestFocus();
}
};
}
94 changes: 94 additions & 0 deletions test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/model/compose.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/widgets/compose_box.dart';
import 'package:zulip/widgets/content.dart';
import 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/store.dart';
import '../api/fake_api.dart';

import '../example_data.dart' as eg;
import '../model/binding.dart';
import '../model/test_store.dart';

void main() {
group('QuoteAndReplyButton', () {
TestDataBinding.ensureInitialized();

testWidgets('happy path', (WidgetTester tester) async {
addTearDown(TestDataBinding.instance.reset);

final sender = eg.user();
final stream = eg.stream();
final message = eg.streamMessage(sender: sender, stream: stream);
await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot);
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
store.addUser(sender);
store.addStream(stream);
final connection = store.connection as FakeApiConnection;

// prepare message list data
connection.prepare(json: GetMessagesResult(
anchor: message.id,
foundNewest: true,
foundOldest: true,
foundAnchor: true,
historyLimited: false,
messages: [message],
).toJson());

await tester.pumpWidget(
MaterialApp(
home: GlobalStoreWidget(
child: PerAccountStoreWidget(
accountId: eg.selfAccount.id,
child: MessageListPage(
narrow: StreamNarrow(message.streamId))))));

// global store, per-account store, and message list get loaded
await tester.pumpAndSettle();

// request the message action sheet
final messageContent = tester.widget<MessageContent>(
find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id));
await tester.longPress(find.byWidget(messageContent));
await tester.pumpAndSettle(); // sheet appears onscreen

// Prepare fetch-raw-Markdown response
// TODO: Message should really only differ from `message`
// in its content / content_type, not in `id` or anything else.
connection.prepare(json:
GetMessageResult(message: eg.streamMessage(contentMarkdown: 'Hello world')).toJson());

// compose box exists because this is a stream narrow
final composeBox = tester.widget<ComposeBox>(find.byType(ComposeBox));
final composeBoxController = composeBox.controllerKey!.currentState!;
final contentController = composeBoxController.contentController;

// the "Quote and reply" button, which exists because the compose box exists
await tester.tap(find.byIcon(Icons.format_quote_outlined));

final expectedPlaceholder = quoteAndReplyPlaceholder(store, message: message);
// expect newline from [ComposeContentController.insertPadded]
String expectedText = '$expectedPlaceholder\n';
check(contentController.value).equals(TextEditingValue(
text: expectedText,
// compose input not yet focused, so there is no selection
selection: const TextSelection.collapsed(offset: -1)));

// message is fetched; compose box updates
await tester.pumpAndSettle();

final expectedQuoteAndReplyText = quoteAndReply(store, message: message, rawContent: 'Hello world');
// expect newline from [ComposeContentController.insertPadded]
expectedText = '$expectedQuoteAndReplyText\n';
check(contentController.value).equals(TextEditingValue(
text: expectedText,
// compose input focused, so there is a selection
selection: TextSelection.collapsed(offset: expectedText.length)));
});
});
}

0 comments on commit 10953f1

Please sign in to comment.