Skip to content

Commit

Permalink
action_sheet: Add "Quote and reply" button
Browse files Browse the repository at this point in the history
Fixes: #116
  • Loading branch information
chrisbobbe authored and gnprice committed Jul 1, 2023
1 parent 02fd562 commit 406b7d0
Show file tree
Hide file tree
Showing 4 changed files with 358 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 _) {
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();
}
};
}
6 changes: 6 additions & 0 deletions test/flutter_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
import 'dart:ui';

import 'package:checks/checks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';


extension ValueNotifierChecks<T> on Subject<ValueNotifier<T>> {
Subject<T> get value => has((c) => c.value, 'value');
}

extension TextStyleChecks on Subject<TextStyle> {
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');
Expand Down
223 changes: 223 additions & 0 deletions test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/model/compose.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.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 '../flutter_checks.dart';
import '../model/binding.dart';
import '../model/test_store.dart';
import 'compose_box_checks.dart';
import 'dialog_checks.dart';

/// Simulates loading a [MessageListPage] and long-pressing on [message].
Future<void> setupToMessageActionSheet(WidgetTester tester, {
required Message message,
required Narrow narrow,
}) async {
addTearDown(TestDataBinding.instance.reset);

await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot);
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
store.addUser(eg.user(userId: message.senderId));
if (message is StreamMessage) {
store.addStream(eg.stream(streamId: message.streamId));
}
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: narrow)))));

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

// request the message action sheet
await tester.longPress(find.byType(MessageContent));
// sheet appears onscreen; default duration of bottom-sheet enter animation
await tester.pump(const Duration(milliseconds: 250));
}

void main() {
TestDataBinding.ensureInitialized();

group('QuoteAndReplyButton', () {
ComposeBoxController? findComposeBoxController(WidgetTester tester) {
return tester.widget<ComposeBox>(find.byType(ComposeBox))
.controllerKey?.currentState;
}

Widget? findQuoteAndReplyButton(WidgetTester tester) {
return tester.widgetList(find.byIcon(Icons.format_quote_outlined)).singleOrNull;
}

void prepareRawContentResponseSuccess(PerAccountStore store, {
required Message message,
required String rawContent,
}) {
// Prepare fetch-raw-Markdown response
// TODO: Message should really only differ from `message`
// in its content / content_type, not in `id` or anything else.
(store.connection as FakeApiConnection).prepare(json:
GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson());
}

void prepareRawContentResponseError(PerAccountStore store) {
final fakeResponseJson = {
'code': 'BAD_REQUEST',
'msg': 'Invalid message(s)',
'result': 'error',
};
(store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson);
}

/// Simulates tapping the quote-and-reply button in the message action sheet.
///
/// Checks that there is a quote-and-reply button.
Future<void> tapQuoteAndReplyButton(WidgetTester tester) async {
final quoteAndReplyButton = findQuoteAndReplyButton(tester);
check(quoteAndReplyButton).isNotNull();
await tester.tap(find.byWidget(quoteAndReplyButton!));
}

void checkLoadingState(PerAccountStore store, ComposeContentController contentController, {
required TextEditingValue valueBefore,
required Message message,
}) {
check(contentController).value.equals((ComposeContentController()
..value = valueBefore
..insertPadded(quoteAndReplyPlaceholder(store, message: message))
).value);
check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress);
}

void checkSuccessState(PerAccountStore store, ComposeContentController contentController, {
required TextEditingValue valueBefore,
required Message message,
required String rawContent,
}) {
final builder = ComposeContentController()
..value = valueBefore
..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent));
if (!valueBefore.selection.isValid) {
// (At the end of the process, we focus the input, which puts a cursor
// at text's end, if there was no cursor at the time.)
builder.selection = TextSelection.collapsed(offset: builder.text.length);
}
check(contentController).value.equals(builder.value);
check(contentController).not(it()..validationErrors.contains(ContentValidationError.quoteAndReplyInProgress));
}

testWidgets('in stream narrow', (WidgetTester tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: StreamNarrow(message.streamId));
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);

final composeBoxController = findComposeBoxController(tester)!;
final contentController = composeBoxController.contentController;

final valueBefore = contentController.value;
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
await tapQuoteAndReplyButton(tester);
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
await tester.pump(Duration.zero); // message is fetched; compose box updates
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
checkSuccessState(store, contentController,
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
});

testWidgets('in topic narrow', (WidgetTester tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);

final composeBoxController = findComposeBoxController(tester)!;
final contentController = composeBoxController.contentController;

final valueBefore = contentController.value;
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
await tapQuoteAndReplyButton(tester);
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
await tester.pump(Duration.zero); // message is fetched; compose box updates
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
checkSuccessState(store, contentController,
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
});

testWidgets('in DM narrow', (WidgetTester tester) async {
final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
await setupToMessageActionSheet(tester,
message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);

final composeBoxController = findComposeBoxController(tester)!;
final contentController = composeBoxController.contentController;

final valueBefore = contentController.value;
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
await tapQuoteAndReplyButton(tester);
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
await tester.pump(Duration.zero); // message is fetched; compose box updates
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
checkSuccessState(store, contentController,
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
});

testWidgets('request has an error', (WidgetTester tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);

final composeBoxController = findComposeBoxController(tester)!;
final contentController = composeBoxController.contentController;

final valueBefore = contentController.value = TextEditingValue.empty;
prepareRawContentResponseError(store);
await tapQuoteAndReplyButton(tester);
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
await tester.pump(Duration.zero); // error arrives; error dialog shows

await tester.tap(find.byWidget(checkErrorDialog(tester,
expectedTitle: 'Quotation failed',
expectedMessage: 'That message does not seem to exist.',
)));

check(contentController.value).equals(const TextEditingValue(
// The placeholder was removed. (A newline from the placeholder's
// insertPadded remains; I guess ideally we'd try to prevent that.)
text: '\n',

// (At the end of the process, we focus the input.)
selection: TextSelection.collapsed(offset: 1), //
));
});

testWidgets('not offered in AllMessagesNarrow (composing to reply is not yet supported)', (WidgetTester tester) async {
final message = eg.streamMessage();
await setupToMessageActionSheet(tester, message: message, narrow: const AllMessagesNarrow());
check(findQuoteAndReplyButton(tester)).isNull();
});
});
}
Loading

0 comments on commit 406b7d0

Please sign in to comment.