Skip to content

Commit

Permalink
msglist: Add MarkAsRead widget
Browse files Browse the repository at this point in the history
  • Loading branch information
sirpengi committed Nov 2, 2023
1 parent 33c0f92 commit 0731b86
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 6 deletions.
26 changes: 26 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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"
Expand Down
147 changes: 143 additions & 4 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -274,10 +278,10 @@ class _MessageListState extends State<MessageList> 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.
Expand All @@ -286,7 +290,9 @@ class _MessageListState extends State<MessageList> 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(
Expand All @@ -305,7 +311,7 @@ class _MessageListState extends State<MessageList> 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);
}
});
Expand Down Expand Up @@ -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});

Expand Down Expand Up @@ -635,3 +702,75 @@ final _kMessageTimestampStyle = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor());

Future<void> 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)));
}
}
7 changes: 5 additions & 2 deletions test/widgets/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +51,8 @@ void main() {

await tester.pumpWidget(
MaterialApp(
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
supportedLocales: ZulipLocalizations.supportedLocales,
home: GlobalStoreWidget(
child: PerAccountStoreWidget(
accountId: eg.selfAccount.id,
Expand All @@ -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);
Expand All @@ -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 {
Expand Down

0 comments on commit 0731b86

Please sign in to comment.