-
Notifications
You must be signed in to change notification settings - Fork 306
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
Conversation
lib/model/narrow.dart
Outdated
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, | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense.
String mention(User user, {bool silent = false}) { | ||
return '@${silent ? '_' : ''}**${user.fullName}|${user.userId}**'; | ||
} |
There was a problem hiding this comment.
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:
- Having tests for this logic can be nice
- We'll also be writing mentions for @-mention typeahead/autocomplete #49 / Support silent @-mention autocomplete #129.
lib/widgets/compose_box.dart
Outdated
@@ -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) |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
lib/model/compose.dart
Outdated
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'); | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/widgets/compose_box.dart
Outdated
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); | ||
} | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
test/example_data.dart
Outdated
{User? sender, ZulipStream? inStream, String? topic}) { | ||
final effectiveStream = inStream ?? stream(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
{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.)
lib/model/narrow.dart
Outdated
case DmMessage(:var displayRecipient): | ||
return DmNarrow( | ||
allRecipientIds: displayRecipient.map((r) => r.id).toList(), | ||
selfUserId: selfUserId, |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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
andDmNarrow
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
?
There was a problem hiding this comment.
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 anIterable
instead of aList
?
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.
lib/model/narrow.dart
Outdated
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, | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
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.
lib/model/narrow.dart
Outdated
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, | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
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
.
test/model/narrow_test.dart
Outdated
group('SendableNarrow', () { | ||
test('stream message', () { | ||
final stream = eg.stream(); | ||
final message = eg.streamMessage(inStream: stream); |
There was a problem hiding this comment.
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();
There was a problem hiding this comment.
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!
lib/widgets/action_sheet.dart
Outdated
// 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plus as a bonus:
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.
lib/widgets/message_list.dart
Outdated
/// don't call this in a build method. | ||
static ComposeBoxController? composeBoxControllerOf(BuildContext context) { | ||
final messageListPageState = context.findAncestorStateOfType<MessageListPageState>(); | ||
assert(messageListPageState != null, 'No MessageListPageState ancestor'); |
There was a problem hiding this comment.
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:
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
.)
lib/widgets/action_sheet.dart
Outdated
switch (e) { | ||
case ZulipApiException(): | ||
errorMessage = e.message; | ||
} | ||
} | ||
|
||
if (fetchedMessage == null && errorMessage == null) { | ||
errorMessage = 'Could not fetch message source.'; | ||
} |
There was a problem hiding this comment.
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:
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.
lib/widgets/action_sheet.dart
Outdated
// TODO specific messages for common errors, like network errors | ||
// (support with reusable code) |
There was a problem hiding this comment.
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.
lib/widgets/action_sheet.dart
Outdated
|
||
@override String get label => 'Quote and reply'; | ||
|
||
@override VoidCallback get onPressed => () async { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
Thanks for the review! Revision pushed. In particular, please see #201 (comment) about something we might discuss in the office today. 🙂 |
…acing We're already showing an error dialog for the failure, so this seems redundant; discussion: zulip#201 (comment)
00dad6a
to
10953f1
Compare
There was a problem hiding this 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.)
test/widgets/compose_box_test.dart
Outdated
group('insert at end', () { | ||
checkInsertPadded('one empty line; insert one line', |
There was a problem hiding this comment.
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.
test/widgets/compose_box_test.dart
Outdated
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'); |
There was a problem hiding this comment.
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!)
There was a problem hiding this comment.
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.
10953f1
to
ffc6a82
Compare
Thanks for the review! Revision pushed. |
There was a problem hiding this 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.
test/widgets/action_sheet_test.dart
Outdated
final messageContent = tester.widget<MessageContent>( | ||
find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id)); | ||
await tester.longPress(find.byWidget(messageContent)); |
There was a problem hiding this comment.
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:
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)); |
There was a problem hiding this comment.
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 afind.byWidget
. So the original finder could be used directly, instead of going throughtester.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.
test/widgets/action_sheet_test.dart
Outdated
void main() { | ||
group('QuoteAndReplyButton', () { | ||
TestDataBinding.ensureInitialized(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
void main() { | |
group('QuoteAndReplyButton', () { | |
TestDataBinding.ensureInitialized(); | |
void main() { | |
TestDataBinding.ensureInitialized(); | |
group('QuoteAndReplyButton', () { |
Idiomatic to initialize the bindings at the top of main
, I think.
test/widgets/action_sheet_test.dart
Outdated
/// Simulates loading a [MessageListPage] and long-pressing on [message]. | ||
Future<void> setupToMessageActionSheet(WidgetTester tester, { |
There was a problem hiding this comment.
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.
test/widgets/action_sheet_test.dart
Outdated
return tester.widgetList<ComposeBox>(find.byType(ComposeBox)) | ||
.single.controllerKey?.currentState; |
There was a problem hiding this comment.
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:
return tester.widgetList<ComposeBox>(find.byType(ComposeBox)) | |
.single.controllerKey?.currentState; | |
return tester.widget<ComposeBox>(find.byType(ComposeBox)) | |
.controllerKey?.currentState; |
test/widgets/action_sheet_test.dart
Outdated
required Message message, | ||
required String rawContent, | ||
}) { | ||
final c = ComposeContentController() |
There was a problem hiding this comment.
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"?
There was a problem hiding this comment.
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);
}
test/widgets/action_sheet_test.dart
Outdated
|
||
final valueBefore = contentController.value; | ||
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); | ||
await tapQuoteAndReplyButton(tester); // should exist; this is a stream narrow |
There was a problem hiding this comment.
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:
await tapQuoteAndReplyButton(tester); // should exist; this is a stream narrow | |
await tapQuoteAndReplyButton(tester); |
test/widgets/dialog_checks.dart
Outdated
// 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); |
There was a problem hiding this comment.
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.
test/widgets/dialog_checks.dart
Outdated
required String expectedTitle, | ||
String? expectedMessage, | ||
}) { | ||
final alertDialogList = tester.widgetList<AlertDialog>(find.byWidgetPredicate((widget) { |
There was a problem hiding this comment.
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.
test/widgets/dialog_checks.dart
Outdated
matching: find.byWidgetPredicate((widget) { | ||
return widget is TextButton && widget.child is Text && (widget.child as Text).data == 'OK'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
matching: find.byWidgetPredicate((widget) { | |
return widget is TextButton && widget.child is Text && (widget.child as Text).data == 'OK'; | |
matching: find.widgetWithText(TextButton, 'OK'))); |
test/widgets/compose_box_checks.dart
Outdated
import 'package:zulip/widgets/compose_box.dart'; | ||
|
||
extension ComposeContentControllerChecks on Subject<ComposeContentController> { | ||
Subject<TextEditingValue> get value => has((c) => c.value, 'value'); |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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. 🙂
ffc6a82
to
9df7ad8
Compare
Thanks for the review! Revision pushed, and please see #201 (comment). |
There was a problem hiding this 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.
test/widgets/action_sheet_test.dart
Outdated
|
||
final valueBefore = contentController.value; | ||
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); | ||
await tapQuoteAndReplyButton(tester); // should exist; this is a DM narrow |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
await tapQuoteAndReplyButton(tester); // should exist; this is a DM narrow | |
await tapQuoteAndReplyButton(tester); |
test/widgets/action_sheet_test.dart
Outdated
required Message message, | ||
required String rawContent, | ||
}) { | ||
final c = ComposeContentController() // a builder for our expected TextEditingValue |
There was a problem hiding this comment.
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
?
9df7ad8
to
f9179ed
Compare
Thanks! Revision pushed, with those tweaks. |
There was a problem hiding this 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.
String mention(User user, {bool silent = false}) { | ||
return '@${silent ? '_' : ''}**${user.fullName}|${user.userId}**'; |
There was a problem hiding this comment.
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.
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…]()'); |
There was a problem hiding this comment.
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.
lib/model/compose.dart
Outdated
final senderLine = '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n'; // TODO(i18n) ? | ||
return senderLine + wrapWithBacktickFence(content: rawContent, infoString: 'quote'); |
There was a problem hiding this comment.
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:
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.
lib/model/compose.dart
Outdated
|
||
/// What we show while fetching the target message's raw Markdown. | ||
String quoteAndReplyPlaceholder(PerAccountStore store, { | ||
required Message message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
required Message message | |
required Message message, |
lib/widgets/action_sheet.dart
Outdated
showDraggableScrollableModalBottomSheet( | ||
context: context, | ||
builder: (BuildContext context) { | ||
builder: (BuildContext innerContext) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/widgets/compose_box.dart
Outdated
value = value.replaced(i, paddingBefore + newText); | ||
value = value.copyWith(selection: TextSelection.collapsed(offset: value.selection.start + 1)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
testInsertPadded('middle of line', | ||
'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); |
There was a problem hiding this comment.
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');
f9179ed
to
2def4b0
Compare
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.
Thanks! All looks good; merging. |
2def4b0
to
406b7d0
Compare
Fixes: #116