-
Notifications
You must be signed in to change notification settings - Fork 214
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
Support topic muting/unmuting/following #1041
Conversation
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! Quick initial comments from skimming this draft.
lib/widgets/action_sheet.dart
Outdated
@override void onPressed(BuildContext context) async { | ||
Navigator.of(context).pop(); | ||
try { | ||
await updateUserTopic( |
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 seems more specific than the name TopicActionSheetButton
reflects. We'll have other options in the topic action sheet that aren't about visibility policy, like resolve/unresolve.
It makes sense to group all these options under a common base class; it should just get a more specific name.
lib/widgets/action_sheet.dart
Outdated
|
||
final int streamId; | ||
final String topic; | ||
final BuildContext topicParentContext; |
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 name doesn't feel right conceptually. I'll see about sending a quick PR refactoring the existing message-action-sheet options that might help clarify what this field is about.
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.
Sent #1044.
c21f055
to
be64a22
Compare
0b6cdac
to
11209cc
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! Comments below, and I agree with your comment at #348 (comment) 🙂
I think a design requirement of this feature is to also display mute/following states for each topic. At this point we can borrow that from the legacy mobile app.
lib/widgets/action_sheet.dart
Outdated
|
||
void _showActionSheet( | ||
BuildContext context, { | ||
required List<Widget> optionButtons, |
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.
required List<Widget> optionButtons, | |
required List<ActionSheetMenuItemButton> optionButtons, |
lib/widgets/action_sheet.dart
Outdated
void showMessageActionSheet({required BuildContext context, required Message message}) { | ||
void showMessageActionSheet(BuildContext context, {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.
action_sheet [nfc]: Make context an unamed parameter
nit: "unnamed" (spelling); also, is there room in the summary line to name the function whose params we're talking about? I guess there's just one other function in the action_sheet subsystem with a named context param, fetchRawContentWithFeedback
. How about:
action_sheet [nfc]: Make showMessageActionSheet's context an unnamed param
lib/api/route/channels.dart
Outdated
// There can be an error when muting a topic that is already muted | ||
// or unmuting one that is already unmuted. Let it throw because we can't | ||
// reliably filter out the error, which doesn't have a specific "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.
It's not clear from the comment that this would be the right layer to "filter out" such errors even if we could; do we need the 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.
I agree that this isn't the right layer to handle the error. Since we are not planning to catch that anyway before we drop this fallback, calling this out doesn't really do anything helpful. I will just move this to the commit message.
lib/api/route/channels.dart
Outdated
// There can be an error when muting a topic that is already muted | ||
// or unmuting one that is already unmuted. Let it throw because we can't | ||
// reliably filter out the error, which doesn't have a specific "code". | ||
return connection.post('muteTopic', (_) {}, 'users/me/subscriptions/muted_topics', { |
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.
Should be connection.patch
: https://zulip.com/api/mute-topic
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 catch! Looks like we don't even have the connection.patch
method; will add it in a prep commit.
lib/widgets/message_list.dart
Outdated
@@ -305,7 +305,7 @@ class MessageListAppBarTitle extends StatelessWidget { | |||
}) { | |||
// A null [Icon.icon] makes a blank space. | |||
final icon = (stream != null) ? iconDataForStream(stream) : null; | |||
return Row( | |||
final appBar = Row( |
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.
appBar
doesn't sound like the right name for something that can get returned from a function named _buildStreamRow
. How about just result
?
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, the name _buildStreamRow
seems like a hint that it's not really the right place for something that opens a topic action sheet. I think we want a comment in the if (narrow case TopicNarrow
that the added GestureDetector
will go somewhere else or be removed as part of #1039.
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.
While updating this PR to support message list app bar with two rows, I found a better place where this GestureDetector
belongs to:
case TopicNarrow(:var streamId, :var topic):
final store = PerAccountStoreWidget.of(context);
final stream = store.streams[streamId];
return SizedBox(
width: double.infinity,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPress: () => showTopicActionSheet(context,
channelId: streamId, topic: topic),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStreamRow(context, stream: stream),
_buildTopicRow(context, stream: stream, topic: topic),
])));
to: UserTopicVisibilityPolicy.none); | ||
|
||
Future<void> setupToTopicActionSheet(WidgetTester tester, { | ||
required bool? isChannelMuted, |
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 be more explicit about what null
means for isChannelMuted
. I think it's supposed to mean that the self-user isn't subscribed to the channel (right?), but that isn't clear from test-failure output—
example
$ flutter test test/widgets/action_sheet_test.dart
00:02 +17: UserTopicUpdateButton check expected buttons null UserTopicVisibilityPolicy.none
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: a Finder that:
Actual: means none were found but one was expected
Which: exactly one matching candidate
When the exception was thrown, this was the stack:
#0 check.<anonymous closure> (package:checks/src/checks.dart:85:9)
#1 _TestContext.expect (package:checks/src/checks.dart:708:12)
#2 LegacyMatcher.legacyMatcher (package:legacy_checks/src/matcher_compat.dart:27:13)
#3 FinderBaseChecks.findsOne (package:flutter_checks/src/flutter_checks.dart:187:5)
#4 main.<anonymous closure>.checkButtons (file:///Users/chrisbobbe/dev/zulip-flutter/test/widgets/action_sheet_test.dart:225:39)
#5 main.<anonymous closure>.<anonymous closure>.<anonymous closure> (file:///Users/chrisbobbe/dev/zulip-flutter/test/widgets/action_sheet_test.dart:322:11)
<asynchronous suspension>
#6 testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:189:15)
<asynchronous suspension>
#7 TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1027:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
The test description was:
null UserTopicVisibilityPolicy.none
════════════════════════════════════════════════════════════════════════════════════════════════════
00:02 +17 -1: UserTopicUpdateButton check expected buttons null UserTopicVisibilityPolicy.none [E]
Test failed. See exception logs above.
The test description was: null UserTopicVisibilityPolicy.none
or from reading the tests, except if you dig into the implementation of setupToTopicActionSheet
. A minimal fix would be to give this function a dartdoc and update the test descriptions where they currently just say $isChannelMuted
.
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 be more explicit about what null means for isChannelMuted. I think it's supposed to mean that the self-user isn't subscribed to the channel (right?), but that isn't clear from test-failure output—
Yeah. Added dartdoc, updated the test description and left the topic names as-is, because the description is what shows up when a test fails.
lib/widgets/action_sheet.dart
Outdated
case UserTopicVisibilityPolicy.unknown: | ||
assert(false); | ||
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.
Can this case be reached? It seems like we should either:
- Reject unrecognized policy values at the edge as malformed server data, or
- Handle them gracefully in the app, e.g., not handle them by giving up on showing the topic action sheet, which might still have useful buttons like "move topic" or "resolve topic" etc. once we implement those
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. By having this unknown
value in the enum we're already paying most of the cost of gracefully handling the situation where the server introduces a new policy value, so we should handle it here too.
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 do not expect it here because we already semi-explicitly keep it out of our data structure, so it would be a bug to get unknown
here. However, I think it is not obvious that we are doing so because the enum always includes unknown
.
zulip-flutter/lib/model/channel.dart
Lines 216 to 222 in 9e42f26
static bool _warnInvalidVisibilityPolicy(UserTopicVisibilityPolicy visibilityPolicy) { | |
if (visibilityPolicy == UserTopicVisibilityPolicy.unknown) { | |
// Not a value we expect. Keep it out of our data structures. // TODO(log) | |
return true; | |
} | |
return false; | |
} |
zulip-flutter/lib/model/channel.dart
Lines 348 to 350 in 9e42f26
if (_warnInvalidVisibilityPolicy(visibilityPolicy)) { | |
visibilityPolicy = UserTopicVisibilityPolicy.none; | |
} |
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, indeed. In that case it doesn't matter what this line does — though it's still probably cleaner to have it skip this type of button rather than abort the whole function. And it should have a comment saying why it's impossible.
I've noticed in a few other places that it'd be good to have a type for "visibility policy, but only the known/valid ones". Probably that points to removing unknown
from the enum, and using a nullable type where we actually want that value. Out of scope for this PR, though.
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 will allow us to remove the cases here (where this new assert(false)
originally comes from). It is good to have the type confirm that we process the unknown values at the edge.
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.
Opened #1074 and included TODO comments linked in this PR. (Also removed assert(false)
's under UserTopicVisibilityPolicy.none
because it was incorrectly grouped with UserTopicVisibilityPolicy.unknown
)
lib/widgets/action_sheet.dart
Outdated
// Not subscribed to the channel; there is no user topic change to be made. | ||
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.
The comment helps understand what this case is about, but I think I would remove the return
.
Then, we can add chunks of code for more buttons in the future, like copy-link-to-topic, without having to go back and think about this other chunk of code that mostly looks like it's about different buttons.
lib/widgets/action_sheet.dart
Outdated
} | ||
|
||
if (optionButtons.isEmpty) { | ||
assert(!supportsUnmutingTopics); |
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 we don't need this assert
; it should be redundant with tests, and it doesn't unlock any opportunities to simplify code that comes after it.
lib/widgets/action_sheet.dart
Outdated
if (optionButtons.isEmpty) { | ||
assert(!supportsUnmutingTopics); | ||
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.
When we return before showing the action sheet, this long-press handler is a no-op. Screen reader software can't tell it's a no-op, so I think it still presents the element as though it offers a long-press interaction, which isn't really accurate.
So for accessibility, what we'd normally want is to pass null
to the GestureDetector
instead of a no-op function. Alternatively we could design an "empty" appearance for the action sheet.
Those solutions could be finicky or take some time—probably our solution is to eventually just remove the case where the action sheet has no buttons, by implementing a button that's present unconditionally. So for now let's just leave a TODO(a11y)
for that. (I think "Copy link to topic" would be such a button.)
Thanks for the review! Updated the PR. |
21cccab
to
8bd44c2
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! Comments below, including some things I missed last time (oops).
|| visibilityPolicy == UserTopicVisibilityPolicy.muted); | ||
final op = visibilityPolicy == UserTopicVisibilityPolicy.none ? 'remove' | ||
: 'add'; | ||
return connection.patch('muteTopic', (_) {}, 'users/me/subscriptions/muted_topics', { |
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.
api: Add route updateUserTopic
For the legacy case, there can be an error when muting a topic that
is already muted or unmuting one that is already unmuted. Let it
throw because we can't reliably filter out the error, which doesn't
have a specific "code".
Signed-off-by: Zixuan James Li <[email protected]>
Moving this from a code comment to the commit message doesn't make it more convincing 🙂—as I understand it, the reason we don't catch the error is the same reason we don't generally catch API errors in the binding layer: we want the bindings to correspond as closely as possible to the documented API, as a thin wrapper, so they don't hide or mess with things that callers might be interested in.
I see just one catch
in lib/api/route—
/// Convenience function to get a single message from any server.
///
/// This encapsulates a server-feature check.
///
/// Gives null if the server reports that the message doesn't exist.
// TODO(server-5) Simplify this away; just use getMessage.
Future<Message?> getMessageCompat(ApiConnection connection, {
required int messageId,
bool? applyMarkdown,
}) async {
final useLegacyApi = connection.zulipFeatureLevel! < 120;
if (useLegacyApi) {
final response = await getMessages(connection,
narrow: [ApiNarrowMessageId(messageId)],
anchor: NumericAnchor(messageId),
numBefore: 0,
numAfter: 0,
applyMarkdown: applyMarkdown,
// Hard-code this param to `true`, as the new single-message API
// effectively does:
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/.60client_gravatar.60.20in.20.60messages.2F.7Bmessage_id.7D.60/near/1418337
clientGravatar: true,
);
return response.messages.firstOrNull;
} else {
try {
final response = await getMessage(connection,
messageId: messageId,
applyMarkdown: applyMarkdown,
);
return response.message;
} on ZulipApiException catch (e) {
if (e.code == 'BAD_REQUEST') {
// Servers use this code when the message doesn't exist, according to
// the example in the doc.
return null;
}
rethrow;
}
}
}
In that case we have an explicitly thicker wrapper (marked "compat") that encapsulates a difference between legacy and current behavior, so callers don't have to think about the two behaviors separately. (Because of that catch
, callers don't need both a null
check and their own catch
.)
That kind of encapsulation might actually be a fine reason to want to "filter out" these errors in this binding layer, I'm not sure. But if that's the reason, it's not clear from your paragraph about it. Are the errors useful on those legacy servers, or are they basically just an API wart? Let's write an opinion on that first, if we're going to talk about dropping things from the server, instead of just saying we don't drop things because we can't drop them.
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. The story is that the caller of this is not expected to handle the error, mostly because it isn't helpful to the user. If the topic is already muted/unmuted, and that the user wants it to be muted/unmuted, respectively, there really isn't anything that requires actions from the user:
api: Add route updateUserTopic
For the legacy case, there can be an error when muting a topic that
- is already muted or unmuting one that is already unmuted. Let it
- throw because we can't reliably filter out the error, which doesn't
- have a specific "code".
+ is already muted or unmuting one that is already unmuted.
+
+ The callers are not expected to handle such errors because they aren't
+ really actionable.
case UserTopicVisibilityPolicy.unknown: | ||
// TODO(#1074): This should be unreachable as we keep `unknown` out of | ||
// our data structures. | ||
assert(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.
case UserTopicVisibilityPolicy.unknown: | |
// TODO(#1074): This should be unreachable as we keep `unknown` out of | |
// our data structures. | |
assert(false); | |
case UserTopicVisibilityPolicy.unknown: | |
// TODO(#1074): This should be unreachable as we keep `unknown` out of | |
// our data structures. | |
assert(false); |
At first I read "TODO" and "This should be unreachable" as saying that the case is currently reachable and that we plan to make it unreachable in #1074. In reality, it's currently unreachable (or we expect so, anyway), and we want the case to disappear in #1074. Is that correct?
So I think we'll want
// TODO(#1074) remove this
unknown(apiValue: null);
in the UserTopicVisibilityPolicy
definition, right? Then when we do that TODO, these unknown
cases will fall away naturally as part of that (the analyzer will help us there), so they don't need their own separate TODOs. How about:
case UserTopicVisibilityPolicy.unknown:
// This case is unreachable (or should be) because we keep `unknown` out
// of our data structures. We plan to remove the `unknown` case in #1074.
assert(false);
lib/widgets/action_sheet.dart
Outdated
if (optionButtons.isEmpty) { | ||
// TODO(a11y): While long press has no effect when we return early without | ||
// bringing up the action sheet, the screen readers do not have a way to | ||
// know that. However, we may return this early return after we add a | ||
// always-present button. | ||
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.
Can we be clearer about how the bug affects users? How about:
if (optionButtons.isEmpty) {
// TODO(a11y): This case makes a no-op gesture handler; as a consequence,
// we're presenting some UI (to people who use screen-reader software) as
// though it offers a gesture interaction that it doesn't meaningfully
// offer, which is confusing. The solution here is probably to remove this
// is-empty case by having at least one button that's always present,
// such as "copy link to topic".
return;
}
lib/widgets/action_sheet.dart
Outdated
case (_, UserTopicVisibilityPolicy.none): | ||
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.
A label
method returning the empty string looks like a code smell to me; that can't be a helpful label for anything.
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 turned out that this would be better as a separate case and keep the assert(false)
. Added an explanation below:
case (_, UserTopicVisibilityPolicy.none):
// This is unexpected because `UserTopicVisibilityPolicy.muted` and
// `UserTopicVisibilityPolicy.followed` (handled in separate `case`'s)
// are the only expected `currentVisibilityPolicy`
// when `newVisibilityPolicy` is `UserTopicVisibilityPolicy.none`.
assert(false);
return '';
final mute = button(to: UserTopicVisibilityPolicy.muted); | ||
final unmute = button(from: UserTopicVisibilityPolicy.muted, | ||
to: UserTopicVisibilityPolicy.none); | ||
final unmuteInMutedChannel = button(to: UserTopicVisibilityPolicy.unmuted); | ||
final follow = button(to: UserTopicVisibilityPolicy.followed); | ||
final unfollow = button(from: UserTopicVisibilityPolicy.followed, | ||
to: UserTopicVisibilityPolicy.none); |
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 approach in this PR—which I understand follows zulip-mobile—doesn't support all the state transitions that web supports. Maybe we can open a followup issue for the unsupported ones?
Here's what web does:
In a muted channel, the menu for a topic lets you choose one of four options: "Mute", "Default", "Unmute", "Follow":
In an unmuted channel, the menu for a topic lets you choose one of three options: "Mute", "Default", "Follow", unless "Unmute" is currently selected; if so, that option is shown too until you select something different. (You can choose "Unmute" for a topic in a muted channel, and that choice persists when you unmute the channel.)
So that should fully describe which state transitions web supports. Among those, here are the transitions that this zulip-flutter PR doesn't yet support:
In a muted channel:
- You can't go from "Mute" to "Default" (unless you go through "Follow").
- You can't go from "Default" to "Mute" (unless you go through "Unmute" or "Follow").
- You can't go from "Unmute" to "Default" (unless you go through "Follow").
(In a muted channel, the distinction between "Mute" and "Default" matters because it controls whether we show the topic in the whole-stream message list.)
- You can't go from "Follow" to "Unmute" (unless you go through "Mute" or "Default"). ("Follow" and "Unmute" cause different behavior with notifications.)
In an unmuted channel:
- You can't go from "Unmute" to "Default" (unless you go through "Mute" or "Follow"). (I'm not sure if "Unmute" and "Default" cause different behavior in an unmuted channel, I think maybe they don't.)
None of those "unless you go through" workarounds are intuitive. Even if you know about them, you might get a surprise when you try going through "Mute" and the conversation disappears from the UI and you can't find it to choose the state you wanted in the first place.
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's radio-buttons approach seems like a fine solution to me, but again we might postpone that; we should make an issue if it's planned though. @gnprice, what do you 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, this PR follows the state transitions in the mobile app very closely, including the UX. If we are going with a newer radio-button design, it will probably save some time to get this right with a Figma redesign.
See discussion here.
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, that discussion is helpful context! I missed it in my review because I didn't see it linked from this PR or the issue. 🙂 I agree with the outcome there:
Yeah, some version of [web's radio-buttons design] would probably be good to put in mobile's action sheet too.
Definitely post-launch, though.
Would you add the link to that discussion on #348, and file an issue for that post-launch task? It can link to my comment here to memoize the work of figuring out what state transitions we're missing. (I used a lot of post-it notes, haha.)
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! Opened #1078 and added the link.
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!
assets/icons/ZulipIcons.ttf
Outdated
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.
icons: Take mute/unmute/following icons from the web app
Hmm yeah, looks like the Figma doesn't have icons for these yet. Following web seems fine, but let's note in the commit message that we checked https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=544-22131&node-type=canvas&m=dev and didn't find corresponding icons there.
@@ -59,6 +59,22 @@ | |||
"@permissionsDeniedReadExternalStorage": { | |||
"description": "Message for dialog asking the user to grant permissions for external storage read access." | |||
}, | |||
"actionSheetOptionMuteTopic": "Mute topic", |
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.
action_sheet: Support muting/unmuting/following topics
Bump on #1041 (review) :
Thanks! Comments below, and I agree with your comment at #348 (comment) 🙂
I think a design requirement of this feature is to also display mute/following states for each topic. At this point we can borrow that from the legacy mobile app.
The Fixes:
line doesn't belong on this commit until we do that 🙂 or make it not part of #348.
I have updated the PR to implement (sorry about the debug banner on the screenshots!) |
8942165
to
25f5f64
Compare
Opened #1125 |
0041f92
to
15e1b83
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.
lib/api/route/channels.dart
Outdated
assert(visibilityPolicy == UserTopicVisibilityPolicy.none | ||
|| visibilityPolicy == UserTopicVisibilityPolicy.muted); | ||
final op = visibilityPolicy == UserTopicVisibilityPolicy.none ? 'remove' | ||
: 'add'; |
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.
assert(visibilityPolicy == UserTopicVisibilityPolicy.none | |
|| visibilityPolicy == UserTopicVisibilityPolicy.muted); | |
final op = visibilityPolicy == UserTopicVisibilityPolicy.none ? 'remove' | |
: 'add'; | |
final op = switch (visibilityPolicy) { | |
UserTopicVisibilityPolicy.none => 'remove', | |
UserTopicVisibilityPolicy.muted => 'add', | |
_ => throw UnsupportedError('$visibilityPolicy on old server'), | |
}; |
If we get to this point with unmuted
or follow
, best to just throw — that'll be better than going and muting the topic as if we got muted
.
test/api/route/channels_test.dart
Outdated
test('updateUserTopic throws AssertionError when FL < 170', () { | ||
return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { | ||
check(() => updateUserTopic(connection, | ||
streamId: 1, topic: 'topic', | ||
visibilityPolicy: UserTopicVisibilityPolicy.followed), |
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.
test('updateUserTopic throws AssertionError when FL < 170', () { | |
return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { | |
check(() => updateUserTopic(connection, | |
streamId: 1, topic: 'topic', | |
visibilityPolicy: UserTopicVisibilityPolicy.followed), | |
test('updateUserTopic throws AssertionError when FL < 170', () { | |
return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { | |
check(() => updateUserTopic(connection, | |
streamId: 1, topic: 'topic', | |
visibilityPolicy: UserTopicVisibilityPolicy.muted), |
Otherwise the test is confounded: does the function throw because update-user-topic didn't exist at that version, or because followed
didn't exist? The latter didn't exist until some time later than this threshold.
lib/widgets/message_list.dart
Outdated
@@ -327,10 +328,57 @@ class MessageListAppBarTitle extends StatelessWidget { | |||
children: [ | |||
Icon(size: 16, icon), | |||
const SizedBox(width: 4), | |||
Flexible(child: Text(text)), | |||
Flexible(child: Text(stream?.name ?? '(unknown 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.
should say "channel" (like it does in main)
lib/widgets/message_list.dart
Outdated
}) { | ||
final store = PerAccountStoreWidget.of(context); | ||
final designVariables = DesignVariables.of(context); | ||
final icon = (stream == null) ? 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.
nit: extra parens
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 the rest of a full review. Comments below; mostly nits, and a few substantive items in the tests.
lib/widgets/message_list.dart
Outdated
Expanded( | ||
child: Padding( |
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 subtree gets kind of big. Let's pull it out as a local variable:
Expanded( | |
child: Padding( | |
Expanded( | |
child: topicWidget), |
bool hasAtSign(WidgetTester tester, Widget? parent) => | ||
hasIcon(tester, parent: parent, icon: ZulipIcons.at_sign); |
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.
test/widgets/inbox_test.dart
Outdated
await setupPage(tester, | ||
users: [eg.selfUser, eg.otherUser], | ||
streams: [channel], |
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: unused setup
await setupPage(tester, | |
users: [eg.selfUser, eg.otherUser], | |
streams: [channel], | |
await setupPage(tester, | |
streams: [channel], |
(here and below)
test/widgets/inbox_test.dart
Outdated
}); | ||
|
||
|
||
testWidgets('unmuted', (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: double blank line
lib/widgets/action_sheet.dart
Outdated
if (channelMuted != null && !channelMuted) { | ||
switch (visibilityPolicy) { |
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:
if (channelMuted != null && !channelMuted) { | |
switch (visibilityPolicy) { | |
if (channelMuted != null && !channelMuted) { | |
// Channel is subscribed and not muted. | |
switch (visibilityPolicy) { |
And then for the next case:
// Channel is muted.
I had the thought that these conditionals were a bit opaque and required some unpacking by the reader. Then I looked at the handy linked zulip-mobile code and saw it has comments for exactly that need 🙂 — so let's carry those over.
test/widgets/action_sheet_test.dart
Outdated
await tester.tap(unmuteInMutedChannel); | ||
await tester.pump(); | ||
checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); |
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 just:
await tester.tap(unmuteInMutedChannel); | |
await tester.pump(); | |
checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); | |
await tester.tap(find.text('Unmute topic')); | |
await tester.pump(); | |
checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); |
I think that covers all the facts that matter to the app's behavior as far as the user is concerned. And it both makes the test simpler, and less brittle to changes in the implementation.
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.
(Well, I guess there's one more fact this version doesn't check: the icon. But the icon isn't really being checked in the current revision either — just some inputs toward computing the icon.
It'd be a nice bonus to also check what icon is shown for each button, but I'll be happy merging this without that check.)
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.
For this and #1041 (comment), maybe have it set up like this:
final mute = find.text('Mute topic');
final unmute = find.text('Unmute topic');
final follow = find.text('Follow topic');
final unfollow = find.text('Unfollow topic');
so that the later tests like this one:
final testCases = [
(false, UserTopicVisibilityPolicy.muted, [unmute, follow]),
(false, UserTopicVisibilityPolicy.none, [mute, follow]),
(false, UserTopicVisibilityPolicy.unmuted, [mute, follow]),
(false, UserTopicVisibilityPolicy.followed, [mute, unfollow]),
(true, UserTopicVisibilityPolicy.muted, [unmute, follow]),
(true, UserTopicVisibilityPolicy.none, [unmute, follow]),
(true, UserTopicVisibilityPolicy.unmuted, [mute, follow]),
(true, UserTopicVisibilityPolicy.followed, [mute, unfollow]),
(null, UserTopicVisibilityPolicy.none, <Finder>[]),
];
can still be formatted compactly.
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, that works.
test/widgets/action_sheet_test.dart
Outdated
check(find.byType(BottomSheet)).findsOne(); | ||
}); |
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 can be a bit more specific in pinning down that the bottom sheet that comes up is the right bottom sheet:
check(find.byType(BottomSheet)).findsOne(); | |
}); | |
check(find.byType(BottomSheet)).findsOne(); | |
check(find.text('Follow topic')).findsOne(); | |
}); |
test/widgets/action_sheet_test.dart
Outdated
final testCases = { | ||
(false, UserTopicVisibilityPolicy.muted, [unmute, follow]), |
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: conceptually a list, not set
final testCases = { | |
(false, UserTopicVisibilityPolicy.muted, [unmute, follow]), | |
final testCases = [ | |
(false, UserTopicVisibilityPolicy.muted, [unmute, follow]), |
(here and below)
test/widgets/action_sheet_test.dart
Outdated
await setupToTopicActionSheet(tester, | ||
isChannelMuted: isChannelMuted, | ||
visibilityPolicy: visibilityPolicy, | ||
featureLevel: 218); |
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: use consistent standard name for a given concept
featureLevel: 218); | |
zulipFeatureLevel: 218); |
test/widgets/action_sheet_test.dart
Outdated
..bodyFields.deepEquals({ | ||
'stream_id': '${channel.streamId}', | ||
'topic': topic, | ||
'visibility_policy': '${expectedPolicy.toJson()}', |
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.
'visibility_policy': '${expectedPolicy.toJson()}', | |
'visibility_policy': jsonConvert(expectedPolicy), |
Just calling toString
(or doing string interpolation, which calls toString
) on the toJson
result happens to work here, because the toJson result is an int. But it doesn't get a JSON encoding in general. Better to just use the always-correct thing, which isn't any more complicated to write.
Thanks for the revision! Looks good; merging. |
Signed-off-by: Zixuan James Li <[email protected]>
The tests, ApiConnection.{post,patch,delete}, are mostly similar to each other, because the majority of them are testing that the params are parsed into the body with the same content type. If we find the need to update these test cases with new examples, it will be ripe to refactor them with a helper. Until then, just duplicate them for simplicity. Signed-off-by: Zixuan James Li <[email protected]>
For the legacy case, there can be an error when muting a topic that is already muted or unmuting one that is already unmuted. The callers are not expected to handle such errors because they aren't really actionable. Similar to getMessageCompat, updateUserTopicCompat is expected to be dropped, eventually. Signed-off-by: Zixuan James Li <[email protected]>
Renamed "mute-new.svg", "unmute-new.svg" to "mute.svg" and "unmute.svg", respectively. These are taken from the web app because we checked https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=544-22131&node-type=canvas&m=dev and the Figma doesn't have the corresponding icons at the time this is implemented. See: https://github.com/zulip/zulip/tree/da4443f392cc8aa9e6879d905cb1ccd50b66127b/web/shared/icons Signed-off-by: Zixuan James Li <[email protected]>
This will eventually be superseded by zulip#1039, so we should keep the implementation as simple as possible for now. The two-line app bar idea comes from the legacy mobile app. This gives us a place to show the topic visibility policy on the app bar. References: https://github.com/zulip/zulip-mobile/blob/a115df1f71c9dc31e9b41060a8d57b51c017d786/src/title/TitleStream.js#L113-L141 https://github.com/zulip/zulip-mobile/blob/a115df1f71c9dc31e9b41060a8d57b51c017d786/src/styles/navStyles.js#L5-L18 Signed-off-by: Zixuan James Li <[email protected]>
The design took some inspiration from the legacy mobile app. This displays a privacy level related icon (e.g.: web public, invite only). We will have a different place to show channel mute/unmute status in zulip#347. The color for the icon is taken from the web app: https://github.com/zulip/zulip/blob/dc58c8450f8524f226115a7b449b05e01ae15d8b/web/styles/message_header.css#L296-L297 https://github.com/zulip/zulip/blob/dc58c8450f8524f226115a7b449b05e01ae15d8b/web/styles/app_variables.css#L590 https://github.com/zulip/zulip/blob/dc58c8450f8524f226115a7b449b05e01ae15d8b/web/styles/app_variables.css#L1330 In the web app, these colors are used for the topic visibility icons on message recipient headers. To maintain the different conventional title alignment on iOS and Android, we borrow some checks from AppBar in title centering. Signed-off-by: Zixuan James Li <[email protected]>
Signed-off-by: Zixuan James Li <[email protected]>
This design was inspired by the legacy mobile app. Reference: https://github.com/zulip/zulip-mobile/blob/a115df1f71c9dc31e9b41060a8d57b51c017d786/src/streams/TopicItem.js#L47-L51 Signed-off-by: Zixuan James Li <[email protected]>
This is helpful for adding marker of topic visibility. Signed-off-by: Zixuan James Li <[email protected]>
Signed-off-by: Zixuan James Li <[email protected]>
Currently, we don't have buttons, like "resolve topic", other than the ones added here. The switch statements follow the layout of the legacy app implementation. See also: https://github.com/zulip/zulip-mobile/blob/715d60a5e87fe37032bce58bd72edb99208e15be/src/action-sheets/index.js#L656-L753 Fixes: zulip#348 Signed-off-by: Zixuan James Li <[email protected]>
Well, score one for running
Fixed it with:
|
(The issue was that in main we'd added those translations since this PR's current revision; so when adding strings, those generated files now needed to be updated just like the others.) |
Screenshots
Reference zulip-mobile implementation: https://github.com/zulip/zulip-mobile/blob/715d60a5e87fe37032bce58bd72edb99208e15be/src/action-sheets/index.js#L656-L753
Fixes: #348