- Notifications
You must be signed in to change notification settings - Fork 374
Offer "Invisible mode" toggle switch! #1631
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
| Here's how it could look after some upstream work (flutter/flutter#131478). This removes the differences from the Figma that I mentioned in the PR description: |
7fd821c to 4881bd5 Compare | Yeah, I guess depends on how much we expect users to actually send us those details. If not, probably not super helpful to show them, unless we can translate them to something more readable (e.g., just say "Network error" or "Couldn't connect to server." or something). |
| I think Sentry is for debugging, and we want some generic network error as the thing to present in the UI. |
| (And I'll add a TODO(#741) for interpreting the error) |
4881bd5 to 159c6d7 Compare 159c6d7 to b67ac62 Compare
rajveermalviya left 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.
Thanks for working on this @chrisbobbe! Works great in local testing.
Couple small comments, and I see that the some tests are TODO (e.g. PerAccountSettingBuilder), otherwise LGTM.
| /// so until the spam-taps are finished, the switch responds only to the taps, | ||
| /// not to the event stream. | ||
| /// Then when the taps stop, it settles to the value from the latest event.) | ||
| static final Duration localEchoMininum = 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.
nit:
| static final Duration localEchoMininum = Duration(seconds: 1); | |
| static final Duration localEchoMinimum = 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.
nit:
| }); | |
| } | |
| }); | |
| } |
b67ac62 to a71d75d Compare | Thanks for the review! Revision pushed, with some tests added. |
gnprice left 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.
Thanks! Generally this looks great; comments below.
I haven't yet read all the logic in PerAccountSettingBuilder (pending a couple of questions / requests for dartdoc below), or the tests in the last commit. I've read everything else, so including everything before the last commit:
57da943 api: Require InitialSnapshot.userSettings, present since Zulip Server 5
ebdb035 api: Add InitialSnapshot.userSettings.presenceEnabled
f373c94 api: Add updateSettings
4f114af button [nfc]: Extract MenuButton{,sShape} out to here, from action sheet
847b326 button: Fix an inconsistency with Figma in MenuButton
3e496ec button: Give MenuButton a beforeIcon param, to use for a switch
f5598f5 button [nfc]: Factor MenuButton's vertical padding as around just the text
428e97e button: Implement "toggle" component from Figma, for "Invisible mode"
ed544a7 profile test [nfc]: Rename group about custom fields; pull out some tests
and part of that last commit:
a71d75d profile: Add toggle switch for "Invisible mode", with local echo
lib/api/route/settings.dart Outdated
| part 'settings.g.dart'; | ||
| | ||
| /// https://zulip.com/api/update-settings | ||
| Future<UpdateSettingsResult> updateSettings(ApiConnection connection, { |
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:
api: Add updateSettings add the word "route" at the end — it wasn't clear to me before reading the code what sort of a thing "updateSettings" was
lib/api/model/initial_snapshot.dart Outdated
| // (1) add it to the [UserSettingName] enum | ||
| // (2) then re-run the command to refresh the .g.dart files | ||
| // (3) handle the event that signals an update to the setting | ||
| // (4) add the setting to the updateSettings binding |
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: similarly,
| // (4) add the setting to the updateSettings binding | |
| // (4) add the setting to the [updateSettings] route binding |
lib/api/route/settings.dart Outdated
| class UpdateSettingsResult { | ||
| UpdateSettingsResult(); | ||
| | ||
| factory UpdateSettingsResult.fromJson(Map<String, dynamic> json) => |
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.
Since there's no content in this result, we can skip it — just have the endpoint function return Future<void>. Grep for "Future" in this directory to see other examples.
| final newSettings = <UserSettingName, Object?>{}; | ||
| final expectedBodyFields = <String, String>{}; | ||
| | ||
| for (final name in UserSettingName.values) { |
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: join declarations with their initializers
| final newSettings = <UserSettingName, Object?>{}; | |
| final expectedBodyFields = <String, String>{}; | |
| for (final name in UserSettingName.values) { | |
| final newSettings = <UserSettingName, Object?>{}; | |
| final expectedBodyFields = <String, String>{}; | |
| for (final name in UserSettingName.values) { |
| for (final name in UserSettingName.values) { | ||
| switch (name) { | ||
| case UserSettingName.twentyFourHourTime: | ||
| newSettings[name] = true; | ||
| expectedBodyFields['twenty_four_hour_time'] = 'true'; | ||
| case UserSettingName.displayEmojiReactionUsers: |
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.
Interesting, so this ensures that future additions to the enum get tested on this endpoint.
| } | ||
| } | ||
| | ||
| class _LocalEchoNotifier<T> extends ValueNotifier<_LocalEchoValue<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.
I think this would benefit from a short dartdoc: particularly on what it means to have a value here, or for [value] to be null.
| }); | ||
| } | ||
| | ||
| Future<void> stop() 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.
Also good to document would be what this Future represents — what does it mean to await it?
lib/widgets/profile.dart Outdated
| if (!store.realmPresenceDisabled && userId == store.selfUserId) ...[ | ||
| const SizedBox(height: 16), | ||
| MenuButtonsShape(buttons: [ |
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 pull this whole MenuButtonsShape subtree out to its own widget — e.g. call it _InvisibleModeToggle — so that this just has the condition, the spacers, and a reference to that widget. I think that'll help keep this more organized to read.
(The same might also be good for some of these other items; but this is more complex than any of the existing items here. And conversely it doesn't share interactions like oh they're all centered, they're all using _TextStyles.primaryFieldText, which are advantages to having the others inline next to each other.)
lib/widgets/profile.dart Outdated
| findValueInStore: (store) => !store.userSettings.presenceEnabled, | ||
| sendValueToServer: (value) => updateSettings(store.connection, | ||
| newSettings: {UserSettingName.presenceEnabled: !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.
Hmm, interesting that findValueInStore gets passed the store to use, but then sendValueToServer ends up using the store it got from the parent widget.
(This is fine to ship, doesn't need to be refactored, but feels like it suggests the API could ideally be adjusted)
lib/widgets/button.dart Outdated
| children: [ | ||
| if (beforeIcon != null) beforeIcon!, | ||
| if (icon != null) Icon(icon, color: designVariables.contextMenuItemText), |
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.
Interesting that this provides for both an icon and some other widget before it. Are there examples of that in the Figma designs?
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 at the Figma link in the dartdoc 🙂:
https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60681&m=dev
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, indeed 😄
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 name "before icon" feels like it doesn't quite get to the point — I feel like I have to think a bit to work through what it's supposed to end up meaning. And in the current example, as the dartdoc says:
/// An element to go before [icon], or in its place if it's null. /// /// E.g. a [Toggle]. final Widget? beforeIcon;this "before icon" widget appears even though there is no icon.
How about a name like "control"? It's the visible control in the button, when the button has such a thing.
Or perhaps with less generalization: "toggle"? That's the name it has in that Figma subtree: the children are "label", "toggle", "icon". If we later have a menu button somewhere that has some other kind of widget in here other than a toggle, I'm not sure we'll necessarily want to treat it the same way.
a71d75d to f7ebd5a Compare | Thanks for the review! Revision pushed. |
f7ebd5a to 62029e3 Compare | And pushed again, with the "before icon" / "toggle" change suggestion #1631 (comment) |
gnprice left 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.
Thanks for the revisions! Just read the changes and re-scanned my comments above; all looks good except a few small comments below.
Next I'll read the remaining parts of the last/main commit, as mentioned at #1631 (review).
| check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); | ||
| }); | ||
| | ||
| testWidgets('page builds; dm links to correct narrow', (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:
Most of the tests in this ProfilePage group are about custom profile fields. Pull out the two that aren't, and rename the group. s/two/handful/ or some other update 🙂 (after #1631 (comment))
| /// But the local echo minimizes flickering (see [localEchoMinimum]) | ||
| /// while ensuring that the real in-store value is shown soon after the | ||
| /// user finishes interacting, whether the request(s) succeeded or failed. | ||
| typedef PerAccountSettingBuilderFn<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.
nit: rename this to match the class; rename the file, too
| /// | ||
| /// If extending a local-echo period, replaces the old local-echo value. | ||
| /// | ||
| /// This may be called any time. API requests are not debounced, |
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" should be [handleRequestNewValue], right? Not the function whose type this is documenting.
| /// The session will be stopped either immediately or | ||
| /// [RemoteSettingBuilder.localEchoMinimum] after the last [startOrExtend] call, | ||
| /// whichever is later. | ||
| /// | ||
| /// The returned [Future] resolves when the session is stopped. | ||
| Future<void> stop() 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.
Very helpful, thanks.
gnprice left 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.
OK, and finished reading the rest. Generally this all looks good — comments below, most notably some checks missing in the tests.
| final value = switch (_notifier.value) { | ||
| OptionSome(:final value) => value, | ||
| OptionNone() => widget.findValueInStore(store), | ||
| }; |
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 guess this would be a use case for adding an orElse:
| final value = switch (_notifier.value) { | |
| OptionSome(:final value) => value, | |
| OptionNone() => widget.findValueInStore(store), | |
| }; | |
| final value = _notifier.value.orElse(() => widget.findValueInStore(store)); |
(taking API inspiration from Rust's Option, per the Option dartdoc)
This version is fine, though.
| // (Create the PerAccountStoreWidget dependency unconditionally.) | ||
| final 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.
Is there a reason we actively want to do this? That's what I take the comment to mean, but then I'm curious what the reason is 🙂
(I think it's harmless to do so — in fact we'll always have the dependency anyway before even reaching the build method, because we use it from didChangeDependencies)
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 thinking was:
didChangeDependenciesneeds to be called when local-echo isn't active, which means the dependency needs to exist when local-echo isn't active- The (only) way to create a dependency is by calling
PerAccountStoreWidget.ofin the build method; it doesn't count if we call it indidChangeDependencies - The dependency might disappear if a
buildcall doesn't callPerAccountStoreWidget.of(I remember being pretty sure about this but can't right away find where I read about it)
I think I was indeed wrong on the second point though 🙂—
/// This method should not be called from widget constructors or from /// [State.initState] methods, because those methods would not get called /// again if the inherited value were to change. To ensure that the widget /// correctly updates itself when the inherited value changes, only call this /// (directly or indirectly) from build methods, layout and paint callbacks, /// or from [State.didChangeDependencies] (which is called immediately after /// [State.initState]).so yeah, the comment isn't guarding against a potential bug, and I'll remove 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.
- The dependency might disappear if a
buildcall doesn't callPerAccountStoreWidget.of(I remember being pretty sure about this but can't right away find where I read about it)
There've been occasional requests upstream for this behavior. But the current behavior is that once an element has a dependency, it keeps it throughout its lifetime; there's no attempt to clear out dependencies.
See background here:
flutter/flutter#12992 (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.
Good to know, thanks!
| if (_disposed) return; | ||
| // Don't call _notifier.stop(). We do that when the event arrives, | ||
| // causing the in-store value to change (see didChangeDependencies). | ||
| } catch (e) { |
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 potentially a real error, so let's leave a TODO(log) comment:
| } catch (e) { | |
| } catch (e) { // TODO(log) |
| } | ||
| | ||
| | ||
| class _ProfileErrorPage extends StatelessWidget { |
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
| factory _LocalEchoNotifier() { | ||
| return _LocalEchoNotifier._(OptionNone()); | ||
| } | ||
| | ||
| _LocalEchoNotifier._(super.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.
nit: can simplify:
| factory _LocalEchoNotifier() { | |
| return _LocalEchoNotifier._(OptionNone()); | |
| } | |
| _LocalEchoNotifier._(super.value); | |
| _LocalEchoNotifier() : super(OptionNone()); |
| void dispose() { | ||
| _lowerBoundTimer?.cancel(); | ||
| _upperBoundTimer?.cancel(); | ||
| _disposed = true; | ||
| super.dispose(); |
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.
Also _lowerBoundCompleter?.complete()?
Otherwise, I think if there's an outstanding stop call awaiting the completer's future, then that will hang around forever because the future never completes. Which might then keep a _handleRequestNewValue call hanging around, and therefore the state object and widget object, and so on.
test/widgets/profile_test.dart Outdated
| final toggle = tester.widget<Toggle>(findToggle); | ||
| check(toggle.value).equals(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.
nit: this is equivalent, right?
| final toggle = tester.widget<Toggle>(findToggle); | |
| check(toggle.value).equals(expected); | |
| check(getValue(tester)).equals(expected); |
test/widgets/profile_test.dart Outdated
| // Events will be coming in, but those don't control the switch; | ||
| // only the user interaction does, until there have been no interactions | ||
| // for [PerAccountSettingBuilder.localEchoMinimum]. | ||
| await doSpamTap(); | ||
| await tester.pump(Duration(milliseconds: 90)); | ||
| await doSpamTap(); |
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 doesn't seem like these actually check that events don't control the switch.
In fact I tried deleting the key part of stop:
Future<void> stop() async { - if (_lowerBoundCompleter != null) { - await _lowerBoundCompleter!.future; - if (_disposed) return; - } value = OptionNone(); }and it seems tests still pass.
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, indeed! Will fix.
test/widgets/profile_test.dart Outdated
| checkAppearsActive(tester, true); | ||
| checkRequest(true); | ||
| | ||
| // After [PerAccountSettingBuilder.localEchoMinimum], changes back | ||
| await tester.pump(RemoteSettingBuilder.localEchoMinimum); |
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, this should gain a check that the appearance doesn't change before that minimum (when the request failure comes back).
62029e3 to d34e419 Compare | Thanks! Revision pushed. |
gnprice left 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.
Thanks for the revision! Just small comments now.
| /// The value contained in this option, if any; | ||
| /// else the value returned by [fn]. | ||
| T orElse(T Function() fn); |
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: specify the other key piece of this method's semantics:
| /// The value contained in this option, if any; | |
| /// else the value returned by [fn]. | |
| T orElse(T Function() fn); | |
| /// The value contained in this option, if any; | |
| /// else the value returned by [fn]. | |
| /// | |
| /// [fn] is called only if its return value is needed. | |
| T orElse(T Function() fn); |
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 and I guess test that too, e.g. by having the callback throw.
| // The appearance doesn't change as soon as the request errors, | ||
| // if it errored quickly… | ||
| await tester.pump(requestDuration); | ||
| checkAppearsActive(tester, 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.
We've seen elsewhere (those route transitions) that sometimes one needs to call tester.pump for a duration that's epsilon longer than nominally should be necessary in order to get something to happen.
So when a test is, like here, checking that something doesn't happen, it's not entirely convincing as a reader unless it waits for a bit longer than the nominal time, for good measure.
Could say requestDuration + Duration(milliseconds: 1). Or could say like Duration(milliseconds: 101), or Duration(milliseconds: 200), and let the reader compare to the 100ms constant above.
| await doSpamTap(expectedCurrentValue: false); | ||
| | ||
| await tester.pump(RemoteSettingBuilder.localEchoMinimum - Duration(milliseconds: 1)); | ||
| check(getValue(tester)).equals(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: I think a bit more explicitness would help understanding here:
| await doSpamTap(expectedCurrentValue: false); | |
| await tester.pump(RemoteSettingBuilder.localEchoMinimum - Duration(milliseconds: 1)); | |
| check(getValue(tester)).equals(true); | |
| await doSpamTap(expectedCurrentValue: false); | |
| check(getValue(tester)).equals(true); | |
| await tester.pump(RemoteSettingBuilder.localEchoMinimum - Duration(milliseconds: 1)); | |
| check(getValue(tester)).equals(true); |
(That is the right expectation, right?)
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.
Yep!
| I left a design comment on CZO: #mobile-design > presence: invisible mode @ 💬 |
| I'm planning to include this PR in a v30.0.261 release tomorrow. It'd be great to have it merged first, and to have that design tweak made. |
d34e419 to ada007e Compare | Thanks for the reviews and design guidance! Revision pushed, with three new commits: 60a3e2b button: Fix MenuButton's icon color The first of those fixes a bug in
New screenshots:
|
36d9735 to 5cece3a Compare | Thanks both! Revision pushed. |
gnprice left 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.
Thanks! Looks good; one nit and a small question.
lib/widgets/button.dart Outdated
| if (ancestor != null) return true; | ||
| throw FlutterError.fromParts([ | ||
| ErrorSummary('No MenuButtonsShape ancestor found.'), | ||
| ErrorDescription('MenuButton widgets require a MenuButtonsShape 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.
nit: one stray reference here to the MenuButton name
| contextMenuItemIcon: const Color(0xff4f42c9), | ||
| contextMenuItemLabel: const Color(0xff242631), | ||
| contextMenuItemMeta: const Color(0xff626573), | ||
| contextMenuItemText: const Color(0xff381da7), |
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.
button: Fix MenuButton's icon color Oops, this wasn't following the Figma. Fixed. Hmm good catch.
I guess this affects the icons in the action sheets? Is that the existing place this affects?
Looks like the difference is pretty subtle, thankfully. Here's a quick preview, from my IDE showing color swatches in the left gutter next to the source code:

Bringing those right next to each other (new above, old below):

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.
Yep yep, and I posted a screenshot of the change in the action sheet: #1631 (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.
Ah indeed, thanks :-)
Most of the tests in this ProfilePage group are about custom profile fields. Pull out some that aren't, and rename the group.
The PerAccountSettingBuilder widget should be helpful for plenty of other things too, like channel settings. I think we'll get an updated design for the profile page soon, but pending that, this is our current thinking for how "Invisible mode" should look: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/presence.3A.20invisible.20mode/near/2204336 Fixes: zulip#1578
Oops, this wasn't following the Figma. Fixed.
And rename MenuButton to ZulipMenuItemButton, since it's no longer only about the Figma's "menu button" component.
5cece3a to 9b5a637 Compare | Thanks! Revision pushed. |
| Thanks! Looks good; merging. |
Added in zulip/zulip-flutter#1631. (cherry picked from commit cb35d41)























Still TODO: tests.Some known differences from the Figma, which won't be hard to fix but require upstream changes, so not totally trivial:
When the switch is on:
When the switch is off:
(edit: screenshots of what it would look like with these differences resolved: #1631 (comment) , and I filed #1636 for these fixes)
Fixes: #1578