diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 584db9c6884..574a57cbd2e 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -289,6 +289,10 @@ "num": {"type": "int", "example": "4"} } }, + "errorInvalidResponse": "The server sent an invalid response", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, "errorNetworkRequestFailed": "Network request failed", "@errorNetworkRequestFailed": { "description": "Error message when a network request fails." @@ -323,6 +327,28 @@ "@serverUrlValidationErrorUnsupportedScheme": { "description": "Error message when URL has an unsupported scheme." }, + "markAsReadLabel": "Mark {num, plural, =1{1 message} other{{num} messages}} as read", + "@markAsReadLabel": { + "description": "Button text to mark messages as read.", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "markAsReadComplete": "Marked {num, plural, =1{1 message} other{{num} messages}} as read.", + "@markAsReadComplete": { + "description": "SnackBar message when marking messages as read", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "markAsReadInProgress": "Marking messages as read...", + "@markAsReadInProgress": { + "description": "SnackBar message when marking messages as read" + }, + "errorMarkAsReadFailedTitle": "Mark as read failed", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, "userRoleOwner": "Owner", "@userRoleOwner": { "description": "Label for UserRole.owner" diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d6af914be22..ac3d34d047a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2,15 +2,19 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; +import '../api/core.dart'; import '../api/model/model.dart'; +import '../api/route/messages.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'action_sheet.dart'; import 'compose_box.dart'; import 'content.dart'; +import 'dialog.dart'; import 'icons.dart'; import 'page.dart'; import 'profile.dart'; @@ -274,10 +278,10 @@ class _MessageListState extends State with PerAccountStoreAwareStat final valueKey = key as ValueKey; final index = model!.findItemWithMessageId(valueKey.value); if (index == -1) return null; - return length - 1 - index; + return length - 1 - (index - 1); }, controller: scrollController, - itemCount: length, + itemCount: length + 1, // Setting reverse: true means the scroll starts at the bottom. // Flipping the indexes (in itemBuilder) means the start/bottom // has the latest messages. @@ -286,7 +290,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // TODO on new message when scrolled up, anchor scroll to what's in view reverse: true, itemBuilder: (context, i) { - final data = model!.items[length - 1 - i]; + if (i == 0) return MarkAsReadWidget(model: model!); + + final data = model!.items[length - 1 - (i - 1)]; switch (data) { case MessageListHistoryStartItem(): return const Center( @@ -305,7 +311,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat case MessageListMessageItem(): return MessageItem( key: ValueKey(data.message.id), - trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11), + trailing: i == 1 ? const SizedBox(height: 8) : const SizedBox(height: 11), item: data); } }); @@ -345,6 +351,67 @@ class ScrollToBottomButton extends StatelessWidget { } } +class MarkAsReadWidget extends StatelessWidget { + const MarkAsReadWidget({super.key, required this.model}); + + final MessageListView model; + + void _handlePress(BuildContext context) async { + if (!context.mounted) return; + final narrow = model.narrow; + final connection = model.store.connection; + if (connection.zulipFeatureLevel! >= 155) { + await markNarrowAsRead(context, connection, narrow); + } else { + switch (narrow) { + case AllMessagesNarrow(): + await markAllAsRead(connection); + case StreamNarrow(): + await markStreamAsRead(connection, + streamId: narrow.streamId); + case TopicNarrow(): + await markTopicAsRead(connection, + streamId: narrow.streamId, topicName: narrow.topic); + case DmNarrow(): + final unreadDms = model.store.unreads.dms[narrow]; + if (unreadDms == null) { + return; + } + await updateMessageFlags(connection, + messages: unreadDms, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + } + } + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final unreadCount = model.store.unreads.countInNarrow(model.narrow); + return AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: (unreadCount > 0) ? CrossFadeState.showSecond : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: SizedBox(width: double.infinity, + // Design referenced from: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 + child: ColoredBox( + color: const HSLColor.fromAHSL(1, 204, 0.58, 0.92).toColor(), + child: Padding( + padding: const EdgeInsets.all(10), + child: FilledButton.icon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.all(10), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w200), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), + onPressed: () => _handlePress(context), + icon: const Icon(Icons.playlist_add_check), + label: Text(zulipLocalizations.markAsReadLabel(unreadCount))))))); + } +} + class RecipientHeader extends StatelessWidget { const RecipientHeader({super.key, required this.message}); @@ -635,3 +702,75 @@ final _kMessageTimestampStyle = TextStyle( fontSize: 12, fontWeight: FontWeight.w400, color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor()); + +Future markNarrowAsRead(BuildContext context, ApiConnection connection, Narrow narrow) async { + final zulipLocalizations = ZulipLocalizations.of(context); + // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] + // will be the oldest non-muted unread message, which would + // result in muted unreads older than the first unread not + // being processed. + Anchor anchor = AnchorCode.oldest; + int responseCount = 0; + int updatedCount = 0; + while (true) { + final result = await updateMessageFlagsForNarrow(connection, + anchor: anchor, + // anchor="oldest" is an anchor ID lower than any valid + // message ID; and follow-up requests will have already + // processed the anchor ID, so we just want this to be + // unconditionally false. + includeAnchor: false, + // There is an upper limit of 5000 messages per batch + // (that is, numBefore + numAfter <= 5000) enforced + // on the server at zulip:zerver/views/message_flags.py. + // zulip-mobile:src/action-sheets/index.js uses `numAfter:5000` + // here, but web uses 1000 for more responsive feedback. + // See zulip@f0d87fcf6. + numBefore: 0, + numAfter: 1000, + narrow: narrow.apiEncode(), + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + if (!context.mounted) return; + responseCount++; + updatedCount += result.updatedCount; + + if (result.foundNewest) { + if (responseCount > 1) { + // We previously showed an in-progress SnackBar, so say we're done. + // There may be a backlog of SnackBars accumulated in the queue + // so be sure to clear them out here. + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(zulipLocalizations.markAsReadComplete(updatedCount)))); + } + break; + } + + if (result.lastProcessedId == null) { + // No messages were in the range of the request. + // This should be impossible given that found_newest was false + // (and that our num_after was positive.) + await showErrorDialog(context: context, + title: zulipLocalizations.errorMarkAsReadFailedTitle, + message: zulipLocalizations.errorInvalidResponse); + return; + } + anchor = NumericAnchor(result.lastProcessedId!); + + // The task is taking a while, so tell the user we're working on it. + // No need to say how many messages, as the MarkAsUnread widget should + // follow along. + // TODO: Ideally we'd have a progress widget here that showed up based + // on actual time elapsed -- so it could appear before the first + // batch returns, if that takes a while -- and that then stuck + // around continuously until the task ends. But we don't have an + // off-the-shelf way to wire up such a thing, and marking a giant + // number of messages unread isn't a common enough flow to be worth + // substantial effort on UI polish. So for now, we use a SnackBar, + // even though they may feel a bit janky. + ScaffoldMessenger.of(context).showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(zulipLocalizations.markAsReadInProgress))); + } +} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f95ff687935..c452fe84d71 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; @@ -50,6 +51,8 @@ void main() { await tester.pumpWidget( MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: GlobalStoreWidget( child: PerAccountStoreWidget( accountId: eg.selfAccount.id, @@ -71,7 +74,7 @@ void main() { testWidgets('basic', (tester) async { await setupMessageListPage(tester, foundOldest: false, messages: List.generate(200, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser))); - check(itemCount(tester)).equals(201); + check(itemCount(tester)).equals(202); // Fling-scroll upward... await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); @@ -84,7 +87,7 @@ void main() { await tester.pump(Duration.zero); // Allow a frame for the response to arrive. // Now we have more messages. - check(itemCount(tester)).equals(301); + check(itemCount(tester)).equals(302); }); testWidgets('observe double-fetch glitch', (tester) async {