diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 477680b745..b10a7587bd 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -123,6 +123,8 @@ class Unreads extends ChangeNotifier { final int selfUserId; + int countInDmNarrow(DmNarrow narrow) => dms[narrow]?.length ?? 0; + void handleMessageEvent(MessageEvent event) { final message = event.message; if (message.flags.contains(MessageFlag.read)) { diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index ccf6909c45..5c437f6f9a 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,7 +1,10 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; +import '../model/unreads.dart'; import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; @@ -23,18 +26,30 @@ class RecentDmConversationsPage extends StatefulWidget { class _RecentDmConversationsPageState extends State with PerAccountStoreAwareStateMixin { RecentDmConversationsView? model; + Unreads? unreadsModel; @override void onNewStore() { model?.removeListener(_modelChanged); model = PerAccountStoreWidget.of(context).recentDmConversationsView ..addListener(_modelChanged); + + unreadsModel?.removeListener(_modelChanged); + unreadsModel = PerAccountStoreWidget.of(context).unreads + ..addListener(_modelChanged); + } + + @override + void dispose() { + model?.removeListener(_modelChanged); + unreadsModel?.removeListener(_modelChanged); + super.dispose(); } void _modelChanged() { setState(() { - // The actual state lives in [model]. - // This method was called because that just changed. + // The actual state lives in [model] and [unreadsModel]. + // This method was called because one of those just changed. }); } @@ -45,14 +60,25 @@ class _RecentDmConversationsPageState extends State w appBar: AppBar(title: const Text('Direct messages')), body: ListView.builder( itemCount: sorted.length, - itemBuilder: (context, index) => RecentDmConversationsItem(narrow: sorted[index]))); + itemBuilder: (context, index) { + final narrow = sorted[index]; + return RecentDmConversationsItem( + narrow: narrow, + unreadCount: unreadsModel!.countInDmNarrow(narrow), + ); + })); } } class RecentDmConversationsItem extends StatelessWidget { - const RecentDmConversationsItem({super.key, required this.narrow}); + const RecentDmConversationsItem({ + super.key, + required this.narrow, + required this.unreadCount, + }); final DmNarrow narrow; + final int unreadCount; @override Widget build(BuildContext context) { @@ -66,6 +92,9 @@ class RecentDmConversationsItem extends StatelessWidget { title = selfUser.fullName; avatar = AvatarImage(userId: selfUser.userId); case [var otherUserId]: + // TODO(#296) actually don't show this row if the user is muted? + // (should we offer a "spam folder" style summary screen of recent + // 1:1 DM conversations from muted users?) final otherUser = store.users[otherUserId]; title = otherUser?.fullName ?? '(unknown user)'; avatar = AvatarImage(userId: otherUserId); @@ -101,8 +130,27 @@ class RecentDmConversationsItem extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, title))), - const SizedBox(width: 8), - // TODO(#253): Unread count + const SizedBox(width: 12), + unreadCount > 0 + ? Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: const Color.fromRGBO(102, 102, 153, 0.15), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 0, 4, 1), + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 16, + height: (18 / 16), + fontFeatures: [FontFeature.enable('smcp')], // small caps + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context)), + unreadCount.toString())))) + : const SizedBox(), ]))); } } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index d6ee6d9b88..6d3dfab777 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -35,6 +35,10 @@ extension ValueNotifierChecks on Subject> { Subject get value => has((c) => c.value, 'value'); } +extension TextChecks on Subject { + Subject get data => has((t) => t.data, 'data'); +} + extension TextStyleChecks on Subject { Subject get inherit => has((t) => t.inherit, 'inherit'); Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 02d6d127dc..aa1dedd3ac 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -13,6 +13,7 @@ import 'package:zulip/widgets/recent_dm_conversations.dart'; import 'package:zulip/widgets/store.dart'; import '../example_data.dart' as eg; +import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; @@ -61,12 +62,9 @@ void main() { TestZulipBinding.ensureInitialized(); group('RecentDmConversationsPage', () { - Finder findConversationItem(Narrow narrow) { - return find.byWidgetPredicate( - (widget) => - widget is RecentDmConversationsItem && widget.narrow == narrow, - ); - } + Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( + (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, + ); testWidgets('page builds; conversations appear in order', (WidgetTester tester) async { final user1 = eg.user(userId: 1); @@ -109,7 +107,7 @@ void main() { }); group('RecentDmConversationsItem', () { - group('appearance', () { + group('content/appearance', () { void checkAvatar(WidgetTester tester, DmNarrow narrow) { final shape = tester.widget( find.descendant( @@ -148,8 +146,28 @@ void main() { } } + Future markMessageAsRead(WidgetTester tester, Message message) async { + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store.handleEvent(UpdateMessageFlagsAddEvent( + id: 1, flag: MessageFlag.read, all: false, messages: [message.id])); + await tester.pump(); + } + + void checkUnreadCount(WidgetTester tester, int expectedCount) { + final Text? textWidget = tester.widgetList(find.descendant( + of: find.byType(RecentDmConversationsItem), + matching: find.textContaining(RegExp(r'^\d+$'), + ))).singleOrNull; + + if (expectedCount == 0) { + check(textWidget).isNull(); + } else { + check(textWidget).isNotNull().data.equals(expectedCount.toString()); + } + } + group('self-1:1', () { - testWidgets('has right content', (WidgetTester tester) async { + testWidgets('has right title/avatar', (WidgetTester tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -172,10 +190,19 @@ void main() { newNameForSelfUser: name); checkTitle(tester, name, 2); }); + + testWidgets('unread counts', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, users: [], dmMessages: [message]); + + checkUnreadCount(tester, 1); + await markMessageAsRead(tester, message); + checkUnreadCount(tester, 0); + }); }); group('1:1', () { - testWidgets('has right content', (WidgetTester tester) async { + testWidgets('has right title/avatar', (WidgetTester tester) async { final user = eg.user(userId: 1); final message = eg.dmMessage(from: eg.selfUser, to: [user]); await setupPage(tester, users: [user], dmMessages: [message]); @@ -209,6 +236,15 @@ void main() { await setupPage(tester, users: [user], dmMessages: [message]); checkTitle(tester, user.fullName, 2); }); + + testWidgets('unread counts', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + await setupPage(tester, users: [], dmMessages: [message]); + + checkUnreadCount(tester, 1); + await markMessageAsRead(tester, message); + checkUnreadCount(tester, 0); + }); }); group('group', () { @@ -220,7 +256,7 @@ void main() { return result; } - testWidgets('has right content', (WidgetTester tester) async { + testWidgets('has right title/avatar', (WidgetTester tester) async { final users = usersList(2); final user0 = users[0]; final user1 = users[1]; @@ -258,6 +294,15 @@ void main() { await setupPage(tester, users: users, dmMessages: [message]); checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + + testWidgets('unread counts', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); + await setupPage(tester, users: [], dmMessages: [message]); + + checkUnreadCount(tester, 1); + await markMessageAsRead(tester, message); + checkUnreadCount(tester, 0); + }); }); });