diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 0be670d5cb..a2d20d40cd 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/message_checked.svg b/assets/icons/message_checked.svg new file mode 100644 index 0000000000..5c598ae87e --- /dev/null +++ b/assets/icons/message_checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 118ab83c70..9bc14daf7c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -136,6 +136,10 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "actionSheetOptionMarkTopicAsRead": "Mark topic as read", + "@actionSheetOptionMarkTopicAsRead": { + "description": "Option to mark a specific topic as read in the action sheet." + }, "errorWebAuthOperationalErrorTitle": "Something went wrong", "@errorWebAuthOperationalErrorTitle": { "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 9579683908..2756fe632e 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -309,6 +309,12 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Option to mark a specific topic as read in the action sheet. + /// + /// In en, this message translates to: + /// **'Mark topic as read'** + String get actionSheetOptionMarkTopicAsRead; + /// Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials). /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 71bf06d8ce..70d76b017f 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 7a33e33567..d926cdb0c1 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 137883e5e9..c2f5692864 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3dec7d9b5a..13fa7ea71a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 83a777bfc4..f074d87acc 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Coś poszło nie tak'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 827aaf0155..848075052a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Что-то пошло не так'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 38a3f8a240..c013ab3886 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -112,6 +112,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override String get errorWebAuthOperationalErrorTitle => 'Niečo sa pokazilo'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index e943d7eb36..fb10e8d185 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -255,6 +255,14 @@ void showTopicActionSheet(BuildContext context, { someMessageIdInTopic: someMessageIdInTopic)); } + final unreadCount = store.unreads.countInTopicNarrow(channelId, topic); + if (unreadCount > 0) { + optionButtons.add(MarkTopicAsReadButton( + channelId: channelId, + topic: topic, + pageContext: context)); + } + if (optionButtons.isEmpty) { // TODO(a11y): This case makes a no-op gesture handler; as a consequence, // we're presenting some UI (to people who use screen-reader software) as @@ -461,6 +469,29 @@ class ResolveUnresolveButton extends ActionSheetMenuItemButton { } } +class MarkTopicAsReadButton extends ActionSheetMenuItemButton { + const MarkTopicAsReadButton({ + super.key, + required this.channelId, + required this.topic, + required super.pageContext, + }); + + final int channelId; + final TopicName topic; + + @override IconData get icon => ZulipIcons.message_checked; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionMarkTopicAsRead; + } + + @override void onPressed() async { + await ZulipAction.markNarrowAsRead(pageContext, TopicNarrow(channelId, topic)); + } +} + /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 82cb83704b..c3a68085c7 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -99,44 +99,47 @@ abstract final class ZulipIcons { /// The Zulip custom icon "menu". static const IconData menu = IconData(0xf119, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "message_checked". + static const IconData message_checked = IconData(0xf11a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf127, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 48c0d70b59..26460781f2 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -852,7 +852,7 @@ class _MarkAsReadWidgetState extends State { backgroundColor: WidgetStatePropertyAll(messageListTheme.unreadMarker), ), onPressed: _loading ? null : () => _handlePress(context), - icon: const Icon(Icons.playlist_add_check), + icon: const Icon(ZulipIcons.message_checked), label: Text(zulipLocalizations.markAllAsReadLabel)))))); } } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 5a4c22c604..286b26d3ee 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -10,6 +10,7 @@ import 'package:http/http.dart' as http; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/binding.dart'; @@ -562,6 +563,47 @@ void main() { expectedTitle: 'Failed to mark topic as unresolved'); }); }); + + group('MarkTopicAsReadButton', () { + testWidgets('visible if topic has unread messages', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic, flags: []); + await store.addMessage(message); + await showFromAppBar(tester, messages: [message]); + check(find.text('Mark topic as read')).findsOne(); + }); + + testWidgets('not visible if topic has no unread messages', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic, flags: [MessageFlag.read]); + await store.addMessage(message); + await showFromAppBar(tester, messages: [message]); + check(find.text('Mark topic as read')).findsNothing(); + }); + + testWidgets('marks topic as read when pressed', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await store.handleEvent(MessageEvent(id: 0, message: message)); + await showFromAppBar(tester, messages: [message]); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1, updatedCount: 1, + firstProcessedId: message.id, lastProcessedId: message.id, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.text('Mark topic as read')); + await tester.pumpAndSettle(); + + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields['narrow'].equals(jsonEncode([ + ...eg.topicNarrow(someChannel.streamId, someTopic).apiEncode(), + ApiNarrowIs(IsOperand.unread), + ])) + ..bodyFields['op'].equals('add') + ..bodyFields['flag'].equals('read'); + }); + }); }); group('message action sheet', () {