-
Notifications
You must be signed in to change notification settings - Fork 309
Edit-message (8/n): Implement edit-message! #1498
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
base: main
Are you sure you want to change the base?
Conversation
Oh, and if you tap "Edit message" in the action sheet but there's already text in the compose box, here's the dialog you get. Giving the "Discard" button a destructive/red style is future work, #1032.
|
212ba0b
to
c23b526
Compare
Did we discuss the strings anywhere? I would maybe write something like this:
|
I think it's good to put a |
Makes sense; I started a discussion on CZO: #mobile-team > Edit-message: UI strings @ 💬 |
c23b526
to
0bf8739
Compare
Revision pushed, with those UI string updates, and I've updated the screenshots. |
0bf8739
to
19b96de
Compare
(Oops, fixed a failing test.) |
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 the PR! Looks pretty good to me overall. Left some comments.
lib/widgets/compose_box.dart
Outdated
designVariables.bannerBgIntInfo; | ||
|
||
@override | ||
Widget? buildTrailing(context) { |
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: this can be
Widget? buildTrailing(context) { | |
Widget buildTrailing(context) { |
lib/widgets/message_list.dart
Outdated
case true: | ||
content = _withRestoreEditMessageGestureDetector(context, content); | ||
case false: | ||
content = IgnorePointer(child: content); |
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.
What's the motivation of IgnorePointer
here? At first, I thought it's for disabling the long press menu when the edit request is pending. But it seems like that menu is still available.
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.
It's meant to neutralize interactive message content like links, spoilers, etc.; I thought it made sense along with the faded 0.6-opacity appearance in the Figma. I can add a comment about that.
lib/widgets/action_sheet.dart
Outdated
editLimit != null | ||
&& editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) | ||
&& now - message.timestamp > editLimit; | ||
final showEditButton = message.senderId == store.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.
One other thing we can check for it whether the message contains a poll, since polls cannot be edited.
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, TIL; thanks.
lib/widgets/message_list.dart
Outdated
if (editMessageErrorStatus != null) { | ||
content = Opacity(opacity: 0.6, child: content); | ||
switch (editMessageErrorStatus) { | ||
case true: |
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: perhaps match the order of the previous switch since they are parallel to each other
lib/widgets/message_list.dart
Outdated
final controller = composeBoxState?.controller; | ||
final editMessageInProgress = | ||
controller is EditMessageComposeBoxController | ||
|| editMessageErrorStatus != null; |
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 controller is EditMessageComposeBoxController
check seems quite interesting. editMessageErrorStatus != null
sounds reasonable, but I can't really grasp why controller
needs to be of certain type. Maybe this needs a 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.
This condition means whether the compose box is in edit-message mode instead of the default write-new-message mode; I'll write a comment :)
lib/widgets/action_sheet.dart
Outdated
&& editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) | ||
&& now - message.timestamp > editLimit; | ||
final showEditButton = message.senderId == store.selfUserId | ||
&& messageListHasComposeBox |
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.
How about
&& messageListHasComposeBox | |
&& isComposeBoxOffered |
and we remove messageListHasComposeBox
?
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 indeed, I didn't see that we already have the value we need.
Widget content = MessageContent(message: message, content: item.content); | ||
|
||
if (editMessageErrorStatus != null) { | ||
content = Opacity(opacity: 0.6, child: content); |
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 should also apply the opacity to the sender row, if there is one:

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.
Left a comment about this after asking Greg in the office:
// The Figma also fades the sender row:
// https://github.com/zulip/zulip-flutter/pull/1498#discussion_r2076574000
// We've decided to just fade the message content because that's the only
// thing that's changing.
test/widgets/action_sheet_test.dart
Outdated
/// Test whether the edit-message button is visible, given params. | ||
/// | ||
/// The message timestamp is 60s before the current time | ||
/// ([TestBinding.utcNow]) as of the start of the test run. |
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: TestZulipBinding
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.
Indeed—also, thanks for creating this; you must have noticed I cherry-picked your commits :)
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.
Glad that helps!
test/widgets/action_sheet_test.dart
Outdated
realmMessageContentEditLimitSeconds: limit, | ||
); | ||
|
||
if (!composing && errorStatus == null) { |
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, I don't quite understand the purpose of composing
. It's said to be "editing compose box active", but this condition seems to be the only place that checks the value of the flag (other than an assert). It feels a bit different from the other test params.
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've added some comments to try to clarify.
message: eg.streamMessage(content: 'foo')).toJson()); | ||
await tester.tap(find.byIcon(ZulipIcons.edit)); | ||
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e | ||
await tester.pump(Duration.zero); |
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: tapEditAndPump
(when moved out) might be useful here and later
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.
Exciting! A few quick comments from an early skim.
lib/widgets/message_list.dart
Outdated
if ((message.reactions?.total ?? 0) > 0) | ||
ReactionChipsList(messageId: message.id, reactions: message.reactions!), | ||
if (editStateText != null) | ||
if (editMessageErrorStatusRow != null) editMessageErrorStatusRow |
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 this would go well as its own widget:
if (editMessageErrorStatusRow != null) editMessageErrorStatusRow | |
if (editMessageErrorStatus != null) | |
_EditMessageStatusRow(messageId: message.id), |
That'd help keep this build method mostly clear of details that are specific to a message currently being edited.
lib/widgets/message_list.dart
Outdated
content = Opacity(opacity: 0.6, child: content); | ||
switch (editMessageErrorStatus) { | ||
case true: | ||
content = _withRestoreEditMessageGestureDetector(context, content); |
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 also seems like a good candidate for its own widget:
content = _withRestoreEditMessageGestureDetector(context, content); | |
content = _RestoreEditMessageGestureDetector(messageId: item.message.id, child: content); |
lib/widgets/message_list.dart
Outdated
final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; | ||
final controller = composeBoxState?.controller; | ||
final editMessageInProgress = | ||
controller is EditMessageComposeBoxController | ||
|| editMessageErrorStatus != null; |
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 this logic all move into showMessageActionSheet
? Seems like between context
and message
it has all the inputs it needs in order to do the same calculations.
d519086
to
9953ac1
Compare
Thanks for the reviews! Revision pushed, still for maintainer review this time (but I've incorporated both reviews in this revision). |
Looks like there are some compilation errors in lib/widgets/message_list.dart, probably from some non-local merge conflicts. |
test/widgets/action_sheet_test.dart
Outdated
// It's impossible for the compose box to be in editing mode | ||
// while, for the same message, the message-edit request is either | ||
// in progress or in the error state. | ||
assert(!composing || errorStatus == null); |
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 see! Yeah this clears up the meaning of composing
.
I think it is still possible if you initiate the edit in both the channel narrow and the topic narrow containing it, then save the edit in the topic narrow, navigate back and save in the channel narrow, on the same message. If the first request takes long enough to finish. But that's quite an edge case, and is guarded by a containsKey check already.
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, good find! I can address this edge case in my next revision.
Thanks for the update! This looks good to me other than the compilation errors. |
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 to you both! Comments below. I haven't yet read the last commit's tests; I've read everything else in the branch.
test/example_data.dart
Outdated
editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp | ||
editTimestamp: editTimestamp ?? utcTimestamp(), |
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 is effectively still a constant, right? Just with more computation first.
The TODO here is to generate distinct timestamps for different messages, rather than have all the edits happen in the same second.
There's no rush to resolve that TODO (the fact that all the timestamps are the same hasn't caused a problem yet). But as long as the behavior is still a constant, I think adding this indirection isn't an improvement.
@@ -1446,6 +1446,26 @@ class MessageWithPossibleSender extends StatelessWidget { | |||
child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); | |||
} | |||
|
|||
Widget content = MessageContent(message: message, content: item.content); |
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: this two-line change can be squashed into the later commit that uses it
msglist [nfc]: Pull out `content` var in MessageWithPossibleSender.build
lib/api/model/submessage.dart
Outdated
/// | ||
/// This exists because [toJson] would just return [] otherwise. | ||
@visibleForTesting | ||
List<Submessage>? debugSubmessagesForToJson; |
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 works, but I wonder if we can do it a bit more cleanly and directly. How about:
@@ -61,12 +61,13 @@ class Submessage {
switch (widgetData) {
case PollWidgetData():
return Poll.fromSubmessages(
widgetData: widgetData,
pollEventSubmessages: submessages.skip(1),
messageSenderId: messageSenderId,
+ debugSubmessages: kDebugMode ? submessages : null,
);
Then Poll.handleSubmessageEvent could append to debugSubmessages, but only in debug mode.
That way eg.streamMessage
and eg.dmMessage
wouldn't have to separately set this; it'd be set to the intended value automatically.
This would also make it present in debug builds of the app, as well as in tests. That seems potentially helpful anyway. It's in a similar spirit to ContentNode.debugHtmlNode
.
test/widgets/action_sheet_test.dart
Outdated
// because its render box effectively has HitTestBehavior.deferToChild, and | ||
// the long-press might land on a child such that it doesn't hit-test as true, | ||
// like if it's in padding around a Paragraph. |
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:
// because its render box effectively has HitTestBehavior.deferToChild, and | |
// the long-press might land on a child such that it doesn't hit-test as true, | |
// like if it's in padding around a Paragraph. | |
// because its render box effectively has HitTestBehavior.deferToChild, and | |
// the long-press might land where no child hit-tests as true, | |
// like if it's in padding around a Paragraph. |
I'd think of that case as not really landing on any child at all — after all, the outcome of the hit-testing is precisely that it didn't hit any child at all.
lib/widgets/action_sheet.dart
Outdated
final composeBoxState = findMessageListPage().composeBoxState; | ||
if (composeBoxState == null) { | ||
throw StateError('Compose box unexpectedly absent when edit-message button pressed'); | ||
} | ||
final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); | ||
if (editMessageErrorStatus != null) { | ||
throw StateError('Message edit already in progress when edit-message button pressed'); | ||
} |
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.
These checks would be good after awaiting the raw content. That's a time when either of them really could fail — e.g. maybe the request took a while, and the user gave up and navigated away.
Then I think we don't actually need them before that await. That request itself doesn't rely on the facts these are checking; the part that does rely on those is when we attempt to start the edit interaction.
lib/widgets/compose_box.dart
Outdated
onPressed: () { | ||
composeBoxState.endEditInteraction(); | ||
}), |
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: can reduce to a tearoff:
onPressed: () { | |
composeBoxState.endEditInteraction(); | |
}), | |
onPressed: composeBoxState.endEditInteraction), |
lib/widgets/compose_box.dart
Outdated
// TODO(#1481) disabled appearance when there are validation errors | ||
ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonSave, | ||
attention: ZulipWebUiKitButtonAttention.high, | ||
onPressed: () { |
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: this callback is about 20 lines; once it's more than a small handful of lines, I think it's a bit easier to read as a separate named function, so here a private method on this class.
lib/widgets/message_list.dart
Outdated
switch (editMessageErrorStatus) { | ||
case false: |
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: How about if (!editMessageErrorStatus)
?
That'll have basically the same exhaustiveness-checking effect, too — if
requires a bool and will reject a bool?
.
lib/widgets/message_list.dart
Outdated
// TODO instead place within outer padding; see Figma: | ||
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4026-8775&m=dev | ||
LinearProgressIndicator( |
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 don't quite follow this comment. Which padding should this be placed within?
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 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, those screenshots don't really help. 🙂 How does this version differ from what's in Figma?
Looking into it a bit more, I think you're referring to the 4px of bottom padding on the MessageWithPossibleSender.
So in the Figma design, the indicator encroaches on that padding and gets closer to whatever's below (like the next message, or a date separator). In this version, when the indicator appears, it instead makes the message taller and stays 4px away from the sibling below.
I see the appeal of the version in Figma, but the difference is subtle. Happy to leave it as a TODO.
lib/widgets/message_list.dart
Outdated
composeBoxState.startEditInteraction(messageId: messageId, | ||
originalRawContent: store.takeFailedMessageEdit(messageId)); |
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. This originalRawContent
value is the right value to initialize the text input to — but it's not the right value to use when sending the resulting edit request to the server. For that, we want the original original raw content, because that's the value the user is (implicitly) expecting the server will have.
Probably the right fix is to add another field on _EditMessageRequestStatus
.
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 see; yeah, thanks for catching this.
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've had some more thoughts prompted by this—will share later today
9953ac1
to
db0a671
Compare
Thanks for the review! Revision pushed, PTAL. As discussed in the office on Friday, this makes some improvements over the last revision—but omits the refetch-raw-content request on restoring a failed edit, which I'd been considering. 🙂 (Omits it because the edge case it addresses is minuscule, and it adds time and risk of failure in bad network connections.) In particular:
Here are screenshots of that "Preparing…" state, with the normal state for comparison:
Giving the "Save" button a disabled appearance, in this state, is #1481; we don't have a design 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.
Thanks for the revision! This behavior all now looks good, except one glitch mentioned below which I'm fine with for launch.
I haven't yet read the tests in the last/main commit.
test/widgets/compose_box_test.dart
Outdated
await tester.pump(); | ||
await tester.pumpAndSettle(); |
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 this be reduced to e.g. two pumps? (Perhaps one of them with a duration.)
lib/api/model/submessage.dart
Outdated
}) { | ||
for (int index = 0; index < options.length; index += 1) { | ||
// Initial poll options use a placeholder senderId. | ||
// See [PollEventSubmessage.optionKey] for details. | ||
_addOption(senderId: null, idx: index, option: options[index]); | ||
} | ||
_debugSubmessages = debugSubmessages; |
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 put this inside an assert (or use kDebugMode
). That way we can be confident it's clear to the optimizer, in release builds, that this field is always a constant null and so can be left out of the class entirely.
lib/widgets/compose_box.dart
Outdated
final editMessageErrorStatus = store.getEditMessageErrorStatus(messageId); | ||
switch (editMessageErrorStatus) { |
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: simpler inlined
lib/widgets/compose_box.dart
Outdated
final confirmationDialogResult = await _discardDraftConfirmationDialog(); | ||
if (!confirmationDialogResult) return; |
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 is a bit hard to understand — partly because it's often/usually not actually the result of any dialog, and also because it's not obvious what the boolean result of the dialog means.
I think a rename would do it. Perhaps flipping the sense of the bool if that makes the naming easier: _canDiscardDraft
?
lib/widgets/compose_box.dart
Outdated
if (!confirmationDialogResult) return; | ||
if (!mounted) return; | ||
|
||
PerAccountStore store = PerAccountStoreWidget.of(context); |
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: final?
if (fetchedRawContent == null) { | ||
// Fetch-raw-content failed; abort the edit session. | ||
// An error dialog was already shown, by fetchRawContentWithFeedback. |
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.
It's regrettable that this all leads to so many lines of code (over 100) within _ComposeBoxState
, which is also about composing new messages with no editing of existing messages involved.
But this logic does have a lot of spots where it's setting the _controller
field on this state, either to an edit-message controller or back to a normal new-message controller. So I don't see an easy way it could be cleanly moved off to another class. Oh well, then.
lib/widgets/compose_box.dart
Outdated
final validationErrorMessages = | ||
controller.content.validationErrors.map((error) => | ||
error.message(zulipLocalizations)).toList(); | ||
showErrorDialog(context: context, | ||
title: zulipLocalizations.errorMessageEditNotSaved, | ||
message: validationErrorMessages.join('\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.
nit: no need to materialize List before .join
final validationErrorMessages = | |
controller.content.validationErrors.map((error) => | |
error.message(zulipLocalizations)).toList(); | |
showErrorDialog(context: context, | |
title: zulipLocalizations.errorMessageEditNotSaved, | |
message: validationErrorMessages.join('\n\n')); | |
final validationErrorMessages = | |
controller.content.validationErrors.map((error) => | |
error.message(zulipLocalizations)); | |
showErrorDialog(context: context, | |
title: zulipLocalizations.errorMessageEditNotSaved, | |
message: validationErrorMessages.join('\n\n')); |
]); | ||
} | ||
|
||
void _handleTapSave (BuildContext context) { |
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: callback above build
and other build methods
lib/widgets/compose_box.dart
Outdated
(_controller as EditMessageComposeBoxController) | ||
..originalRawContent = fetchedRawContent | ||
..content.value = TextEditingValue(text: fetchedRawContent) | ||
..contentFocusNode.requestFocus(); |
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 step doesn't seem to work for me empirically (on my Android 15 phone). When I hit "Edit message" on the message action sheet, the input doesn't get focused — I have to tap it to focus (and to cause the keyboard to appear).
OTOH the corresponding step in the _byRestoringFailedEdit
case does work.
Do you reproduce?
This is a perfectly acceptable glitch for launch: it just means an extra tap, and I think users are accustomed to having to sometimes tap an input field to get the keyboard to open when it seems like it should happen automatically. So let's only spend a small amount of effort now on diagnosing or fixing, and if that isn't enough then just file a short issue to record the point for post-launch.
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.
Debugged and fixed! 🎉
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.
(FTR, the fix was the addPostFrameCallback
here, for the reason its comment explains:
setState(() {
// setState to refresh the input, upload buttons, etc.
// out of the disabled "Preparing…" state.
editMessageController.originalRawContent = fetchedRawContent;
});
editMessageController.content.value = TextEditingValue(text: fetchedRawContent);
SchedulerBinding.instance.addPostFrameCallback((_) {
// post-frame callback so this happens after the input is enabled
editMessageController.contentFocusNode.requestFocus();
});
)
lib/widgets/compose_box.dart
Outdated
// This can happen if you start an edit interaction on one | ||
// MessageListPage and then do an edit on a different MessageListPage, | ||
// and the second edit is still saving when you return to the first. | ||
// | ||
// Abort rather than sending a request with a prevContentSha256 | ||
// that the server might not accept, and don't clear the compose | ||
// box, so the user can try again after the request settles. | ||
// TODO write a test for this | ||
showErrorDialog(context: context, |
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, I'm OK with skipping a test for this case.
When we set up the message list in tests, we do it by preparing the API response for a message fetch or new-message event, via JSON. But {Stream,Dm}Message.toJson drops poll data on the floor, which defeats setting up a poll-style message in the message list. Until now, anyway, with this workaround. A reference in a dartdoc to something called `prepareMessageWithSubmessages` was dangling; it seemed to be dangling when it first appeared, too, in 5af5c76; there's nothing by that name.
We're about to add a test that renders a poll-style message, and we'd get this warning on that.
Greg points out that we'll need both values when restoring a failed message edit: originalRawContent for the eventual edit-message request (for prevContentSha256), and newContent to fill the edit-message compose box, so the user can restore the edit session to what it was before it failed.
In the upcoming edit-message feature, the edit-message compose box will have a "Preparing..." state after you tap "Edit message" in the action sheet, while we're fetching the raw message content. The edit-message compose box shouldn't be interactable in this state.
db0a671
to
44a1df7
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! Glad that glitch turned out to have a nice clean fix.
All looks good now except one nit — oh and I should read the tests 🙂
case null: | ||
_editFromRawContentFetch(messageId); |
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.
bump #1498 (comment): put the two non-error cases next to each other
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! I put null
and false
together as the "two non-error cases", thinking of "error" as it's used in getEditMessageErrorStatus
which produces this value:
@override
bool? getEditMessageErrorStatus(int messageId) =>
_editMessageRequests[messageId]?.hasError;
But I see now what you mean, that the error case is the one where we show an error dialog. 😛
lib/widgets/compose_box.dart
Outdated
(_controller as EditMessageComposeBoxController) | ||
..originalRawContent = fetchedRawContent | ||
..content.value = TextEditingValue(text: fetchedRawContent) | ||
..contentFocusNode.requestFocus(); |
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.
(FTR, the fix was the addPostFrameCallback
here, for the reason its comment explains:
setState(() {
// setState to refresh the input, upload buttons, etc.
// out of the disabled "Preparing…" state.
editMessageController.originalRawContent = fetchedRawContent;
});
editMessageController.content.value = TextEditingValue(text: fetchedRawContent);
SchedulerBinding.instance.addPostFrameCallback((_) {
// post-frame callback so this happens after the input is enabled
editMessageController.contentFocusNode.requestFocus();
});
)
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, and here's a review of the remaining portion: the tests. They look good! Only small comments below.
These are definitely thorough. 😅 It's good to know that we can write such thorough tests of complex UI flows when we need to, and that we can do so while keeping them readable as these tests are. In general, though, I think our balance should be more in the direction of completing features faster, by spending less effort on comprehensive tests, than this represents.
One other aspect of these tests is that they have a noticeable runtime cost when running this test file. Here's timings on my office desktop (which is somewhat less speedy than my home desktop) with and without the edit message
test group (knocking it out by inserting a return;
):
$ time flutter test test/widgets/compose_box_test.dart
00:17 +116: All tests passed!
time: 19.781s wall (25.325s u, 3.213s s)
$ time flutter test test/widgets/compose_box_test.dart
00:10 +92: All tests passed!
time: 13.379s wall (16.966s u, 2.759s s)
So the 24 new test cases add about 6 seconds, or 50%, to the time to run the test file. Hardly a catastrophe, but enough to be a noticeable drag in the future when working on unrelated tests in this file (or unrelated changes to the compose box) and so repeatedly running this file.
I think one contributor to that runtime is that there's the beginnings of a combinatorial explosion: each test scenario gets run under three different narrows, including for interactions that seem unlikely to behave differently depending on the narrow.
Are there any of these where you could delete the doTestFoo
calls for some of the narrows and it'd feel like all the same codepaths are effectively tested? If so, it'd be worth doing that.
If not, then the next point might be too much change to be worth doing before merging this, but would be good to have in mind for future tests: some of the additional checks like these, within some of the doTestFoo
functions:
// Now that we have the raw content, check the input is interactive
// but no typing notifications are sent…
// […]
await enterContent(tester, 'some new content');
// […]
// …and the upload buttons work.
// […]
await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false);
seem like they'd be just as effective if they were run for only one narrow, and only one value of other parameters like start
. Separating them out is basically a form of making test cases more focused; and among the benefits of that sort of separation is that it can help a cluster of test cases end up doing N + M work, instead of N * M, while still effectively covering all the logic.
if (!boxInEditMode && errorStatus == null) { | ||
checkButtonIsPresent(expected); | ||
return; | ||
} |
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 structure took me a bit to get. Perhaps be more explicit about it earlier:
if (!boxInEditMode && errorStatus == null) { | |
checkButtonIsPresent(expected); | |
return; | |
} | |
if (!boxInEditMode && errorStatus == null) { | |
// The state we're testing is present on the original action sheet. | |
checkButtonIsPresent(expected); | |
return; | |
} | |
// The state we're testing requires a previous "edit message" action | |
// in order to set up. Use the first action sheet for that setup step. |
// We're testing the request-failed state. Prepare a failure | ||
// and tap Save. | ||
connection.prepare( | ||
json: UpdateMessageResult().toJson(), delay: Duration(seconds: 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.
This comment and code don't seem to match.
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.
Eep, thanks!
check(find.byType(BottomSheet)).findsOne(); | ||
checkButtonIsPresent(expected); | ||
|
||
await tester.pump(Duration(milliseconds: 500)); |
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.
What's this delay do?
It seems like not enough time to expire the timer in the errorStatus
true case.
check(findComposeBoxController(tester)) | ||
.isNotNull() | ||
.isA<FixedDestinationComposeBoxController>(); |
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: redundancy
check(findComposeBoxController(tester)) | |
.isNotNull() | |
.isA<FixedDestinationComposeBoxController>(); | |
check(findComposeBoxController(tester)) | |
.isA<FixedDestinationComposeBoxController>(); |
} | ||
|
||
void doSmokeTest({required Narrow narrow, required _EditInteractionStart start}) { | ||
final description = 'smoke: ${[narrow.toString(), start.message()].join(', ')}'; |
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.
final description = 'smoke: ${[narrow.toString(), start.message()].join(', ')}'; | |
final description = 'smoke: $narrow, ${start.message()}'; |
That's equivalent, right?
final description = 'smoke: ${[narrow.toString(), start.message()].join(', ')}'; | ||
testWidgets(description, (tester) 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.
nit: then can also inline description
} | ||
|
||
|
||
/// Starts an interaction from the action sheet's 'Edit message' button. |
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: double blank line
// Test the "Discard…?" confirmation dialog when there's text in the compose | ||
// box for a new message, but you've said you want to edit a message. | ||
void doTestInterruptComposingFromFailedEdit({required Narrow 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.
This comment doesn't seem to match the name. (The comment seems to say the same thing as the comment on doTestInterruptComposingFromActionSheet above.)
check(connection.takeRequests()).deepEquals([ | ||
(Subject<Object?> it) => it.isA<http.Request>() |
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: this sort of deepEquals
argument is a bit shorter using Condition
:
check(connection.takeRequests()).deepEquals([ | |
(Subject<Object?> it) => it.isA<http.Request>() | |
check(connection.takeRequests()).deepEquals(<Condition<Object?>>[ | |
(it) => it.isA<http.Request>() |
checkNotInEditingMode(tester, narrow: narrow); | ||
}); | ||
} | ||
doTestInterruptComposingFromFailedEdit(narrow: channelNarrow); |
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: for helpers that define a test case like test
and testWidgets
do, name them testFoo
to match those:
doTestInterruptComposingFromFailedEdit(narrow: channelNarrow); | |
testInterruptComposingFromFailedEdit(narrow: channelNarrow); |
We're mostly consistent about that practice already; for examples, see git grep -P '\btest[^W]\w+\('
. (Or refine the pattern to possibly find more examples; it's excluding testWidgets
but crudely so.)
Edit-message! 🎉
Fixes: #126
Screenshots