Skip to content

Commit b75ddf8

Browse files
committed
channel_list: Implement subscribe to channel
Fixes: zulip#188
1 parent 3e3e4d5 commit b75ddf8

File tree

4 files changed

+256
-4
lines changed

4 files changed

+256
-4
lines changed

Diff for: assets/l10n/app_en.arb

+23-2
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,8 @@
367367
"num": {"type": "int", "example": "4"}
368368
}
369369
},
370-
"browseMoreNChannels": "Browse {num, plural, =0{no other channels} =1{1 more channel} other{{num} more channels}}",
371-
"@browseMoreNChannels": {
370+
"browseNMoreChannels": "Browse {num, plural, =1{1 more channel} other{{num} more channels}}",
371+
"@browseNMoreChannels": {
372372
"description": "Label showing the number of other channels that user can subscribe to",
373373
"placeholders": {
374374
"num": {"type": "int", "example": "4"}
@@ -552,5 +552,26 @@
552552
"manyPeopleTyping": "Several people are typing…",
553553
"@manyPeopleTyping": {
554554
"description": "Text to display when there are multiple users typing."
555+
},
556+
"messageSubscribedToChannel": "You've just subscribed to {channelName}",
557+
"@messageSubscribedToChannel": {
558+
"description": "A message shown to inform user that subscription is successful",
559+
"placeholders": {
560+
"channelName": {"type": "String", "example": "announce"}
561+
}
562+
},
563+
"messageAlreadySubscribedToChannel": "You're already subscribed to {channelName}!",
564+
"@messageAlreadySubscribedToChannel": {
565+
"description": "A message shown to inform user that subscription is already made",
566+
"placeholders": {
567+
"channelName": {"type": "String", "example": "announce"}
568+
}
569+
},
570+
"errorFailedToSubscribedToChannel": "Failed to subscribe to {channelName}",
571+
"@errorFailedToSubscribedToChannel": {
572+
"description": "An error message when subscribe action fails",
573+
"placeholders": {
574+
"channelName": {"type": "String", "example": "announce"}
575+
}
555576
}
556577
}

Diff for: lib/widgets/channel_list.dart

+69
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ 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';
@@ -68,6 +71,7 @@ class ChannelItem extends StatelessWidget {
6871
Widget build(BuildContext context) {
6972
final designVariables = DesignVariables.of(context);
7073
return Material(
74+
// TODO(design) check if this is the right variable
7175
color: designVariables.background,
7276
child: InkWell(
7377
onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context,
@@ -99,6 +103,71 @@ class ChannelItem extends StatelessWidget {
99103
maxLines: 1,
100104
overflow: TextOverflow.ellipsis),
101105
])),
106+
const SizedBox(width: 8),
107+
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream, channelItemContext: context),
102108
]))));
103109
}
104110
}
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 scaffoldMessenger = ScaffoldMessenger.of(context);
145+
final zulipLocalizations = ZulipLocalizations.of(context);
146+
try {
147+
final res = await subscribeToChannels(connection, [stream]);
148+
if (!context.mounted) return;
149+
if (_emailSubscriptionsContains(store, res.subscribed, stream.name)) {
150+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
151+
content: Text(zulipLocalizations.messageSubscribedToChannel(stream.name))));
152+
} else if (_emailSubscriptionsContains(store, res.alreadySubscribed, stream.name)) {
153+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
154+
content: Text(zulipLocalizations.messageAlreadySubscribedToChannel(stream.name))));
155+
} else {
156+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
157+
content: Text(zulipLocalizations.errorFailedToSubscribedToChannel(stream.name))));
158+
}
159+
} catch (e) {
160+
if (!context.mounted) return;
161+
final zulipLocalizations = ZulipLocalizations.of(context);
162+
showErrorDialog(context: context,
163+
title: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
164+
message: e.toString()); // TODO(#741): extract user-facing message better
165+
}
166+
}
167+
168+
bool _emailSubscriptionsContains(PerAccountStore store, Map<String, List<String>> emailSubs, String subscription) {
169+
final expectedEmail = store.users[store.selfUserId]?.email;
170+
final found = emailSubs[expectedEmail]?.contains(subscription);
171+
return found ?? false;
172+
}
173+
}

Diff for: lib/widgets/subscription_list.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ class _ChannelListLinkItem extends StatelessWidget {
211211
final notShownStreams = store.streams.length - store.subscriptions.length;
212212
final zulipLocalizations = ZulipLocalizations.of(context);
213213
final label = notShownStreams != 0
214-
? zulipLocalizations.browseMoreNChannels(notShownStreams)
214+
? zulipLocalizations.browseNMoreChannels(notShownStreams)
215215
: zulipLocalizations.browseAllChannels;
216216
return SliverToBoxAdapter(
217217
child: Material(

Diff for: test/widgets/channel_list_test.dart

+163-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';
513

14+
import '../api/fake_api.dart';
615
import '../model/binding.dart';
716
import '../example_data.dart' as eg;
17+
import '../stdlib_checks.dart';
18+
import 'dialog_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,151 @@ 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) async {
100+
await tester.pump(Duration.zero);
101+
await tester.pumpAndSettle();
102+
check(find.text(message).evaluate()).isNotEmpty();
103+
}
104+
105+
testWidgets('is affected by subscription events', (WidgetTester tester) async {
106+
final stream = await prepareSingleStream(tester);
107+
connection.prepare(json: SubscribeToChannelsResult(
108+
subscribed: {eg.selfUser.email: [stream.name]},
109+
alreadySubscribed: {}).toJson());
110+
111+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
112+
113+
await store.handleEvent(SubscriptionAddEvent(id: 1,
114+
subscriptions: [eg.subscription(stream)]));
115+
await tester.pumpAndSettle();
116+
117+
check(find.byIcon(Icons.add).evaluate()).isEmpty();
118+
119+
await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId]));
120+
await tester.pumpAndSettle();
121+
122+
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
123+
});
124+
125+
testWidgets('is disabled while loading', (WidgetTester tester) async {
126+
final stream = eg.stream();
127+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
128+
connection.prepare(json: SubscribeToChannelsResult(
129+
subscribed: {eg.selfUser.email: [stream.name]},
130+
alreadySubscribed: {}).toJson());
131+
await tapSubscribeButton(tester);
132+
await tester.pump();
133+
134+
check(tester.widget<IconButton>(
135+
find.byType(IconButton)).onPressed).isNull();
136+
137+
await tester.pump(const Duration(seconds: 2));
138+
139+
check(tester.widget<IconButton>(
140+
find.byType(IconButton)).onPressed).isNotNull();
141+
});
142+
143+
testWidgets('is disabled while loading and enabled back when loading fails', (WidgetTester tester) async {
144+
final stream = eg.stream();
145+
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
146+
connection.prepare(exception: http.ClientException('Oops'), delay: const Duration(seconds: 2));
147+
await tapSubscribeButton(tester);
148+
await tester.pump();
149+
150+
check(tester.widget<IconButton>(
151+
find.byType(IconButton)).onPressed).isNull();
152+
153+
await tester.pump(const Duration(seconds: 2));
154+
155+
check(tester.widget<IconButton>(
156+
find.byType(IconButton)).onPressed).isNotNull();
157+
});
158+
159+
group('subscribe', () {
160+
testWidgets('is shown only for streams that user is not subscribed to', (tester) async {
161+
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
162+
final subscriptions = [streams[2] as Subscription];
163+
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions);
164+
165+
check(find.byIcon(Icons.add).evaluate().length).equals(2);
166+
});
167+
168+
testWidgets('smoke api', (tester) async {
169+
final stream = await prepareSingleStream(tester);
170+
connection.prepare(json: SubscribeToChannelsResult(
171+
subscribed: {eg.selfUser.email: [stream.name]},
172+
alreadySubscribed: {}).toJson());
173+
await tapSubscribeButton(tester);
174+
175+
await tester.pump(Duration.zero);
176+
await tester.pumpAndSettle();
177+
check(connection.lastRequest).isA<http.Request>()
178+
..method.equals('POST')
179+
..url.path.equals('/api/v1/users/me/subscriptions')
180+
..bodyFields.deepEquals({
181+
'subscriptions': jsonEncode([{'name': stream.name}])
182+
});
183+
});
184+
185+
testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
186+
final stream = await prepareSingleStream(tester);
187+
connection.prepare(json: SubscribeToChannelsResult(
188+
subscribed: {eg.selfUser.email: [stream.name]},
189+
alreadySubscribed: {}).toJson());
190+
await tapSubscribeButton(tester);
191+
192+
await waitAndCheckSnackbarIsShown(tester,
193+
zulipLocalizations.messageSubscribedToChannel(stream.name));
194+
});
195+
196+
testWidgets('shows a snackbar when already subscribed', (WidgetTester tester) async {
197+
final stream = await prepareSingleStream(tester);
198+
connection.prepare(json: SubscribeToChannelsResult(
199+
subscribed: {},
200+
alreadySubscribed: {eg.selfUser.email: [stream.name]}).toJson());
201+
await tapSubscribeButton(tester);
202+
203+
await waitAndCheckSnackbarIsShown(tester,
204+
zulipLocalizations.messageAlreadySubscribedToChannel(stream.name));
205+
});
206+
207+
testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
208+
final stream = await prepareSingleStream(tester);
209+
connection.prepare(json: SubscribeToChannelsResult(
210+
subscribed: {},
211+
alreadySubscribed: {},
212+
unauthorized: [stream.name]).toJson());
213+
await tapSubscribeButton(tester);
214+
215+
await waitAndCheckSnackbarIsShown(tester,
216+
zulipLocalizations.errorFailedToSubscribedToChannel(stream.name));
217+
});
218+
219+
testWidgets('catch-all api errors', (WidgetTester tester) async {
220+
final stream = await prepareSingleStream(tester);
221+
connection.prepare(exception: http.ClientException('Oops'));
222+
await tapSubscribeButton(tester);
223+
await tester.pump(Duration.zero);
224+
await tester.pumpAndSettle();
225+
226+
checkErrorDialog(tester,
227+
expectedTitle: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
228+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
229+
});
230+
});
231+
});
70232
}

0 commit comments

Comments
 (0)