Skip to content

Commit

Permalink
channel_list: Implement unsubscribe from channel
Browse files Browse the repository at this point in the history
  • Loading branch information
Khader-1 committed Aug 29, 2024
1 parent 296a844 commit 368ad56
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 7 deletions.
14 changes: 14 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,19 @@
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
},
"messageUnsubscribedFromChannel": "You've unsubscribed from #{channelName}.",
"@messageUnsubscribedFromChannel": {
"description": "A message shown to inform user that unsubscribe action passes",
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
},
"errorFailedToUnsubscribedFromChannel": "Failed to unsubscribe to #{channelName}.",
"@errorFailedToUnsubscribedFromChannel": {
"description": "An error message when unsubscribe action fails",
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
}
}
44 changes: 37 additions & 7 deletions lib/widgets/channel_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,22 @@ class ChannelItem extends StatelessWidget {
overflow: TextOverflow.ellipsis),
])),
const SizedBox(width: 8),
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream, channelItemContext: context),
_ChannelItemSubscriptionToggle(stream: stream, channelItemContext: context),
]))));
}
}

class _ChannelItemSubscribeButton extends StatefulWidget {
const _ChannelItemSubscribeButton({required this.stream, required this.channelItemContext});
class _ChannelItemSubscriptionToggle extends StatefulWidget {
const _ChannelItemSubscriptionToggle({required this.stream, required this.channelItemContext});

final ZulipStream stream;
final BuildContext channelItemContext;

@override
State<_ChannelItemSubscribeButton> createState() => _ChannelItemSubscribeButtonState();
State<_ChannelItemSubscriptionToggle> createState() => _ChannelItemSubscriptionToggleState();
}

class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton> {
class _ChannelItemSubscriptionToggleState extends State<_ChannelItemSubscriptionToggle> {
bool _isLoading = false;

void _setIsLoading(bool value) {
Expand All @@ -129,15 +129,45 @@ class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final (icon, color, onPressed) = widget.stream is Subscription
? (Icons.check, colorScheme.primary, _unsubscribeFromChannel)
: (Icons.add, null, _subscribeToChannel);

return IconButton(
icon: const Icon(Icons.add),
color: color,
icon: Icon(icon),
onPressed: _isLoading ? null : () async {
_setIsLoading(true);
await _subscribeToChannel(context, widget.stream);
await onPressed(context, widget.stream);
_setIsLoading(false);
});
}

Future<void> _unsubscribeFromChannel(BuildContext context, ZulipStream stream) async {
final store = PerAccountStoreWidget.of(context);
final connection = store.connection;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
try {
final res = await unsubscribeFromChannels(connection, [stream]);
if (!context.mounted) return;
if (res.removed.contains(stream.name)) {
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.messageUnsubscribedFromChannel(stream.name))));
} else if (res.notRemoved.contains(stream.name)) {
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name))));
}
} catch (e) {
if (!context.mounted) return;
final zulipLocalizations = ZulipLocalizations.of(context);
await showErrorDialog(context: context,
title: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
message: e.toString()); // TODO(#741): extract user-facing message better
}
}

Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {
final store = PerAccountStoreWidget.of(context);
final connection = store.connection;
Expand Down
74 changes: 74 additions & 0 deletions test/widgets/channel_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,20 @@ void main() {
return stream;
}

Future<Subscription> prepareSingleSubscription(WidgetTester tester) async {
final stream = eg.subscription(eg.stream());
await setupChannelListPage(tester, streams: [stream], subscriptions: [stream]);
return stream;
}

Future<void> tapSubscribeButton(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.add));
}

Future<void> tapUnsubscribeButton(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.check));
}

Future<void> waitAndCheckSnackbarIsShown(WidgetTester tester, String message) async {
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
Expand All @@ -109,17 +119,20 @@ void main() {
alreadySubscribed: {}).toJson());

check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
check(find.byIcon(Icons.check).evaluate()).isEmpty();

await store.handleEvent(SubscriptionAddEvent(id: 1,
subscriptions: [eg.subscription(stream)]));
await tester.pumpAndSettle();

check(find.byIcon(Icons.add).evaluate()).isEmpty();
check(find.byIcon(Icons.check).evaluate()).isNotEmpty();

await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId]));
await tester.pumpAndSettle();

check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
check(find.byIcon(Icons.check).evaluate()).isEmpty();
});

testWidgets('is disabled while loading', (WidgetTester tester) async {
Expand Down Expand Up @@ -228,5 +241,66 @@ void main() {
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
});
});

group('unsubscribe', () {
testWidgets('is shown only for streams that user is subscribed to', (tester) async {
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
final subscriptions = [streams[2] as Subscription];
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions);

check(find.byIcon(Icons.check).evaluate().length).equals(1);
});

testWidgets('smoke api', (tester) async {
final stream = await prepareSingleSubscription(tester);
connection.prepare(json: UnsubscribeFromChannelsResult(
removed: [stream.name],
notRemoved: []).toJson());
await tapUnsubscribeButton(tester);

await tester.pump(Duration.zero);
await tester.pumpAndSettle();
check(connection.lastRequest).isA<http.Request>()
..method.equals('DELETE')
..url.path.equals('/api/v1/users/me/subscriptions')
..bodyFields.deepEquals({
'subscriptions': jsonEncode([stream.name])
});
});

testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
final stream = await prepareSingleSubscription(tester);
connection.prepare(json: UnsubscribeFromChannelsResult(
removed: [stream.name],
notRemoved: []).toJson());
await tapUnsubscribeButton(tester);

await waitAndCheckSnackbarIsShown(tester,
zulipLocalizations.messageUnsubscribedFromChannel(stream.name));
});

testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
final stream = await prepareSingleSubscription(tester);
connection.prepare(json: UnsubscribeFromChannelsResult(
removed: [],
notRemoved: [stream.name]).toJson());
await tapUnsubscribeButton(tester);

await waitAndCheckSnackbarIsShown(tester,
zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name));
});

testWidgets('catch-all api errors', (WidgetTester tester) async {
final stream = await prepareSingleSubscription(tester);
connection.prepare(exception: http.ClientException('Oops'));
await tapUnsubscribeButton(tester);
await tester.pump(Duration.zero);
await tester.pumpAndSettle();

checkErrorDialog(tester,
expectedTitle: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
});
});
});
}

0 comments on commit 368ad56

Please sign in to comment.