Skip to content

Commit 06ed689

Browse files
author
chimnayajith
committed
action_sheet: Add channel action sheet with mark as read option
Fixes: zulip#1226
1 parent e905a72 commit 06ed689

13 files changed

+241
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@
7676
"@permissionsDeniedReadExternalStorage": {
7777
"description": "Message for dialog asking the user to grant permissions for external storage read access."
7878
},
79+
"actionSheetOptionMarkChannelAsRead": "Mark channel as read",
80+
"@actionSheetOptionMarkChannelAsRead": {
81+
"description": "Label for marking a channel as read."
82+
},
7983
"actionSheetOptionMuteTopic": "Mute topic",
8084
"@actionSheetOptionMuteTopic": {
8185
"description": "Label for muting a topic on action sheet."

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ abstract class ZulipLocalizations {
219219
/// **'To upload files, please grant Zulip additional permissions in Settings.'**
220220
String get permissionsDeniedReadExternalStorage;
221221

222+
/// Label for marking a channel as read.
223+
///
224+
/// In en, this message translates to:
225+
/// **'Mark channel as read'**
226+
String get actionSheetOptionMarkChannelAsRead;
227+
222228
/// Label for muting a topic on action sheet.
223229
///
224230
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Mute topic';
7275

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Mute topic';
7275

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Mute topic';
7275

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Mute topic';
7275

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Wycisz wątek';
7275

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Отключить тему';
7275

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
6767
@override
6868
String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.';
6969

70+
@override
71+
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
72+
7073
@override
7174
String get actionSheetOptionMuteTopic => 'Stlmiť tému';
7275

lib/widgets/action_sheet.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,58 @@ class ActionSheetCancelButton extends StatelessWidget {
162162
}
163163
}
164164

165+
/// Show a sheet of actions you can take on a channel.
166+
void showChannelActionSheet(BuildContext context, {
167+
required int streamId,
168+
}) {
169+
final store = PerAccountStoreWidget.of(context);
170+
171+
final optionButtons = <ActionSheetMenuItemButton>[];
172+
final unreadCount = store.unreads.countInChannelNarrow(streamId);
173+
if (unreadCount > 0) {
174+
optionButtons.add(
175+
MarkChannelAsReadButton(
176+
streamId: streamId,
177+
pageContext: context,
178+
),
179+
);
180+
}
181+
if (optionButtons.isEmpty) {
182+
// TODO(a11y): This case makes a no-op gesture handler; as a consequence,
183+
// we're presenting some UI (to people who use screen-reader software) as
184+
// though it offers a gesture interaction that it doesn't meaningfully
185+
// offer, which is confusing. The solution here is probably to remove this
186+
// is-empty case by having at least one button that's always present,
187+
// such as "copy link to channel".
188+
return;
189+
}
190+
_showActionSheet(context, optionButtons: optionButtons);
191+
}
192+
193+
class MarkChannelAsReadButton extends ActionSheetMenuItemButton {
194+
const MarkChannelAsReadButton({
195+
super.key,
196+
required this.streamId,
197+
required super.pageContext
198+
});
199+
200+
final int streamId;
201+
202+
@override
203+
IconData get icon => ZulipIcons.message_checked;
204+
205+
@override
206+
String label(ZulipLocalizations zulipLocalizations) {
207+
return zulipLocalizations.actionSheetOptionMarkChannelAsRead;
208+
}
209+
210+
@override
211+
void onPressed() async {
212+
final narrow = ChannelNarrow(streamId);
213+
await ZulipAction.markNarrowAsRead(pageContext, narrow);
214+
}
215+
}
216+
165217
/// Show a sheet of actions you can take on a topic.
166218
void showTopicActionSheet(BuildContext context, {
167219
required int channelId,

lib/widgets/inbox.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ abstract class _HeaderItem extends StatelessWidget {
255255
}
256256

257257
Future<void> onRowTap();
258+
Future<void> onLongPress();
258259

259260
@override
260261
Widget build(BuildContext context) {
@@ -272,6 +273,7 @@ abstract class _HeaderItem extends StatelessWidget {
272273
// But that's in tension with the Figma, which gives these header rows
273274
// 40px min height.
274275
onTap: onCollapseButtonTap,
276+
onLongPress: onLongPress,
275277
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
276278
Padding(padding: const EdgeInsets.all(10),
277279
child: Icon(size: 20, color: designVariables.sectionCollapseIcon,
@@ -330,6 +332,12 @@ class _AllDmsHeaderItem extends _HeaderItem {
330332
pageState.allDmsCollapsed = !collapsed;
331333
}
332334
@override Future<void> onRowTap() => onCollapseButtonTap(); // TODO open all-DMs narrow?
335+
336+
@override
337+
Future<void> onLongPress() async {
338+
// TODO(#1272) action sheet for DM conversation
339+
return;
340+
}
333341
}
334342

335343
class _AllDmsSection extends StatelessWidget {
@@ -464,6 +472,11 @@ class _StreamHeaderItem extends _HeaderItem {
464472
}
465473
}
466474
@override Future<void> onRowTap() => onCollapseButtonTap(); // TODO open channel narrow
475+
476+
@override
477+
Future<void> onLongPress() async {
478+
showChannelActionSheet(sectionContext, streamId: subscription.streamId);
479+
}
467480
}
468481

469482
class _StreamSection extends StatelessWidget {

lib/widgets/subscription_list.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../api/model/model.dart';
44
import '../generated/l10n/zulip_localizations.dart';
55
import '../model/narrow.dart';
66
import '../model/unreads.dart';
7+
import 'action_sheet.dart';
78
import 'icons.dart';
89
import 'message_list.dart';
910
import 'store.dart';
@@ -230,6 +231,7 @@ class SubscriptionItem extends StatelessWidget {
230231
MessageListPage.buildRoute(context: context,
231232
narrow: ChannelNarrow(subscription.streamId)));
232233
},
234+
onLongPress: () => showChannelActionSheet(context, streamId: subscription.streamId),
233235
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
234236
const SizedBox(width: 16),
235237
Padding(

test/widgets/action_sheet_test.dart

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import 'package:zulip/widgets/icons.dart';
3030
import 'package:zulip/widgets/inbox.dart';
3131
import 'package:zulip/widgets/message_list.dart';
3232
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
33+
import 'package:zulip/widgets/subscription_list.dart';
3334
import '../api/fake_api.dart';
3435

3536
import '../example_data.dart' as eg;
@@ -1085,6 +1086,148 @@ void main() {
10851086
});
10861087
});
10871088
});
1089+
1090+
group('channel action sheet', () {
1091+
late ZulipStream someChannel;
1092+
late PerAccountStore store;
1093+
1094+
Future<void> prepare({UnreadMessagesSnapshot? unreadMsgs}) async {
1095+
final stream = eg.stream();
1096+
someChannel = stream;
1097+
addTearDown(testBinding.reset);
1098+
1099+
unreadMsgs ??= eg.unreadMsgs(channels: [
1100+
eg.unreadChannelMsgs(
1101+
streamId: stream.streamId,
1102+
topic: 'topic',
1103+
unreadMessageIds: [1],
1104+
),
1105+
]);
1106+
1107+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
1108+
realmUsers: [eg.selfUser, eg.otherUser],
1109+
streams: [someChannel],
1110+
subscriptions: [eg.subscription(someChannel)],
1111+
unreadMsgs: unreadMsgs));
1112+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
1113+
connection = store.connection as FakeApiConnection;
1114+
}
1115+
1116+
Future<void> showFromInbox(WidgetTester tester) async {
1117+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1118+
child: const HomePage()));
1119+
await tester.pumpAndSettle();
1120+
check(find.byType(InboxPageBody)).findsOne();
1121+
1122+
await tester.pump();
1123+
await tester.longPress(find.text(someChannel.name).hitTestable());
1124+
await tester.pump(const Duration(milliseconds: 250));
1125+
}
1126+
1127+
Future<void> showFromSubscriptionList(WidgetTester tester) async {
1128+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1129+
child: const SubscriptionListPageBody()));
1130+
await tester.pumpAndSettle();
1131+
check(find.byType(SubscriptionListPageBody)).findsOne();
1132+
1133+
await tester.pump();
1134+
await tester.longPress(find.text(someChannel.name).hitTestable());
1135+
await tester.pump(const Duration(milliseconds: 250));
1136+
}
1137+
1138+
final actionSheetFinder = find.byType(BottomSheet);
1139+
Finder findButtonForLabel(String label) =>
1140+
find.descendant(of: actionSheetFinder, matching: find.text(label));
1141+
1142+
void checkButton(String label) {
1143+
check(findButtonForLabel(label)).findsOne();
1144+
}
1145+
1146+
group('showChannelActionSheet', () {
1147+
void checkButtons() {
1148+
check(actionSheetFinder).findsOne();
1149+
checkButton('Mark channel as read');
1150+
}
1151+
1152+
testWidgets('show from inbox', (tester) async {
1153+
await prepare();
1154+
await showFromInbox(tester);
1155+
checkButtons();
1156+
});
1157+
1158+
testWidgets('show from subscription list', (tester) async {
1159+
await prepare();
1160+
await showFromSubscriptionList(tester);
1161+
checkButtons();
1162+
});
1163+
1164+
testWidgets('show with no unread messages', (tester) async {
1165+
await prepare(unreadMsgs: eg.unreadMsgs());
1166+
await showFromSubscriptionList(tester);
1167+
check(actionSheetFinder).findsNothing();
1168+
});
1169+
});
1170+
1171+
group('MarkChannelAsReadButton', () {
1172+
void checkRequest(int streamId) {
1173+
check(connection.takeRequests()).single.isA<http.Request>()
1174+
..method.equals('POST')
1175+
..url.path.equals('/api/v1/messages/flags/narrow')
1176+
..bodyFields.deepEquals({
1177+
'anchor': 'oldest',
1178+
'include_anchor': 'false',
1179+
'num_before': '0',
1180+
'num_after': '1000',
1181+
'narrow': jsonEncode([
1182+
{'operator': 'stream', 'operand': streamId},
1183+
{'operator': 'is', 'operand': 'unread'}
1184+
]),
1185+
'op': 'add',
1186+
'flag': 'read',
1187+
});
1188+
}
1189+
1190+
testWidgets('happy path from inbox', (tester) async {
1191+
await prepare();
1192+
await showFromInbox(tester);
1193+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
1194+
processedCount: 1, updatedCount: 1,
1195+
firstProcessedId: null, lastProcessedId: null,
1196+
foundOldest: true, foundNewest: true).toJson());
1197+
await tester.tap(findButtonForLabel('Mark channel as read'));
1198+
await tester.pumpAndSettle();
1199+
1200+
checkNoErrorDialog(tester);
1201+
checkRequest(someChannel.streamId);
1202+
});
1203+
1204+
testWidgets('request fails', (tester) async {
1205+
await prepare();
1206+
await showFromInbox(tester);
1207+
1208+
// Prepare error response
1209+
connection.prepare(httpStatus: 400, json: {
1210+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''});
1211+
1212+
// Tap and wait for dialog
1213+
await tester.tap(findButtonForLabel('Mark channel as read'));
1214+
await tester.pump(); //
1215+
await tester.pumpAndSettle(); // Wait for dialog animation
1216+
1217+
checkErrorDialog(tester,
1218+
expectedTitle: "Mark as read failed");
1219+
});
1220+
1221+
// testWidgets('shows loading state', (tester) async {
1222+
// await prepare();
1223+
// await showFromInbox(tester);
1224+
// connection.prepareNeverComplete();
1225+
// await tester.tap(findButtonForLabel('Mark channel as read'));
1226+
// await tester.pump();
1227+
// check(find.byType(CircularProgressIndicator)).findsOne();
1228+
// });
1229+
});
1230+
});
10881231
}
10891232

10901233
extension UnicodeEmojiWidgetChecks on Subject<UnicodeEmojiWidget> {

0 commit comments

Comments
 (0)