Skip to content

Commit 3567ebb

Browse files
committed
channel_list: Implement Subscribe to channel
Fixes: zulip#188
1 parent 0bb826b commit 3567ebb

File tree

3 files changed

+201
-1
lines changed

3 files changed

+201
-1
lines changed

Diff for: assets/l10n/app_en.arb

+21
Original file line numberDiff line numberDiff line change
@@ -498,5 +498,26 @@
498498
"notifSelfUser": "You",
499499
"@notifSelfUser": {
500500
"description": "Display name for the user themself, to show after replying in an Android notification"
501+
},
502+
"messageSubscribedToChannel": "You've just subscribed to {channelName}",
503+
"@messageSubscribedToChannel": {
504+
"description": "A message shown to inform user that subscription is successful",
505+
"placeholders": {
506+
"channelName": {"type": "String", "example": "announce"}
507+
}
508+
},
509+
"messageAlreadySubscribedToChannel": "You're already subscribed to {channelName}!",
510+
"@messageAlreadySubscribedToChannel": {
511+
"description": "A message shown to inform user that subscription is already made",
512+
"placeholders": {
513+
"channelName": {"type": "String", "example": "announce"}
514+
}
515+
},
516+
"errorFailedToSubscribedToChannel": "Failed to subscribe to {channelName}",
517+
"@errorFailedToSubscribedToChannel": {
518+
"description": "An error message when subscribe action fails",
519+
"placeholders": {
520+
"channelName": {"type": "String", "example": "announce"}
521+
}
501522
}
502523
}

Diff for: lib/widgets/channel_list.dart

+52
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
33

44
import '../api/model/model.dart';
5+
import '../api/route/channel.dart';
56
import '../model/narrow.dart';
7+
import '../model/store.dart';
8+
import 'dialog.dart';
69
import 'icons.dart';
710
import 'message_list.dart';
811
import 'page.dart';
@@ -101,6 +104,55 @@ class ChannelItem extends StatelessWidget {
101104
maxLines: 1,
102105
overflow: TextOverflow.ellipsis),
103106
])),
107+
const SizedBox(width: 8),
108+
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream),
104109
]))));
105110
}
106111
}
112+
113+
class _ChannelItemSubscribeButton extends StatelessWidget {
114+
const _ChannelItemSubscribeButton({required this.stream});
115+
116+
final ZulipStream stream;
117+
118+
@override
119+
Widget build(BuildContext context) {
120+
return IconButton(
121+
icon: const Icon(Icons.add),
122+
onPressed: () => _subscribeToChannel(context, stream));
123+
}
124+
125+
Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {
126+
final store = PerAccountStoreWidget.of(context);
127+
final connection = store.connection;
128+
final scaffoldMessenger = ScaffoldMessenger.of(context);
129+
final zulipLocalizations = ZulipLocalizations.of(context);
130+
try {
131+
final res = await subscribeToChannels(connection, [stream]);
132+
if (!context.mounted) return;
133+
scaffoldMessenger.clearSnackBars();
134+
if (_emailSubscriptionsContains(store, res.subscribed, stream.name)) {
135+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
136+
content: Text(zulipLocalizations.messageSubscribedToChannel(stream.name))));
137+
} else if (_emailSubscriptionsContains(store, res.alreadySubscribed, stream.name)) {
138+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
139+
content: Text(zulipLocalizations.messageAlreadySubscribedToChannel(stream.name))));
140+
} else {
141+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
142+
content: Text(zulipLocalizations.errorFailedToSubscribedToChannel(stream.name))));
143+
}
144+
} catch (e) {
145+
if (!context.mounted) return;
146+
final zulipLocalizations = ZulipLocalizations.of(context);
147+
await showErrorDialog(context: context,
148+
title: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
149+
message: e.toString()); // TODO(#741): extract user-facing message better
150+
}
151+
}
152+
153+
bool _emailSubscriptionsContains(PerAccountStore store, Map<String, List<String>> emailSubs, String subscription) {
154+
final expectedEmail = store.users[store.selfUserId]?.email;
155+
final found = emailSubs[expectedEmail]?.contains(subscription);
156+
return found ?? false;
157+
}
158+
}

Diff for: test/widgets/channel_list_test.dart

+128-1
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
1+
import 'dart:convert';
2+
13
import 'package:checks/checks.dart';
4+
import 'package:flutter/material.dart';
25
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:http/http.dart' as http;
7+
import 'package:zulip/api/model/events.dart';
38
import 'package:zulip/api/model/model.dart';
9+
import 'package:zulip/api/route/channel.dart';
410
import 'package:zulip/model/localizations.dart';
11+
import 'package:zulip/model/store.dart';
512
import 'package:zulip/widgets/channel_list.dart';
613

14+
import '../api/fake_api.dart';
715
import '../model/binding.dart';
816
import '../example_data.dart' as eg;
17+
import '../stdlib_checks.dart';
18+
import 'dialog_checks.dart';
919
import 'test_app.dart';
1020

1121
void main() {
1222
TestZulipBinding.ensureInitialized();
23+
late FakeApiConnection connection;
24+
late PerAccountStore store;
1325

1426
Future<void> setupChannelListPage(WidgetTester tester, {
1527
required List<ZulipStream> streams, required List<Subscription> subscriptions}) async {
1628
addTearDown(testBinding.reset);
1729
final initialSnapshot = eg.initialSnapshot(
1830
subscriptions: subscriptions,
1931
streams: streams.toList(),
20-
);
32+
realmUsers: [eg.selfUser]);
2133
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
34+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
35+
connection = store.connection as FakeApiConnection;
2236

2337
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const ChannelListPage()));
2438

@@ -67,4 +81,117 @@ void main() {
6781
check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']);
6882
});
6983
});
84+
85+
group('subscription toggle', () {
86+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
87+
88+
Future<ZulipStream> prepareSingleStream(WidgetTester tester) async {
89+
final stream = eg.stream();
90+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
91+
return stream;
92+
}
93+
94+
Future<void> tapSubscribeButton(WidgetTester tester) async {
95+
await tester.tap(find.byIcon(Icons.add));
96+
}
97+
98+
Future<void> waitAndCheckSnackbarIsShown(WidgetTester tester, String message) async {
99+
await tester.pump(Duration.zero);
100+
await tester.pumpAndSettle();
101+
check(find.text(message).evaluate()).isNotEmpty();
102+
}
103+
104+
testWidgets('is affected by subscription events', (WidgetTester tester) async {
105+
final stream = await prepareSingleStream(tester);
106+
connection.prepare(json: SubscribeToChannelsResult(
107+
subscribed: {eg.selfUser.email: [stream.name]},
108+
alreadySubscribed: {}).toJson());
109+
110+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
111+
112+
await store.handleEvent(SubscriptionAddEvent(id: 1,
113+
subscriptions: [eg.subscription(stream)]));
114+
await tester.pumpAndSettle();
115+
116+
check(find.byIcon(Icons.add).evaluate()).isEmpty();
117+
118+
await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId]));
119+
await tester.pumpAndSettle();
120+
121+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
122+
}, skip: true);
123+
124+
group('subscribe', () {
125+
testWidgets('is shown only for streams that user is not subscribed to', (tester) async {
126+
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
127+
final subscriptions = [streams[2]];
128+
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions.cast());
129+
130+
check(find.byIcon(Icons.add).evaluate().length).equals(2);
131+
});
132+
133+
testWidgets('smoke api', (tester) async {
134+
final stream = await prepareSingleStream(tester);
135+
connection.prepare(json: SubscribeToChannelsResult(
136+
subscribed: {eg.selfUser.email: [stream.name]},
137+
alreadySubscribed: {}).toJson());
138+
await tapSubscribeButton(tester);
139+
140+
await tester.pump(Duration.zero);
141+
await tester.pumpAndSettle();
142+
check(connection.lastRequest).isA<http.Request>()
143+
..method.equals('POST')
144+
..url.path.equals('/api/v1/users/me/subscriptions')
145+
..bodyFields.deepEquals({
146+
'subscriptions': jsonEncode([{'name': stream.name}])
147+
});
148+
});
149+
150+
testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
151+
final stream = await prepareSingleStream(tester);
152+
connection.prepare(json: SubscribeToChannelsResult(
153+
subscribed: {eg.selfUser.email: [stream.name]},
154+
alreadySubscribed: {}).toJson());
155+
await tapSubscribeButton(tester);
156+
157+
await waitAndCheckSnackbarIsShown(tester,
158+
zulipLocalizations.messageSubscribedToChannel(stream.name));
159+
}, skip: true);
160+
161+
testWidgets('shows a snackbar when already subscribed', (WidgetTester tester) async {
162+
final stream = await prepareSingleStream(tester);
163+
connection.prepare(json: SubscribeToChannelsResult(
164+
subscribed: {},
165+
alreadySubscribed: {eg.selfUser.email: [stream.name]}).toJson());
166+
await tapSubscribeButton(tester);
167+
168+
await waitAndCheckSnackbarIsShown(tester,
169+
zulipLocalizations.messageAlreadySubscribedToChannel(stream.name));
170+
}, skip: true);
171+
172+
testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
173+
final stream = await prepareSingleStream(tester);
174+
connection.prepare(json: SubscribeToChannelsResult(
175+
subscribed: {},
176+
alreadySubscribed: {},
177+
unauthorized: [stream.name]).toJson());
178+
await tapSubscribeButton(tester);
179+
180+
await waitAndCheckSnackbarIsShown(tester,
181+
zulipLocalizations.errorFailedToSubscribedToChannel(stream.name));
182+
}, skip: true);
183+
184+
testWidgets('catch-all api errors', (WidgetTester tester) async {
185+
final stream = await prepareSingleStream(tester);
186+
connection.prepare(exception: http.ClientException('Oops'));
187+
await tapSubscribeButton(tester);
188+
await tester.pump(Duration.zero);
189+
await tester.pumpAndSettle();
190+
191+
checkErrorDialog(tester,
192+
expectedTitle: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
193+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
194+
}, skip: true);
195+
});
196+
});
70197
}

0 commit comments

Comments
 (0)