Skip to content

model: Add Unreads model, for tracking unread-message counts #304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 23, 2023
11 changes: 9 additions & 2 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,15 @@ class DeleteMessageEvent extends Event {
this.topic,
});

factory DeleteMessageEvent.fromJson(Map<String, dynamic> json) =>
_$DeleteMessageEventFromJson(json);
factory DeleteMessageEvent.fromJson(Map<String, dynamic> json) {
final result = _$DeleteMessageEventFromJson(json);
// Crunchy-shell validation
if (result.messageType == MessageType.stream) {
result.streamId as int;
result.topic as String;
}
return result;
}

@override
Map<String, dynamic> toJson() => _$DeleteMessageEventToJson(this);
Expand Down
12 changes: 8 additions & 4 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';

import '../../model/algorithms.dart';
import 'model.dart';

part 'initial_snapshot.g.dart';
Expand Down Expand Up @@ -227,10 +228,13 @@ class UnreadMessagesSnapshot {

final List<UnreadStreamSnapshot> streams;
final List<UnreadHuddleSnapshot> huddles;

// Unlike other lists of message IDs here, [mentions] is *not* sorted.
final List<int> mentions;
Copy link
Member

Choose a reason for hiding this comment

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

Rereading the zulip-mobile type definitions here, I see we have:

    // Unlike other lists of message IDs here, `mentions` is *not* sorted.

That warning is probably a good one to have in a comment here, too.

(It looks like the PR's data structure already has no trouble with that, as it promptly converts it to a Set and stops caring about the order.)


final bool oldUnreadsMissing;

UnreadMessagesSnapshot({
const UnreadMessagesSnapshot({
required this.count,
required this.dms,
required this.streams,
Expand Down Expand Up @@ -260,7 +264,7 @@ class UnreadDmSnapshot {
UnreadDmSnapshot({
required this.otherUserId,
required this.unreadMessageIds,
});
}) : assert(isSortedWithoutDuplicates(unreadMessageIds));

factory UnreadDmSnapshot.fromJson(Map<String, dynamic> json) =>
_$UnreadDmSnapshotFromJson(json);
Expand All @@ -279,7 +283,7 @@ class UnreadStreamSnapshot {
required this.topic,
required this.streamId,
required this.unreadMessageIds,
});
}) : assert(isSortedWithoutDuplicates(unreadMessageIds));

factory UnreadStreamSnapshot.fromJson(Map<String, dynamic> json) =>
_$UnreadStreamSnapshotFromJson(json);
Expand All @@ -296,7 +300,7 @@ class UnreadHuddleSnapshot {
UnreadHuddleSnapshot({
required this.userIdsString,
required this.unreadMessageIds,
});
}) : assert(isSortedWithoutDuplicates(unreadMessageIds));

factory UnreadHuddleSnapshot.fromJson(Map<String, dynamic> json) =>
_$UnreadHuddleSnapshotFromJson(json);
Expand Down
75 changes: 75 additions & 0 deletions lib/model/algorithms.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@

import 'package:collection/collection.dart';

/// Returns the index in [sortedList] of an element matching the given [key],
/// if there is one.
///
Expand Down Expand Up @@ -41,3 +43,76 @@ int binarySearchByKey<E, K>(
}
return -1;
}

bool isSortedWithoutDuplicates(List<int> items) {
final length = items.length;
if (length == 0) {
return true;
}
int lastItem = items[0];
for (int i = 1; i < length; i++) {
final item = items[i];
if (item <= lastItem) {
return false;
}
lastItem = item;
}
return true;
}

/// The union of sets, represented as sorted lists.
///
/// The inputs must be sorted (by `<`) and without duplicates (by `==`).
///
/// The output will contain all the elements found in either input, again
/// sorted and without duplicates.
// When implementing this, it was convenient to have it return a [QueueList].
// We can make it more general if needed:
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unreads.20model/near/1647754
QueueList<int> setUnion(Iterable<int> xs, Iterable<int> ys) {
// This will overshoot by the number of elements that occur in both lists.
// That may make this optimization less effective, but it will not cause
// incorrectness.
final capacity = xs is List && ys is List // [List]s should have efficient `.length`
? xs.length + ys.length
: null;
final result = QueueList<int>(capacity);

final iterX = xs.iterator;
final iterY = ys.iterator;
late bool xHasElement;
void moveX() => xHasElement = iterX.moveNext();
late bool yHasElement;
void moveY() => yHasElement = iterY.moveNext();

moveX();
moveY();
while (true) {
if (!xHasElement || !yHasElement) {
break;
}

int x = iterX.current;
int y = iterY.current;
if (x < y) {
result.add(x);
moveX();
} else if (x != y) {
result.add(y);
moveY();
} else { // x == y
result.add(x);
moveX();
moveY();
}
}
while (xHasElement) {
result.add(iterX.current);
moveX();
}
while (yHasElement) {
result.add(iterY.current);
moveY();
}
return result;
}
55 changes: 29 additions & 26 deletions lib/model/narrow.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@

import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
import '../api/model/narrow.dart';
import '../api/route/messages.dart';
import 'algorithms.dart';

/// A Zulip narrow.
sealed class Narrow {
Expand Down Expand Up @@ -119,62 +121,63 @@ class TopicNarrow extends Narrow implements SendableNarrow {
int get hashCode => Object.hash('TopicNarrow', streamId, topic);
}

bool _isSortedWithoutDuplicates(List<int> items) {
final length = items.length;
if (length == 0) {
return true;
}
int lastItem = items[0];
for (int i = 1; i < length; i++) {
final item = items[i];
if (item <= lastItem) {
return false;
}
lastItem = item;
}
return true;
}

/// The narrow for a direct-message conversation.
// Zulip has many ways of representing a DM conversation; for example code
// handling many of them, see zulip-mobile:src/utils/recipient.js .
// Please add more constructors and getters here to handle any of those
// as we turn out to need them.
class DmNarrow extends Narrow implements SendableNarrow {
DmNarrow({required this.allRecipientIds, required int selfUserId})
: assert(_isSortedWithoutDuplicates(allRecipientIds)),
: assert(isSortedWithoutDuplicates(allRecipientIds)),
assert(allRecipientIds.contains(selfUserId)),
_selfUserId = selfUserId;

factory DmNarrow.ofMessage(DmMessage message, {required int selfUserId}) {
factory DmNarrow.withUser(int userId, {required int selfUserId}) {
return DmNarrow(
allRecipientIds: List.unmodifiable(message.allRecipientIds),
allRecipientIds: {userId, selfUserId}.toList()..sort(),
selfUserId: selfUserId,
);
}

/// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations].
factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) {
factory DmNarrow.withUsers(List<int> userIds, {required int selfUserId}) {
return DmNarrow(
allRecipientIds: [...conversation.userIds, selfUserId]..sort(),
allRecipientIds: {...userIds, selfUserId}.toList()..sort(),
selfUserId: selfUserId,
);
}

factory DmNarrow.withUser(int userId, {required int selfUserId}) {
factory DmNarrow.ofMessage(DmMessage message, {required int selfUserId}) {
return DmNarrow(
allRecipientIds: {userId, selfUserId}.toList()..sort(),
allRecipientIds: List.unmodifiable(message.allRecipientIds),
selfUserId: selfUserId,
);
}

factory DmNarrow.withUsers(List<int> userIds, {required int selfUserId}) {
/// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations].
factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) {
return DmNarrow(
allRecipientIds: {...userIds, selfUserId}.toList()..sort(),
allRecipientIds: [...conversation.userIds, selfUserId]..sort(),
selfUserId: selfUserId,
);
}

/// A [DmNarrow] from an [UnreadHuddleSnapshot].
factory DmNarrow.ofUnreadHuddleSnapshot(UnreadHuddleSnapshot snapshot, {required int selfUserId}) {
final userIds = snapshot.userIdsString.split(',').map((id) => int.parse(id));
return DmNarrow(selfUserId: selfUserId,
// (already sorted; see API doc)
allRecipientIds: userIds.toList(growable: false));
}

factory DmNarrow.ofUpdateMessageFlagsMessageDetail(
UpdateMessageFlagsMessageDetail detail, {
required int selfUserId,
}) {
assert(detail.type == MessageType.private);
return DmNarrow(selfUserId: selfUserId,
allRecipientIds: [...detail.userIds!, selfUserId]..sort());
}

/// The user IDs of everyone in the conversation, sorted.
///
/// Each message in the conversation is sent by one of these users
Expand Down
9 changes: 8 additions & 1 deletion lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'autocomplete.dart';
import 'database.dart';
import 'message_list.dart';
import 'recent_dm_conversations.dart';
import 'unreads.dart';

export 'package:drift/drift.dart' show Value;
export 'database.dart' show Account, AccountsCompanion;
Expand Down Expand Up @@ -154,6 +155,7 @@ class PerAccountStore extends ChangeNotifier {
realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts,
customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields),
userSettings = initialSnapshot.userSettings,
unreads = Unreads(initial: initialSnapshot.unreadMsgs, selfUserId: account.userId),
users = Map.fromEntries(
initialSnapshot.realmUsers
.followedBy(initialSnapshot.realmNonActiveUsers)
Expand Down Expand Up @@ -181,6 +183,7 @@ class PerAccountStore extends ChangeNotifier {

// Data attached to the self-account on the realm.
final UserSettings? userSettings; // TODO(server-5)
final Unreads unreads;

// Users and data about them.
final Map<int, User> users;
Expand Down Expand Up @@ -306,19 +309,23 @@ class PerAccountStore extends ChangeNotifier {
for (final view in _messageListViews) {
view.maybeAddMessage(event.message);
}
unreads.handleMessageEvent(event);
} else if (event is UpdateMessageEvent) {
assert(debugLog("server event: update_message ${event.messageId}"));
for (final view in _messageListViews) {
view.maybeUpdateMessage(event);
}
unreads.handleUpdateMessageEvent(event);
} else if (event is DeleteMessageEvent) {
assert(debugLog("server event: delete_message ${event.messageIds}"));
// TODO handle
// TODO handle in message lists
unreads.handleDeleteMessageEvent(event);
} else if (event is UpdateMessageFlagsEvent) {
assert(debugLog("server event: update_message_flags/${event.op} ${event.flag.toJson()}"));
for (final view in _messageListViews) {
view.maybeUpdateMessageFlags(event);
}
unreads.handleUpdateMessageFlagsEvent(event);
} else if (event is ReactionEvent) {
assert(debugLog("server event: reaction/${event.op}"));
for (final view in _messageListViews) {
Expand Down
Loading