Skip to content

action_sheet: Add "Quote and reply" button 🎉 #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jul 1, 2023

Conversation

chrisbobbe
Copy link
Collaborator

Fixes: #116

@chrisbobbe chrisbobbe added the a-compose Compose box, autocomplete, attaching files/images label Jun 17, 2023
@chrisbobbe chrisbobbe requested a review from gnprice June 17, 2023 01:40
Comment on lines 23 to 28
static SendableNarrow ofMessage(Message message, {required int selfUserId}) {
switch (message) {
case StreamMessage(:var streamId, :var subject):
return TopicNarrow(streamId, subject);
case DmMessage(:var displayRecipient):
return DmNarrow(
allRecipientIds: displayRecipient.map((r) => r.id).toList(),
selfUserId: selfUserId,
);
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if this would be best as a static method on SendableNarrow, or a helper function defined at toplevel, or even a method on Message.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here on SendableNarrow is good. In fact I think the most idiomatic thing would be to make it a factory constructor, rather than a static method.

That caused me to wonder how a factory constructor differs anyway from just a static method with return type of the class. Here's a writeup I found helpful:
https://dash-overflow.net/articles/factory/
Nothing that affects this example, I think, but anyway I think a factory constructor is most idiomatic.

I wouldn't want to make it a method on Message because Message is part of the API bindings, and this isn't.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A further improvement would be to add [TopicNarrow.ofMessage] and [DmNarrow.ofMessage], and then this one can just switch on the type of the message and call those.

That way a call site that knows it has a DmMessage can say DmNarrow.ofMessage(message) and know that it'll get a DmNarrow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

Comment on lines +180 to +184
String mention(User user, {bool silent = false}) {
return '@${silent ? '_' : ''}**${user.fullName}|${user.userId}**';
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps doesn't need a reusable function defined here, but:

@@ -158,8 +159,8 @@ class ComposeContentController extends ComposeController<ContentValidationError>
value = value.replaced(
replacementRange,
url == null
? '[Failed to upload file: $filename]()' // TODO(i18n)
: '[$filename](${url.toString()})');
? inlineLink('Failed to upload file: $filename', null) // TODO(i18n)
Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jun 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I came back to this code, I wondered how helpful this "failure" content is. It'd be just as easy to delete the "loading" placeholder, instead of replacing it with this.

(Same for quote-and-reply.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(We'll already be showing an error dialog about the failure, I think.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, just deleting the placeholder makes sense to me.

Comment on lines 204 to 240
String quoteAndReplyPlaceholder(PerAccountStore store, {
required Message message
}) {
final sender = store.users[message.senderId];
assert(sender != null);
final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.account.userId));
final senderPart = '${mention(sender!, silent: true)} ${inlineLink('said', url)}: '; // TODO(i18n) ?
return '$senderPart*(loading message ${message.id})*\n'; // TODO(i18n) ?
}

/// Quote-and-reply syntax.
///
/// The result looks like it does in Zulip web:
///
/// @_**Iago|5** [said](link to message):
/// ```quote
/// message content
/// ```
String quoteAndReply(PerAccountStore store, {
required Message message,
required String rawContent,
}) {
final sender = store.users[message.senderId];
assert(sender != null);
final url = narrowLink(store,
SendableNarrow.ofMessage(message, selfUserId: store.account.userId),
nearMessageId: message.id);
final senderLine = '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n'; // TODO(i18n) ?
return senderLine + wrapWithBacktickFence(content: rawContent, infoString: 'quote');
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could write tests for these, I suppose 😅—but they mostly just use code that already has test coverage.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my question above about having the message ID in the link perhaps answers this 🙂.

Because the various pieces that these functions put together already have their own test coverage, these might not need more than a single test case each. But that single test case would provide coverage of how these functions do put those pieces together, like the presence of /near/12345 in the link and the use of silent mentions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, makes sense. For the "expected" string, I guess we have a choice to make it a more-or-less literal string, or put that together using inlineLink and friends since they're covered already.

I've gone the more-or-less literal string route for my next revision but would happily switch to the other.

Comment on lines 140 to 165
void _insertPadded(String newText) { // ignore: unused_element
final i = _insertionIndex();
final textBefore = text.substring(0, i.start);
final String paddingBefore;
if (textBefore.isEmpty || textBefore == '\n' || textBefore.endsWith('\n\n')) {
paddingBefore = ''; // At start of input, or just after an empty line.
} else if (textBefore.endsWith('\n')) {
paddingBefore = '\n'; // After a complete but non-empty line.
} else {
paddingBefore = '\n\n'; // After an incomplete line.
}
final paddingAfter = text.substring(i.start).startsWith('\n') ? '' : '\n';
value = value.replaced(i, paddingBefore + newText + paddingAfter);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be reasonable to add some tests for this; I can do that if you like.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for building this! Comments below.

Comment on lines 107 to 108
{User? sender, ZulipStream? inStream, String? topic}) {
final effectiveStream = inStream ?? stream();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
{User? sender, ZulipStream? inStream, String? topic}) {
final effectiveStream = inStream ?? stream();
{User? sender, ZulipStream? inStream, String? topic}) {
final effectiveStream = inStream ?? stream();

(i.e. keep the indentation of that parameters line)

When the parameters are immediately juxtaposed with the body like this, it's helpful to give them an extra level of indentation so they don't look like part of the body.

(Similarly when indenting the second of two lines in an if-condition, for example.)

Comment on lines 27 to 30
case DmMessage(:var displayRecipient):
return DmNarrow(
allRecipientIds: displayRecipient.map((r) => r.id).toList(),
selfUserId: selfUserId,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a useful helper to have.

When adding the helper, it should be used to replace the existing places where we do the same thing. These can be found among the call sites of the TopicNarrow and DmNarrow constructors.

Seeing one of those reminds me that this can be made to look a bit cleaner by consuming DmMessage.allRecipientIds, rather than displayRecipient.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jun 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

These can be found among the call sites of the TopicNarrow and DmNarrow constructors.

I see that a call site of the DmNarrow constructor makes the allRecipientIds list with .toList(growable: false). If DmNarrow is meant to be immutable, then that seems helpful: you don't want that list to get anything added to it.

…I guess you also don't want any of its elements to be replaced or removed, either. Would a list made with List.unmodifiable be better? What if DmNarrow.allRecipientIds were an Iterable instead of a List?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a list made with List.unmodifiable be better?

Yeah, that'd be pretty reasonable.

What if DmNarrow.allRecipientIds were an Iterable instead of a List?

I think DmNarrow wants to ask that the allRecipientIds it's passed is a List, because the expectation is that it's all materialized and there's not any work that needs to be done to iterate through them.

It could provide the allRecipientIds property as an Iterable instead of a List, but I'm not sure that's useful enough for the small complication it adds.

Comment on lines 23 to 28
static SendableNarrow ofMessage(Message message, {required int selfUserId}) {
switch (message) {
case StreamMessage(:var streamId, :var subject):
return TopicNarrow(streamId, subject);
case DmMessage(:var displayRecipient):
return DmNarrow(
allRecipientIds: displayRecipient.map((r) => r.id).toList(),
selfUserId: selfUserId,
);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here on SendableNarrow is good. In fact I think the most idiomatic thing would be to make it a factory constructor, rather than a static method.

That caused me to wonder how a factory constructor differs anyway from just a static method with return type of the class. Here's a writeup I found helpful:
https://dash-overflow.net/articles/factory/
Nothing that affects this example, I think, but anyway I think a factory constructor is most idiomatic.

I wouldn't want to make it a method on Message because Message is part of the API bindings, and this isn't.

Comment on lines 23 to 28
static SendableNarrow ofMessage(Message message, {required int selfUserId}) {
switch (message) {
case StreamMessage(:var streamId, :var subject):
return TopicNarrow(streamId, subject);
case DmMessage(:var displayRecipient):
return DmNarrow(
allRecipientIds: displayRecipient.map((r) => r.id).toList(),
selfUserId: selfUserId,
);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A further improvement would be to add [TopicNarrow.ofMessage] and [DmNarrow.ofMessage], and then this one can just switch on the type of the message and call those.

That way a call site that knows it has a DmMessage can say DmNarrow.ofMessage(message) and know that it'll get a DmNarrow.

group('SendableNarrow', () {
test('stream message', () {
final stream = eg.stream();
final message = eg.streamMessage(inStream: stream);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, having to call this argument inStream: instead of simply stream: looks pretty weird to me. I think that's going to regularly be confusing when trying to use this helper.

I'm guessing the reason for that name is for the sake of the implementation, where it wants to refer to the outer stream (aka eg.stream) and that would get shadowed.

This name seems confusing enough for callers, though, that I think it'd be worth some hackery at the implementation to avoid it. One solution would be to add a private alias at the global level of the file, which could look like this:

const _stream = stream;

StreamMessage streamMessage(
    {User? sender, ZulipStream? stream, String? topic}) {
  final effectiveStream = stream ?? _stream();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah OK, that's better!

// 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 = MessageListPageState.composeBoxControllerOf(messageListContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
ComposeBoxController? composeBoxController = MessageListPageState.composeBoxControllerOf(messageListContext);
ComposeBoxController? composeBoxController =
MessageListPageState.composeBoxControllerOf(messageListContext);

I know we've talked about avoiding "cliffhanger" newlines following Flutter-repo style, but I think this line is too long for that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plus as a bonus:

Suggested change
ComposeBoxController? composeBoxController = MessageListPageState.composeBoxControllerOf(messageListContext);
ComposeBoxController composeBoxController = MessageListPageState.composeBoxControllerOf(messageListContext)!;

when the line is a reasonable length, it becomes reasonable to put an important one-character bit of syntax at the end of it. And this seems like a more on-point place for that ! than the next line.

/// don't call this in a build method.
static ComposeBoxController? composeBoxControllerOf(BuildContext context) {
final messageListPageState = context.findAncestorStateOfType<MessageListPageState>();
assert(messageListPageState != null, 'No MessageListPageState ancestor');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, related to leaving the state private:

Suggested change
assert(messageListPageState != null, 'No MessageListPageState ancestor');
assert(messageListPageState != null, 'No MessageListPage ancestor');

the error message can just stick to the public name, of the widget.

(See for comparison PerAccountStoreWidget.of.)

Comment on lines 145 to 153
switch (e) {
case ZulipApiException():
errorMessage = e.message;
}
}

if (fetchedMessage == null && errorMessage == null) {
errorMessage = 'Could not fetch message source.';
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly easier to follow, I think, would be:

Suggested change
switch (e) {
case ZulipApiException():
errorMessage = e.message;
}
}
if (fetchedMessage == null && errorMessage == null) {
errorMessage = 'Could not fetch message source.';
}
switch (e) {
case ZulipApiException():
errorMessage = e.message;
default:
errorMessage = 'Could not fetch message source.';
}
}

Can then add an assert(errorMessage != null) inside the if (fetchedMessage == null) case below.

Comment on lines 158 to 159
// TODO specific messages for common errors, like network errors
// (support with reusable code)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment can probably best go inside the switch in the catch above.


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

@override VoidCallback get onPressed => () async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot here in this method, and it'd be real nice to have a few tests for it. Would you try writing some?

I think after #30 we may have all the pieces we need in order to be able to write a reasonable test for code like this. If you run into something that makes it hard to test, let's discuss in chat.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! I'm happy to report that I've gotten a reasonable-looking "happy path" test in my next revision. It does these things:

  • renders a message list with a message and a compose box
  • simulates a long-press on the message to open the message action sheet
  • simulates a tap on the "Quote and reply" button
  • checks the compose-input value before and after the fetch-raw-Markdown request

It's 72 lines of code, which seems like a lot. I'd like to see how we could shape the code for good support of additional tests, such as to exercise when the fetch-raw-Markdown request fails. (And also I guess to check that we don't offer quote-and-reply at all when it can't be supported; i.e., when we don't offer a compose box for the narrow.) Maybe we can go over this in the office today?

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed. In particular, please see #201 (comment) about something we might discuss in the office today. 🙂

chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Jun 23, 2023
…acing

We're already showing an error dialog for the failure, so this seems
redundant; discussion:
  zulip#201 (comment)
@chrisbobbe chrisbobbe force-pushed the pr-quote-and-reply branch from 00dad6a to 10953f1 Compare June 23, 2023 19:30
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

I'll aim to give this revision a full review after the weekend, but here's comments on the handy new insertPadded tests.

(We also talked about the new widget test in the office today as discussed above.)

Comment on lines 54 to 55
group('insert at end', () {
checkInsertPadded('one empty line; insert one line',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have a convention that when the helper creates its own test cases (calls test), its name is like testFoo, and checkFoo is for helpers one calls inside an existing test case. That will help with correctly keeping all the test computations inside test cases.

Comment on lines 72 to 75
checkInsertPadded('one empty line; insert one line',
'^\n', 'a\n', 'a\n^\n');
checkInsertPadded('two empty lines; insert one line',
'^\n\n', 'a\n', 'a\n^\n\n');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting. These two cases don't feel like desired behavior — they mean that you don't end up with a blank line between the inserted text and your cursor.

I think I'd want the expected output here to instead be
a\n\n^\n
a\n\n^\n\n

or else possibly
a\n\n^
a\n\n^\n
.

(This is one of the ways unit tests are useful!)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly some of the cases in the "insert in middle" group.

@chrisbobbe chrisbobbe force-pushed the pr-quote-and-reply branch from 10953f1 to ffc6a82 Compare June 27, 2023 19:34
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! The changes in response to my comments above look good.

Great to see the widget tests too. Here's comments on those. These will be among our first widget tests, and our very first widget tests that really exercise a UI.

Comment on lines 61 to 63
final messageContent = tester.widget<MessageContent>(
find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id));
await tester.longPress(find.byWidget(messageContent));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widget messageContent is used only for passing back to a find.byWidget. So the original finder could be used directly, instead of going through tester.widget.

Also we've constructed things so there's only one message in sight, so the message-ID check seems redundant. This could therefore simplify to:

Suggested change
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.longPress(find.byType(MessageContent));

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jun 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widget messageContent is used only for passing back to a find.byWidget. So the original finder could be used directly, instead of going through tester.widget.

I think my intent here was to error (in the tester.widget call) before accidentally passing a finder to tester.longPress that would find multiple widgets, since it's not clear what tester.longPress would naturally do with such a finder.

…Upon reading the code, I've discovered that it throws a descriptive error, so we don't need our own defensive code. 😅

Also we've constructed things so there's only one message in sight, so the message-ID check seems redundant. […]

I think in particular it helps that that construction of the message list (with just one message in sight) is done locally in this same function. So your proposal seems good.

Comment on lines 21 to 23
void main() {
group('QuoteAndReplyButton', () {
TestDataBinding.ensureInitialized();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

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

Idiomatic to initialize the bindings at the top of main, I think.

Comment on lines 25 to 26
/// Simulates loading a [MessageListPage] and long-pressing on [message].
Future<void> setupToMessageActionSheet(WidgetTester tester, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can go up at the top level of the file — should be relevant to a wide range of action-sheet tests, beyond this particular button.

Comment on lines 69 to 70
return tester.widgetList<ComposeBox>(find.byType(ComposeBox))
.single.controllerKey?.currentState;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the tester.widgetList doc suggests, simplify to widget when you expect only one:

Suggested change
return tester.widgetList<ComposeBox>(find.byType(ComposeBox))
.single.controllerKey?.currentState;
return tester.widget<ComposeBox>(find.byType(ComposeBox))
.controllerKey?.currentState;

required Message message,
required String rawContent,
}) {
final c = ComposeContentController()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one-letter name is a bit cryptic.

Perhaps "expected"?

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jun 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah; hmm. I guess it's a builder for an expected value, which it provides at .value, rather than anything we want to make expectations on directly. My preference would actually be to not store it in a variable at all, and in an earlier draft I had this:

      check(contentController).value.equals(
        (ComposeContentController()
          ..value = valueBefore
          ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent))
        ).value);

But that code doesn't actually produce the TextEditingValue that we want; in particular, it hasn't set its selection to be the last index of the text. To do that, we need to read what that last index is, and I didn't find a way to do that with the .. "cascade notation".

And so (quoting from the current revision):

      if (!valueBefore.selection.isValid) {
        // (At the end of the process, we focus the input, which puts a cursor
        // at the end if there wasn't a cursor before.)
        c.selection = TextSelection.collapsed(offset: c.text.length);
      }


final valueBefore = contentController.value;
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
await tapQuoteAndReplyButton(tester); // should exist; this is a stream narrow
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, can cut this comment:

Suggested change
await tapQuoteAndReplyButton(tester); // should exist; this is a stream narrow
await tapQuoteAndReplyButton(tester);

Comment on lines 37 to 47
// TODO: Can we give a more helpful, to-the-point error message than this:
//
// The following TestFailure was thrown running a test:
// Expected: a Iterable<AlertDialog> that:
// has length that:
// equals <1>
// Actual: a Iterable<AlertDialog> that:
// has length that:
// Actual: <0>
// Which: are not equal
check(alertDialogList).length.equals(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to above, can use tester.widget instead of widgetList, and get the same effect.

The error message will be different from the one quoted here — probably less verbose but not otherwise more or less helpful. I think that's fine. If a test fails for this reason, the stack trace (especially with the name checkErrorDialog) will pretty quickly make it clear that the problem was that there is no error dialog and one was expected.

required String expectedTitle,
String? expectedMessage,
}) {
final alertDialogList = tester.widgetList<AlertDialog>(find.byWidgetPredicate((widget) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think probably simpler than having a big predicate passed to byWidgetPredicate would be to first find the dialog widget, and then inspect its title and content.

After all, there should be only one AlertDialog at a time — if there's one that matches the test's expectations, but somehow also a second one, then we're happy for that to fail the test too.

That can look like this:

  final dialog = tester.widget<AlertDialog>(find.byType(AlertDialog));
  tester.widget(find.descendant(matchRoot: true,
    of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
  if (expectedMessage != null) {
    tester.widget(find.descendant(matchRoot: true,
      of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
  }

As a bonus, if the matching fails, the stack trace will now identify whether (a) there was no AlertDialog at all, (b) it lacked the expected title, or (c) it lacked the expected message.

Comment on lines 51 to 52
matching: find.byWidgetPredicate((widget) {
return widget is TextButton && widget.child is Text && (widget.child as Text).data == 'OK';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
matching: find.byWidgetPredicate((widget) {
return widget is TextButton && widget.child is Text && (widget.child as Text).data == 'OK';
matching: find.widgetWithText(TextButton, 'OK')));

import 'package:zulip/widgets/compose_box.dart';

extension ComposeContentControllerChecks on Subject<ComposeContentController> {
Subject<TextEditingValue> get value => has((c) => c.value, 'value');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This underlying getter is on ChangeNotifier, so it can go in a more general extension in flutter_checks.dart.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValueNotifier, not ChangeNotifier, I think, but yes: good idea. 🙂

@chrisbobbe chrisbobbe force-pushed the pr-quote-and-reply branch from ffc6a82 to 9df7ad8 Compare June 27, 2023 23:18
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed, and please see #201 (comment).

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Small comments on the changes in the latest revision compared to the previous; those changes otherwise LGTM.

I still need to give this a full review following the revision last week. I'll see about doing that now.


final valueBefore = contentController.value;
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
await tapQuoteAndReplyButton(tester); // should exist; this is a DM narrow
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await tapQuoteAndReplyButton(tester); // should exist; this is a DM narrow
await tapQuoteAndReplyButton(tester);

required Message message,
required String rawContent,
}) {
final c = ComposeContentController() // a builder for our expected TextEditingValue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps just call it builder?

@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed, with those tweaks.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK! Finished a full review; comments below, all pretty small.

Comment on lines +180 to +183
String mention(User user, {bool silent = false}) {
return '@${silent ? '_' : ''}**${user.fullName}|${user.userId}**';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web uses just the name, no ID, when that wouldn't be ambiguous. That looks a bit nicer while the user's still composing and looking at the source, so it'd be nice to do.

Fine to leave that as a TODO, though.

Comment on lines +310 to +312
test('inlineLink', () {
check(inlineLink('CZO', Uri.parse('https://chat.zulip.org/'))).equals('[CZO](https://chat.zulip.org/)');
check(inlineLink('Uploading file.txt…', null)).equals('[Uploading file.txt…]()');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also have a third case here that demonstrates using a relative URL string — specifically a path-absolute-URL string, since Zulip regularly uses those for things like uploaded images.

Comment on lines 236 to 237
final senderLine = '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n'; // TODO(i18n) ?
return senderLine + wrapWithBacktickFence(content: rawContent, infoString: 'quote');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use more interpolation, and rely on string-literal coalescing to keep the pieces nicely arranged on separate lines in the source:

Suggested change
final senderLine = '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n'; // TODO(i18n) ?
return senderLine + wrapWithBacktickFence(content: rawContent, infoString: 'quote');
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
'${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';

Similarly in quoteAndReplyPlaceholder above.


/// What we show while fetching the target message's raw Markdown.
String quoteAndReplyPlaceholder(PerAccountStore store, {
required Message message
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
required Message message
required Message message,

showDraggableScrollableModalBottomSheet(
context: context,
builder: (BuildContext context) {
builder: (BuildContext innerContext) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
builder: (BuildContext innerContext) {
builder: (BuildContext _) {

We're no longer using this inner context at all, so a name like _ would help clarify that the reader can ignore it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true as of this later commit:

action_sheet [nfc]: Pull out base class for message action sheet buttons

so I'll plan to make this change there, instead of here in this commit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I think maybe you put this comment on that commit I referred to, and GitHub just doesn't make it obvious whether it was meant for that commit or

action_sheet [nfc]: Rename a param to stop shadowing

or even some other commit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I think maybe you put this comment on that commit I referred to, and GitHub just doesn't make it obvious whether it was meant for that commit or

As far as GitHub sees, I put the comment on the PR's overall diff. My usual review workflow is that I read the changes commit by commit, in git log --stat -p --reverse, but make comments all on the one main page of the "Files changed" tab on the PR in GitHub.

When I made the comment, I was looking at the "Rename a param to stop shadowing" commit, but also consulted the branch tip's version in the IDE to confirm what the state of things was at the end.

I think possibly an ideal version of this branch would have that "Rename a param to stop shadowing" commit happen later after that "Pull out base class" commit, or not at all. But wherever in the branch you find to cleanly make the change is fine.

Comment on lines 159 to 160
value = value.replaced(i, paddingBefore + newText);
value = value.copyWith(selection: TextSelection.collapsed(offset: value.selection.start + 1));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
value = value.replaced(i, paddingBefore + newText);
value = value.copyWith(selection: TextSelection.collapsed(offset: value.selection.start + 1));
value = value.replaced(i, paddingBefore + newText)
.copyWith(selection: TextSelection.collapsed(offset: value.selection.start + 1));

The value setter notifies listeners, so it's cleanest to avoid calling it with a partially-computed intermediate value.

… Hmm I see, but the second line is using value.selection, and wants the value of that after the first update.

Is there a clean way to predict, before making the first update, where we're going to want to place the caret? If not, then calling the setter twice like this is an OK workaround.

Comment on lines +89 to +90
testInsertPadded('middle of line',
'a^a\n', 'b\n', 'a\n\nb\n\n^a\n');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also have a test case that represents what happens if you promptly insert another item from the output state of this one. I think this covers it:

        testInsertPadded('start of non-empty line, after empty line',
          'b\n\n^a\n',   'c\n', 'b\n\nc\n\n^a\n');

@chrisbobbe chrisbobbe force-pushed the pr-quote-and-reply branch from f9179ed to 2def4b0 Compare June 28, 2023 22:50
@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

…acing

We're already showing an error dialog for the failure, so this seems
redundant; discussion:
  zulip#201 (comment)
And use it in the few places where we're doing the same thing.

(*Almost* the same thing: in [DmNarrow.ofMessage], we make the
`allRecipientIds` list unmodifiable instead of just not-growable.)
And use it in the one place where we've been creating inline links.

This is a small amount of code, but drawing it out into a helper
gives a convenient place to write down its shortcomings.

We'll also use this for zulip#116 quote-and-reply, coming up.
Then, widgets that have MessageListPageState in their ancestry will
be able to set the topic and content inputs as part of managing a
quote-and-reply interaction.
We're about to add another button, for quote-and-reply zulip#116.
…w task

The Future returned by FakeHttpClient.send is created with either
Future.value or Future.error, which means it'll complete in a
*microtask*:
  https://web.archive.org/web/20170704074724/https://webdev.dartlang.org/articles/performance/event-loop#event-queue-new-future

That's too soon, as a simulation for a real API response coming over
an HTTP connection. In the live code that this simulates, the Future
completes in a new task. So, mimic that behavior.
@gnprice
Copy link
Member

gnprice commented Jul 1, 2023

Thanks! All looks good; merging.

@gnprice gnprice force-pushed the pr-quote-and-reply branch from 2def4b0 to 406b7d0 Compare July 1, 2023 00:12
@gnprice gnprice merged commit 406b7d0 into zulip:main Jul 1, 2023
@chrisbobbe chrisbobbe deleted the pr-quote-and-reply branch July 1, 2023 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a-compose Compose box, autocomplete, attaching files/images
Projects
None yet
Development

Successfully merging this pull request may close these issues.

compose: Quote and reply
2 participants