Skip to content

Commit 2a5b01d

Browse files
author
chimnayajith
committed
action_sheet: Add channel action sheet with mark as read option
Fixes: zulip#1226
1 parent 866adc1 commit 2a5b01d

13 files changed

+231
-1
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
@@ -163,6 +163,58 @@ class ActionSheetCancelButton extends StatelessWidget {
163163
}
164164
}
165165

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

lib/widgets/inbox.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ abstract class _HeaderItem extends StatelessWidget {
272272
// But that's in tension with the Figma, which gives these header rows
273273
// 40px min height.
274274
onTap: onCollapseButtonTap,
275+
onLongPress: this is _LongPressable
276+
? (this as _LongPressable).onLongPress
277+
: null,
275278
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
276279
Padding(padding: const EdgeInsets.all(10),
277280
child: Icon(size: 20, color: designVariables.sectionCollapseIcon,
@@ -431,7 +434,13 @@ class _DmItem extends StatelessWidget {
431434
}
432435
}
433436

434-
class _StreamHeaderItem extends _HeaderItem {
437+
mixin _LongPressable on _HeaderItem {
438+
// TODO(#1272) move to _HeaderItem base class
439+
// when DM headers become long-pressable; remove mixin
440+
Future<void> onLongPress();
441+
}
442+
443+
class _StreamHeaderItem extends _HeaderItem with _LongPressable {
435444
final Subscription subscription;
436445

437446
const _StreamHeaderItem({
@@ -464,6 +473,11 @@ class _StreamHeaderItem extends _HeaderItem {
464473
}
465474
}
466475
@override Future<void> onRowTap() => onCollapseButtonTap(); // TODO open channel narrow
476+
477+
@override
478+
Future<void> onLongPress() async {
479+
showChannelActionSheet(sectionContext, streamId: subscription.streamId);
480+
}
467481
}
468482

469483
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: 131 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;
@@ -1203,6 +1204,136 @@ void main() {
12031204
});
12041205
});
12051206
});
1207+
1208+
group('channel action sheet', () {
1209+
late ZulipStream someChannel;
1210+
1211+
Future<void> prepare({UnreadMessagesSnapshot? unreadMsgs}) async {
1212+
final stream = eg.stream();
1213+
someChannel = stream;
1214+
addTearDown(testBinding.reset);
1215+
1216+
unreadMsgs ??= eg.unreadMsgs(channels: [
1217+
eg.unreadChannelMsgs(
1218+
streamId: stream.streamId,
1219+
topic: 'topic',
1220+
unreadMessageIds: [1],
1221+
),
1222+
]);
1223+
1224+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
1225+
realmUsers: [eg.selfUser, eg.otherUser],
1226+
streams: [someChannel],
1227+
subscriptions: [eg.subscription(someChannel)],
1228+
unreadMsgs: unreadMsgs));
1229+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
1230+
connection = store.connection as FakeApiConnection;
1231+
}
1232+
1233+
Future<void> showFromInbox(WidgetTester tester) async {
1234+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1235+
child: const HomePage()));
1236+
await tester.pump();
1237+
check(find.byType(InboxPageBody)).findsOne();
1238+
1239+
await tester.longPress(find.text(someChannel.name).hitTestable());
1240+
await tester.pump(const Duration(milliseconds: 250));
1241+
}
1242+
1243+
Future<void> showFromSubscriptionList(WidgetTester tester) async {
1244+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1245+
child: const HomePage()));
1246+
await tester.pump();
1247+
await tester.tap(find.byIcon(ZulipIcons.hash_italic));
1248+
await tester.pump();
1249+
check(find.byType(SubscriptionListPageBody)).findsOne();
1250+
1251+
await tester.longPress(find.text(someChannel.name).hitTestable());
1252+
await tester.pump(const Duration(milliseconds: 250));
1253+
}
1254+
1255+
final actionSheetFinder = find.byType(BottomSheet);
1256+
Finder findButtonForLabel(String label) =>
1257+
find.descendant(of: actionSheetFinder, matching: find.text(label));
1258+
1259+
void checkButton(String label) {
1260+
check(findButtonForLabel(label)).findsOne();
1261+
}
1262+
1263+
group('showChannelActionSheet', () {
1264+
void checkButtons() {
1265+
check(actionSheetFinder).findsOne();
1266+
checkButton('Mark channel as read');
1267+
}
1268+
1269+
testWidgets('show from inbox', (tester) async {
1270+
await prepare();
1271+
await showFromInbox(tester);
1272+
checkButtons();
1273+
});
1274+
1275+
testWidgets('show from subscription list', (tester) async {
1276+
await prepare();
1277+
await showFromSubscriptionList(tester);
1278+
checkButtons();
1279+
});
1280+
1281+
testWidgets('show with no unread messages', (tester) async {
1282+
await prepare(unreadMsgs: eg.unreadMsgs());
1283+
await showFromSubscriptionList(tester);
1284+
check(actionSheetFinder).findsNothing();
1285+
});
1286+
});
1287+
1288+
group('MarkChannelAsReadButton', () {
1289+
void checkRequest(int streamId) {
1290+
check(connection.takeRequests()).single.isA<http.Request>()
1291+
..method.equals('POST')
1292+
..url.path.equals('/api/v1/messages/flags/narrow')
1293+
..bodyFields.deepEquals({
1294+
'anchor': 'oldest',
1295+
'include_anchor': 'false',
1296+
'num_before': '0',
1297+
'num_after': '1000',
1298+
'narrow': jsonEncode([
1299+
{'operator': 'stream', 'operand': streamId},
1300+
{'operator': 'is', 'operand': 'unread'}
1301+
]),
1302+
'op': 'add',
1303+
'flag': 'read',
1304+
});
1305+
}
1306+
1307+
testWidgets('happy path from inbox', (tester) async {
1308+
await prepare();
1309+
await showFromInbox(tester);
1310+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
1311+
processedCount: 1, updatedCount: 1,
1312+
firstProcessedId: null, lastProcessedId: null,
1313+
foundOldest: true, foundNewest: true).toJson());
1314+
await tester.tap(findButtonForLabel('Mark channel as read'));
1315+
await tester.pumpAndSettle();
1316+
1317+
checkNoErrorDialog(tester);
1318+
checkRequest(someChannel.streamId);
1319+
});
1320+
1321+
testWidgets('request fails', (tester) async {
1322+
await prepare();
1323+
await showFromInbox(tester);
1324+
1325+
// Prepare error response
1326+
connection.prepare(exception: http.ClientException('Oops'));
1327+
1328+
// Tap and wait for dialog
1329+
await tester.tap(findButtonForLabel('Mark channel as read'));
1330+
await tester.pumpAndSettle(); // Wait for dialog animation
1331+
1332+
checkErrorDialog(tester,
1333+
expectedTitle: "Mark as read failed");
1334+
});
1335+
});
1336+
});
12061337
}
12071338

12081339
extension UnicodeEmojiWidgetChecks on Subject<UnicodeEmojiWidget> {

0 commit comments

Comments
 (0)