Skip to content
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