Skip to content

Commit 793e9bf

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

File tree

3 files changed

+262
-1
lines changed

3 files changed

+262
-1
lines changed

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+
"errorFailedToSubscribeToChannel": "Failed to subscribe to ",
625+
"@errorFailedToSubscribeToChannel": {
626+
"description": "An error message when subscribe action fails"
615627
}
616628
}

lib/widgets/channel_list.dart

+79
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ 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';
79
import 'icons.dart';
810
import 'message_list.dart';
911
import 'page.dart';
1012
import 'store.dart';
13+
import 'text.dart';
1114
import 'theme.dart';
1215

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

test/widgets/channel_list_test.dart

+171-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
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';
819
import 'test_app.dart';
920

1021
void main() {
1122
TestZulipBinding.ensureInitialized();
23+
late FakeApiConnection connection;
24+
late PerAccountStore store;
1225

1326
Future<void> setupChannelListPage(WidgetTester tester, {
1427
required List<ZulipStream> streams,
@@ -18,8 +31,10 @@ void main() {
1831
final initialSnapshot = eg.initialSnapshot(
1932
subscriptions: subscriptions,
2033
streams: streams,
21-
);
34+
realmUsers: [eg.selfUser]);
2235
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
36+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
37+
connection = store.connection as FakeApiConnection;
2338

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

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

0 commit comments

Comments
 (0)