Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
return true;

case MentionsNarrow():
// If changing this, consider whether [Unreads.countInMentionsNarrow]
// should be changed correspondingly, so the message-list view matches
// the unread-count badge.
case StarredMessagesNarrow():
case KeywordSearchNarrow():
if (message.conversation case DmConversation(:final allRecipientIds)) {
Expand Down
42 changes: 38 additions & 4 deletions lib/model/unreads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInCombinedFeedNarrow() {
int c = 0;
for (final messageIds in dms.values) {
c = c + messageIds.length;
}
c += countInDms();
for (final MapEntry(key: streamId, value: topics) in streams.entries) {
for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
if (channelStore.isTopicVisible(streamId, topic)) {
Expand Down Expand Up @@ -222,14 +220,50 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {

int countInDmNarrow(DmNarrow narrow) => dms[narrow]?.length ?? 0;

int countInMentionsNarrow() => mentions.length;
/// The unread count for the mentions narrow.
///
/// This excludes DM messages in conversations that are considered muted,
/// by [UserStore.shouldMuteDmConversation].
// If changing which messages to exclude, consider whether the @-mentions
// view should change its "is-message-visible" code correspondingly,
// so the unread-count badge matches what you see in that view.
// TODO: deduplicate "is-message-visible" code
// between [Unreads] and [MessageListView]?
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInMentionsNarrow() {
int c = 0;
for (final messageId in mentions) {
final narrow = locatorMap[messageId];
if (narrow == null) continue; // TODO(log)
switch (narrow) {
case DmNarrow():
if (channelStore.shouldMuteDmConversation(narrow)) continue;
case TopicNarrow():
}
c++;
}
return c;
}

// TODO: Implement unreads handling.
int countInStarredMessagesNarrow() => 0;

// TODO: Implement unreads handling?
int countInKeywordSearchNarrow() => 0;

/// The aggregated unread count for DM conversations.
///
/// This excludes conversations that are considered muted,
/// by [UserStore.shouldMuteDmConversation].
int countInDms() {
int c = 0;
for (final MapEntry(key: narrow, value: messageIds) in dms.entries) {
if (channelStore.shouldMuteDmConversation(narrow)) continue;
c += messageIds.length;
}
return c;
}

int countInNarrow(Narrow narrow) {
switch (narrow) {
case CombinedFeedNarrow():
Expand Down
42 changes: 42 additions & 0 deletions lib/widgets/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import 'store.dart';
import 'subscription_list.dart';
import 'text.dart';
import 'theme.dart';
import 'unread_count_badge.dart';
import 'user.dart';

enum _HomePageTab {
Expand Down Expand Up @@ -506,6 +507,8 @@ abstract class _MenuButton extends StatelessWidget {
color: selected ? designVariables.iconSelected : designVariables.icon);
}

Widget? buildTrailing(BuildContext context) => null;

void onPressed(BuildContext context);

void _handlePress(BuildContext context) {
Expand Down Expand Up @@ -546,6 +549,8 @@ abstract class _MenuButton extends StatelessWidget {
~WidgetState.pressed: selected ? borderSideSelected : null,
}));

final trailing = buildTrailing(context);

return AnimatedScaleOnTap(
duration: const Duration(milliseconds: 100),
scaleEnd: 0.95,
Expand All @@ -562,6 +567,7 @@ abstract class _MenuButton extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 19, height: 26 / 19)
.merge(weightVariableTextStyle(context, wght: selected ? 600 : 400)))),
?trailing,
]))));
}
}
Expand Down Expand Up @@ -612,6 +618,18 @@ class _InboxButton extends _NavigationBarMenuButton {
return zulipLocalizations.inboxPageTitle;
}

@override
Widget? buildTrailing(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final unreadCount = store.unreads.countInCombinedFeedNarrow();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I worry a bit about adding this call site given this TODO for #370:

  // TODO(#370): maintain this count incrementally, rather than recomputing from scratch
  int countInCombinedFeedNarrow() {

Do you have any timings on how long this takes with lots of unreads?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, this is only in the main-menu bottom sheet, not the bottom tabs. In that case I guess it doesn't make the impact any worse than it is now with the mark-as-read button in the message list.

if (unreadCount == 0) return null;
return UnreadCountBadge(
style: UnreadCountBadgeStyle.mainMenu,
count: unreadCount,
channelIdForBackground: null,
);
}

@override
_HomePageTab get navigationTarget => _HomePageTab.inbox;
}
Expand All @@ -627,6 +645,18 @@ class _MentionsButton extends _MenuButton {
return zulipLocalizations.mentionsPageTitle;
}

@override
Widget? buildTrailing(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final unreadCount = store.unreads.countInMentionsNarrow();
if (unreadCount == 0) return null;
return UnreadCountBadge(
style: UnreadCountBadgeStyle.mainMenu,
count: unreadCount,
channelIdForBackground: null,
);
}

@override
void onPressed(BuildContext context) {
Navigator.of(context).push(MessageListPage.buildRoute(
Expand Down Expand Up @@ -696,6 +726,18 @@ class _DirectMessagesButton extends _NavigationBarMenuButton {
return zulipLocalizations.recentDmConversationsPageTitle;
}

@override
Widget? buildTrailing(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final unreadCount = store.unreads.countInDms();
if (unreadCount == 0) return null;
return UnreadCountBadge(
style: UnreadCountBadgeStyle.mainMenu,
count: unreadCount,
channelIdForBackground: null,
);
}

@override
_HomePageTab get navigationTarget => _HomePageTab.directMessages;
}
Expand Down
46 changes: 36 additions & 10 deletions lib/widgets/unread_count_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,20 @@ import 'theme.dart';
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-186671&m=dev
/// It looks like that component was created for the main menu,
/// then adapted for various other contexts, like the Inbox page.
/// See [UnreadCountBadgeStyle].
///
/// Currently this widget supports only those other contexts (not the main menu)
/// and only the component's "kind=unread" variant (not "kind=quantity").
/// For example, the "Channels" page and the topic-list page:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6205-26001&m=dev
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6823-37113&m=dev
/// (We use this for the topic-list page even though the Figma makes it a bit
/// more compact there…the inconsistency seems worse and might be accidental.)
// TODO support the main-menu context, update dartdoc
/// Currently this widget supports only the component's "kind=unread" variant,
/// not "kind=quantity".
// TODO support the "kind=quantity" variant, update dartdoc
class UnreadCountBadge extends StatelessWidget {
const UnreadCountBadge({
super.key,
this.style = UnreadCountBadgeStyle.other,
required this.count,
required this.channelIdForBackground,
});

final UnreadCountBadgeStyle style;
final int count;

/// An optional [Subscription.streamId], for a channel-colorized background.
Expand Down Expand Up @@ -55,23 +52,52 @@ class UnreadCountBadge extends StatelessWidget {
backgroundColor = designVariables.bgCounterUnread;
}

final padding = switch (style) {
UnreadCountBadgeStyle.mainMenu =>
const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
UnreadCountBadgeStyle.other =>
const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
};

final double wght = switch (style) {
UnreadCountBadgeStyle.mainMenu => 600,
UnreadCountBadgeStyle.other => 500,
};

return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: backgroundColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
padding: padding,
child: Text(
style: TextStyle(
fontSize: 16,
height: (16 / 16),
color: textColor,
).merge(weightVariableTextStyle(context, wght: 500)),
).merge(weightVariableTextStyle(context, wght: wght)),
count.toString())));
}
}

enum UnreadCountBadgeStyle {
/// The style to use in the main menu.
///
/// Figma:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-185126&m=dev
mainMenu,

/// The style to use in other contexts besides the main menu.
///
/// Other contexts include the "Channels" page and the topic-list page:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6205-26001&m=dev
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6823-37113&m=dev
/// (We use this for the topic-list page even though the Figma makes it a bit
/// more compact there…the inconsistency seems worse and might be accidental.)
other,
}

class MutedUnreadBadge extends StatelessWidget {
const MutedUnreadBadge({super.key});

Expand Down
92 changes: 81 additions & 11 deletions test/model/unreads_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ void main() {
await store.addSubscription(eg.subscription(stream2));
await store.addSubscription(eg.subscription(stream3, isMuted: true));
await store.setUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted);
await store.setMutedUsers([eg.thirdUser.userId]);
fillWithMessages([
eg.streamMessage(stream: stream1, topic: 'a', flags: []),
eg.streamMessage(stream: stream1, topic: 'b', flags: []),
Expand All @@ -200,7 +201,7 @@ void main() {
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
]);
check(model.countInCombinedFeedNarrow()).equals(5);
check(model.countInCombinedFeedNarrow()).equals(4);
});

test('countInChannel/Narrow', () async {
Expand Down Expand Up @@ -249,16 +250,71 @@ void main() {
check(model.countInDmNarrow(narrow)).equals(5);
});

test('countInMentionsNarrow', () async {
final stream = eg.stream();
prepare();
await store.addStream(stream);
fillWithMessages([
eg.streamMessage(stream: stream, flags: []),
eg.streamMessage(stream: stream, flags: [MessageFlag.mentioned]),
eg.streamMessage(stream: stream, flags: [MessageFlag.wildcardMentioned]),
]);
check(model.countInMentionsNarrow()).equals(2);
group('countInMentionsNarrow', () {
test('smoke', () async {
final stream = eg.stream();
prepare();
await store.addUser(eg.otherUser);
await store.addStream(stream);
fillWithMessages([
eg.streamMessage(stream: stream, flags: []),
eg.streamMessage(stream: stream, flags: [MessageFlag.mentioned]),
eg.streamMessage(stream: stream, flags: [MessageFlag.wildcardMentioned]),
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]),
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.wildcardMentioned]),
]);
check(model.countInMentionsNarrow()).equals(4);
});

test('excludes unreads in muted DM conversations', () async {
// Regression test for https://github.com/zulip/zulip-flutter/issues/2026
prepare();
await store.addUser(eg.otherUser);
await store.setMutedUsers([eg.otherUser.userId]);

fillWithMessages([
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]),
]);
check(model.countInMentionsNarrow()).equals(0);
});

test('not affected by DM sender being unknown', () async {
prepare();
final unknownUser = eg.user();
check(store.getUser(unknownUser.userId)).isNull();

fillWithMessages([
eg.dmMessage(from: unknownUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]),
]);
check(model.countInMentionsNarrow()).equals(1);
});

test('not affected by channel being unknown/unsubscribed/muted, nor topic being muted', () async {
prepare();

final channel1 = eg.stream(); // unknown

final channel2 = eg.stream(); // unsubscribed
await store.addStream(channel2);
check(store.subscriptions[channel2.streamId]).isNull();

final channel3 = eg.stream(); // muted
await store.addStream(channel3);
await store.addSubscription(eg.subscription(channel3, isMuted: true));

final channel4 = eg.stream(); // unmuted, containing muted topic
await store.addStream(channel4);
await store.addSubscription(eg.subscription(channel4, isMuted: false));
await store.setUserTopic(channel4, 'a', UserTopicVisibilityPolicy.muted);

fillWithMessages([
eg.streamMessage(stream: channel1, flags: [MessageFlag.mentioned]),
eg.streamMessage(stream: channel2, flags: [MessageFlag.mentioned]),
eg.streamMessage(stream: channel3, flags: [MessageFlag.mentioned]),
eg.streamMessage(stream: channel4, topic: 'a', flags: [MessageFlag.mentioned]),
]);
check(model.countInMentionsNarrow()).equals(4);
});
});

test('countInStarredMessagesNarrow', () async {
Expand All @@ -271,6 +327,20 @@ void main() {
]);
check(model.countInStarredMessagesNarrow()).equals(0);
});

test('countInDms', () async {
prepare();
await store.setMutedUsers([eg.thirdUser.userId]);
fillWithMessages([
// No one is muted: don't exclude
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
// Everyone is muted: exclude
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
// One is muted, one isn't: don't exclude
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser], flags: []),
]);
check(model.countInDms()).equals(2);
});
});

group('isUnread', () {
Expand Down