Skip to content

Commit 1f30bba

Browse files
committed
channel_list: Implement subscribe to channel
Fixes: zulip#188
1 parent cc676ac commit 1f30bba

File tree

3 files changed

+265
-1
lines changed

3 files changed

+265
-1
lines changed

Diff for: assets/l10n/app_en.arb

+12
Original file line numberDiff line numberDiff line change
@@ -612,5 +612,17 @@
612612
"errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.",
613613
"@errorNotificationOpenAccountMissing": {
614614
"description": "Error message when the account associated with the notification is not found"
615+
},
616+
"messageSubscribedToChannel": "You've just subscribed to ",
617+
"@messageSubscribedToChannel": {
618+
"description": "A message shown to inform user that subscription is successful"
619+
},
620+
"messageAlreadySubscribedToChannel": "You're already subscribed to ",
621+
"@messageAlreadySubscribedToChannel": {
622+
"description": "A message shown to inform user that subscription is already made"
623+
},
624+
"errorFailedToSubscribedToChannel": "Failed to subscribe to ",
625+
"@errorFailedToSubscribedToChannel": {
626+
"description": "An error message when subscribe action fails"
615627
}
616628
}

Diff for: lib/widgets/channel_list.dart

+80
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ 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/channels.dart';
56
import '../model/narrow.dart';
67
import 'app_bar.dart';
8+
import '../model/store.dart';
9+
import 'dialog.dart';
710
import 'icons.dart';
811
import 'message_list.dart';
912
import 'page.dart';
1013
import 'store.dart';
14+
import 'text.dart';
1115
import 'theme.dart';
1216

1317
class ChannelListPage extends StatelessWidget {
@@ -100,6 +104,82 @@ class ChannelItem extends StatelessWidget {
100104
maxLines: 1,
101105
overflow: TextOverflow.ellipsis),
102106
])),
107+
const SizedBox(width: 8),
108+
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream, channelItemContext: context),
103109
]))));
104110
}
105111
}
112+
113+
class _ChannelItemSubscribeButton extends StatefulWidget {
114+
const _ChannelItemSubscribeButton({required this.stream, required this.channelItemContext});
115+
116+
final ZulipStream stream;
117+
final BuildContext channelItemContext;
118+
119+
@override
120+
State<_ChannelItemSubscribeButton> createState() => _ChannelItemSubscribeButtonState();
121+
}
122+
123+
class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton> {
124+
bool _isLoading = false;
125+
126+
void _setIsLoading(bool value) {
127+
if (!mounted) return;
128+
setState(() => _isLoading = value);
129+
}
130+
131+
@override
132+
Widget build(BuildContext context) {
133+
return IconButton(
134+
icon: const Icon(Icons.add),
135+
onPressed: _isLoading ? null : () async {
136+
_setIsLoading(true);
137+
await _subscribeToChannel(context, widget.stream);
138+
_setIsLoading(false);
139+
});
140+
}
141+
142+
Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {
143+
final store = PerAccountStoreWidget.of(context);
144+
final connection = store.connection;
145+
final zulipLocalizations = ZulipLocalizations.of(context);
146+
try {
147+
final res = await subscribeToChannels(connection, [stream]);
148+
if (!context.mounted) return;
149+
if (_subscriptionResultContains(store, res.subscribed, stream.name)) {
150+
_showSnackbarWithChannelTitle(context, zulipLocalizations.messageSubscribedToChannel, stream);
151+
} else if (_subscriptionResultContains(store, res.alreadySubscribed, stream.name)) {
152+
_showSnackbarWithChannelTitle(context, zulipLocalizations.messageAlreadySubscribedToChannel, stream);
153+
} else {
154+
_showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToSubscribedToChannel, stream);
155+
}
156+
} catch (e) {
157+
if (!context.mounted) return;
158+
final zulipLocalizations = ZulipLocalizations.of(context);
159+
_showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToUnsubscribedFromChannel, stream);
160+
}
161+
}
162+
163+
bool _subscriptionResultContains(PerAccountStore store, Map<String, List<String>> emailOrIdSubs, String subscription) {
164+
// TODO (server-10) Before the user keys were Zulip API email addresses, not user ID.
165+
final expectedEmail = store.users[store.selfUserId]?.email;
166+
final foundByEmail = emailOrIdSubs[expectedEmail]?.contains(subscription);
167+
final foundById = emailOrIdSubs[store.selfUserId.toString()]?.contains(subscription);
168+
return foundByEmail ?? foundById ?? false;
169+
}
170+
171+
void _showSnackbarWithChannelTitle(BuildContext context, String message, ZulipStream stream) {
172+
ScaffoldMessenger.of(context).showSnackBar(
173+
SnackBar(
174+
behavior: SnackBarBehavior.floating,
175+
content: Text.rich(TextSpan(text: message, children: [
176+
WidgetSpan(child: Padding(
177+
padding: const EdgeInsets.only(bottom: 2),
178+
child: Icon(
179+
size: 12,
180+
iconDataForStream(stream),
181+
color: Theme.of(context).scaffoldBackgroundColor))),
182+
TextSpan(text: ' ${stream.name}.', style: weightVariableTextStyle(context, wght: 700)),
183+
]))));
184+
}
185+
}

Diff for: test/widgets/channel_list_test.dart

+173-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
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/channels.dart';
10+
import 'package:zulip/model/localizations.dart';
11+
import 'package:zulip/model/store.dart';
412
import 'package:zulip/widgets/channel_list.dart';
13+
import 'package:zulip/widgets/icons.dart';
514

15+
import '../api/fake_api.dart';
616
import '../model/binding.dart';
717
import '../example_data.dart' as eg;
18+
import '../stdlib_checks.dart';
19+
import 'dialog_checks.dart';
820
import 'test_app.dart';
921

1022
void main() {
1123
TestZulipBinding.ensureInitialized();
24+
late FakeApiConnection connection;
25+
late PerAccountStore store;
1226

1327
Future<void> setupChannelListPage(WidgetTester tester, {
1428
required List<ZulipStream> streams,
@@ -18,8 +32,10 @@ void main() {
1832
final initialSnapshot = eg.initialSnapshot(
1933
subscriptions: subscriptions,
2034
streams: streams,
21-
);
35+
realmUsers: [eg.selfUser]);
2236
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
37+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
38+
connection = store.connection as FakeApiConnection;
2339

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

@@ -67,4 +83,160 @@ void main() {
6783
check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']);
6884
});
6985
});
86+
87+
group('subscription toggle', () {
88+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
89+
90+
Future<ZulipStream> prepareSingleStream(WidgetTester tester) async {
91+
final stream = eg.stream();
92+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
93+
return stream;
94+
}
95+
96+
Future<void> tapSubscribeButton(WidgetTester tester) async {
97+
await tester.tap(find.byIcon(Icons.add));
98+
}
99+
100+
Future<void> waitAndCheckSnackbarIsShown(WidgetTester tester, String message, String channelName) async {
101+
await tester.pump(Duration.zero);
102+
await tester.pumpAndSettle();
103+
final richTextFinder = find.byWidgetPredicate(
104+
(widget) => widget is RichText && widget.text.toPlainText().contains(message));
105+
check(richTextFinder.evaluate()).single;
106+
final richTextWidget = tester.widget<RichText>(richTextFinder);
107+
check(richTextWidget.text.toPlainText()).contains(message);
108+
check(richTextWidget.text.toPlainText()).contains(channelName);
109+
check(find.descendant(
110+
of: richTextFinder,
111+
matching: find.byIcon(ZulipIcons.hash_sign),
112+
).evaluate()).single;
113+
}
114+
115+
testWidgets('is affected by subscription events', (WidgetTester tester) async {
116+
final stream = await prepareSingleStream(tester);
117+
connection.prepare(json: SubscribeToChannelsResult(
118+
subscribed: {eg.selfUser.email: [stream.name]},
119+
alreadySubscribed: {}).toJson());
120+
121+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
122+
123+
await store.handleEvent(SubscriptionAddEvent(id: 1,
124+
subscriptions: [eg.subscription(stream)]));
125+
await tester.pumpAndSettle();
126+
127+
check(find.byIcon(Icons.add).evaluate()).isEmpty();
128+
129+
await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId]));
130+
await tester.pumpAndSettle();
131+
132+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
133+
});
134+
135+
testWidgets('is disabled while loading', (WidgetTester tester) async {
136+
final stream = eg.stream();
137+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
138+
connection.prepare(json: SubscribeToChannelsResult(
139+
subscribed: {eg.selfUser.email: [stream.name]},
140+
alreadySubscribed: {}).toJson());
141+
await tapSubscribeButton(tester);
142+
await tester.pump();
143+
144+
check(tester.widget<IconButton>(
145+
find.byType(IconButton)).onPressed).isNull();
146+
147+
await tester.pump(const Duration(seconds: 2));
148+
149+
check(tester.widget<IconButton>(
150+
find.byType(IconButton)).onPressed).isNotNull();
151+
});
152+
153+
testWidgets('is disabled while loading and enabled back when loading fails', (WidgetTester tester) async {
154+
final stream = eg.stream();
155+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
156+
connection.prepare(exception: http.ClientException('Oops'), delay: const Duration(seconds: 2));
157+
await tapSubscribeButton(tester);
158+
await tester.pump();
159+
160+
check(tester.widget<IconButton>(
161+
find.byType(IconButton)).onPressed).isNull();
162+
163+
await tester.pump(const Duration(seconds: 2));
164+
165+
check(tester.widget<IconButton>(
166+
find.byType(IconButton)).onPressed).isNotNull();
167+
});
168+
169+
group('subscribe', () {
170+
testWidgets('is shown only for streams that user is not subscribed to', (tester) async {
171+
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
172+
final subscriptions = [streams[2] as Subscription];
173+
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions);
174+
175+
check(find.byIcon(Icons.add).evaluate().length).equals(2);
176+
});
177+
178+
testWidgets('smoke api', (tester) async {
179+
final stream = await prepareSingleStream(tester);
180+
connection.prepare(json: SubscribeToChannelsResult(
181+
subscribed: {eg.selfUser.email: [stream.name]},
182+
alreadySubscribed: {}).toJson());
183+
await tapSubscribeButton(tester);
184+
185+
await tester.pump(Duration.zero);
186+
await tester.pumpAndSettle();
187+
check(connection.lastRequest).isA<http.Request>()
188+
..method.equals('POST')
189+
..url.path.equals('/api/v1/users/me/subscriptions')
190+
..bodyFields.deepEquals({
191+
'subscriptions': jsonEncode([{'name': stream.name}])
192+
});
193+
});
194+
195+
testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
196+
final stream = await prepareSingleStream(tester);
197+
connection.prepare(json: SubscribeToChannelsResult(
198+
subscribed: {eg.selfUser.email: [stream.name]},
199+
alreadySubscribed: {}).toJson());
200+
await tapSubscribeButton(tester);
201+
202+
await waitAndCheckSnackbarIsShown(tester,
203+
zulipLocalizations.messageSubscribedToChannel, stream.name);
204+
});
205+
206+
testWidgets('shows a snackbar when already subscribed', (WidgetTester tester) async {
207+
final stream = await prepareSingleStream(tester);
208+
connection.prepare(json: SubscribeToChannelsResult(
209+
subscribed: {},
210+
alreadySubscribed: {eg.selfUser.email: [stream.name]}).toJson());
211+
await tapSubscribeButton(tester);
212+
213+
await waitAndCheckSnackbarIsShown(tester,
214+
zulipLocalizations.messageAlreadySubscribedToChannel, stream.name);
215+
});
216+
217+
testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
218+
final stream = await prepareSingleStream(tester);
219+
connection.prepare(json: SubscribeToChannelsResult(
220+
subscribed: {},
221+
alreadySubscribed: {},
222+
unauthorized: [stream.name]).toJson());
223+
await tapSubscribeButton(tester);
224+
225+
await waitAndCheckSnackbarIsShown(tester,
226+
zulipLocalizations.errorFailedToSubscribedToChannel, stream.name);
227+
});
228+
229+
testWidgets('catch-all api errors', (WidgetTester tester) async {
230+
await prepareSingleStream(tester);
231+
connection.prepare(exception: http.ClientException('Oops'));
232+
await tapSubscribeButton(tester);
233+
await tester.pump(Duration.zero);
234+
await tester.pumpAndSettle();
235+
236+
checkErrorDialog(tester,
237+
expectedTitle: zulipLocalizations.errorFailedToSubscribedToChannel,
238+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
239+
});
240+
});
241+
});
70242
}

0 commit comments

Comments
 (0)