Skip to content

Commit

Permalink
action_sheet: Add "Mark Topic As Read" button
Browse files Browse the repository at this point in the history
Adds button to mark all messages in a topic as read. The button:
- Appears only when the topic has unread messages
- Uses mark_topic_as_read API for server feature level < 155
- Uses messages/flags/narrow API for server feature level >= 155
- Shows error dialog if the request fails

fixes: #1225
  • Loading branch information
lakshya1goel committed Feb 1, 2025
1 parent 51d71a9 commit e45f627
Show file tree
Hide file tree
Showing 11 changed files with 283 additions and 0 deletions.
8 changes: 8 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -716,5 +716,13 @@
"emojiPickerSearchEmoji": "Search emoji",
"@emojiPickerSearchEmoji": {
"description": "Hint text for the emoji picker search text field."
},
"actionSheetOptionMarkTopicAsRead": "Mark Topic As Read",
"@actionSheetOptionMarkTopicAsRead": {
"description": "Option to mark a specific topic as read in the action sheet."
},
"errorMarkTopicAsReadFailed": "Failed to mark the topic as read. Please try again.",
"@errorMarkTopicAsReadFailed": {
"description": "Error message displayed when marking a topic as read fails."
}
}
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,18 @@ abstract class ZulipLocalizations {
/// In en, this message translates to:
/// **'Search emoji'**
String get emojiPickerSearchEmoji;

/// 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 message displayed when marking a topic as read fails.
///
/// In en, this message translates to:
/// **'Failed to mark the topic as read. Please try again.'**
String get errorMarkTopicAsReadFailed;
}

class _ZulipLocalizationsDelegate extends LocalizationsDelegate<ZulipLocalizations> {
Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Search emoji';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Search emoji';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Search emoji';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_nb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Search emoji';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_pl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Szukaj emoji';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ru.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Поиск эмодзи';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_sk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations {

@override
String get emojiPickerSearchEmoji => 'Hľadať emotikon';

@override
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';

@override
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
}
64 changes: 64 additions & 0 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';

import '../api/exception.dart';
import '../api/model/model.dart';
import '../api/model/narrow.dart';
import '../api/route/channels.dart';
import '../api/route/messages.dart';
import '../generated/l10n/zulip_localizations.dart';
Expand Down Expand Up @@ -240,6 +241,14 @@ void showTopicActionSheet(BuildContext context, {
pageContext: context);
}));

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
Expand Down Expand Up @@ -372,6 +381,61 @@ class UserTopicUpdateButton 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 {
final store = PerAccountStoreWidget.of(pageContext);
final connection = store.connection;
final zulipLocalizations = ZulipLocalizations.of(pageContext);

try {
if (connection.zulipFeatureLevel! >= 155) {
await updateMessageFlagsForNarrow(connection,
anchor: AnchorCode.oldest,
numBefore: 0,
numAfter: 1000,
narrow: TopicNarrow(channelId, topic).apiEncode()
..add(ApiNarrowIs(IsOperand.unread)),
op: UpdateMessageFlagsOp.add,
flag: MessageFlag.read);
} else {
await markTopicAsRead(connection,
streamId: channelId,
topicName: topic);
}
} catch (e) {
if (!pageContext.mounted) return;

String? errorMessage;
switch (e) {
case ZulipApiException():
errorMessage = e.message;
default:
}

showErrorDialog(context: pageContext,
title: zulipLocalizations.errorMarkTopicAsReadFailed,
message: errorMessage);
}
}
}

/// Show a sheet of actions you can take on a message in the message list.
///
/// Must have a [MessageListPage] ancestor.
Expand Down
157 changes: 157 additions & 0 deletions test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,163 @@ void main() {
});
});

group('MarkTopicAsReadButton', () {
Future<void> setupToTopicActionSheetWithUnreadMessages(WidgetTester tester, {
int? zulipFeatureLevel,
ZulipStream? channel,
}) async {
addTearDown(testBinding.reset);

final effectiveChannel = channel ?? eg.stream();
const topicName = TopicName('test topic');
final message = eg.streamMessage(stream: effectiveChannel, topic: 'test topic');
final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel);
await testBinding.globalStore.add(account, eg.initialSnapshot(
realmUsers: [eg.selfUser],
streams: [effectiveChannel],
subscriptions: [eg.subscription(effectiveChannel)],
zulipFeatureLevel: zulipFeatureLevel));
store = await testBinding.globalStore.perAccount(account.id);
connection = store.connection as FakeApiConnection;

connection.prepare(json: eg.newestGetMessagesResult(
foundOldest: true, messages: [message]).toJson());

await store.addMessage(message);
store.unreads.streams[effectiveChannel.streamId] ??= {};
store.unreads.streams[effectiveChannel.streamId]![topicName] ??= QueueList<int>();
store.unreads.streams[effectiveChannel.streamId]![topicName]!.add(message.id);

await tester.pumpWidget(TestZulipApp(accountId: account.id,
child: MessageListPage(initNarrow: TopicNarrow(effectiveChannel.streamId, topicName))));
await tester.pumpAndSettle();

await tester.longPress(find.byType(ZulipAppBar));
await tester.pump(const Duration(milliseconds: 250));
}

Future<void> setupToTopicActionSheetWithNoUnreadMessages(WidgetTester tester) async {
addTearDown(testBinding.reset);

final channel = eg.stream();
const topicName = TopicName('test topic');
final message = eg.streamMessage(stream: channel, topic: 'test topic', flags: [MessageFlag.read]);

await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
realmUsers: [eg.selfUser],
streams: [channel],
subscriptions: [eg.subscription(channel)]));
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
connection = store.connection as FakeApiConnection;

connection.prepare(json: eg.newestGetMessagesResult(
foundOldest: true, messages: [message]).toJson());

await store.addMessage(message);

await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: MessageListPage(initNarrow: TopicNarrow(channel.streamId, topicName))));
await tester.pumpAndSettle();

await tester.longPress(find.byType(ZulipAppBar));
await tester.pump(const Duration(milliseconds: 250));
}

Future<void> tapMarkTopicAsReadButton(WidgetTester tester) async {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
await tester.tap(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead));
await tester.pump();
}

group('visibility', () {
testWidgets('shows button when topic has unread messages', (tester) async {
await setupToTopicActionSheetWithUnreadMessages(tester);

final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
check(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead)).findsOne();
});

testWidgets('hides button when topic has no unread messages', (tester) async {
await setupToTopicActionSheetWithNoUnreadMessages(tester);

final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
check(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead)).findsNothing();
});
});

group('API requests', () {
testWidgets('sends mark_topic_as_read request for server feature level < 155', (tester) async {
final channel = eg.stream();
await setupToTopicActionSheetWithUnreadMessages(tester,
zulipFeatureLevel: 154,
channel: channel);

connection.prepare(json: {'result': 'success'});
await tapMarkTopicAsReadButton(tester);
await tester.pumpAndSettle();

check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/mark_topic_as_read')
..bodyFields.deepEquals({
'stream_id': '${channel.streamId}',
'topic_name': 'test topic',
});
// await tester.pumpAndSettle();
});

testWidgets('sends messages/flags/narrow request for server feature level >= 155', (tester) async {
final channel = eg.stream();
await setupToTopicActionSheetWithUnreadMessages(tester,
zulipFeatureLevel: 155,
channel: channel);

connection.prepare(json: UpdateMessageFlagsForNarrowResult(
processedCount: 11,
updatedCount: 3,
firstProcessedId: 1,
lastProcessedId: 1980,
foundOldest: true,
foundNewest: true).toJson());

await tapMarkTopicAsReadButton(tester);
await tester.pumpAndSettle();

check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/messages/flags/narrow')
..bodyFields.deepEquals({
'anchor': 'oldest',
'num_before': '0',
'num_after': '1000',
'narrow': jsonEncode([
{'operator': 'stream', 'operand': channel.streamId},
{'operator': 'topic', 'operand': 'test topic'},
{'operator': 'is', 'operand': 'unread'},
]),
'op': 'add',
'flag': 'read',
});
});

testWidgets('shows error dialog when mark-as-read request fails', (tester) async {
final channel = eg.stream();
await setupToTopicActionSheetWithUnreadMessages(tester,
zulipFeatureLevel: 154,
channel: channel);

prepareRawContentResponseError();
await tapMarkTopicAsReadButton(tester);
await tester.pumpAndSettle();

final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
checkErrorDialog(tester,
expectedTitle: zulipLocalizations.errorMarkTopicAsReadFailed,
expectedMessage: 'Invalid message(s)');
});
});
});

group('MessageActionSheetCancelButton', () {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;

Expand Down

0 comments on commit e45f627

Please sign in to comment.