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

msglist: Handle UserTopicEvent, hiding/showing messages as needed #822

Merged
merged 5 commits into from
Aug 13, 2024
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
62 changes: 56 additions & 6 deletions lib/model/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,21 @@ mixin ChannelStore {
/// For UI contexts that are not specific to a particular stream, see
/// [isTopicVisible].
bool isTopicVisibleInStream(int streamId, String topic) {
switch (topicVisibilityPolicy(streamId, topic)) {
return _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic));
}

/// Whether the given event will change the result of [isTopicVisibleInStream]
/// for its stream and topic, compared to the current state.
VisibilityEffect willChangeIfTopicVisibleInStream(UserTopicEvent event) {
final streamId = event.streamId;
final topic = event.topicName;
return VisibilityEffect._fromBeforeAfter(
_isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic)),
_isTopicVisibleInStream(event.visibilityPolicy));
}

static bool _isTopicVisibleInStream(UserTopicVisibilityPolicy policy) {
switch (policy) {
case UserTopicVisibilityPolicy.none:
return true;
case UserTopicVisibilityPolicy.muted:
Expand All @@ -48,7 +62,21 @@ mixin ChannelStore {
/// For UI contexts that are specific to a particular stream, see
/// [isTopicVisibleInStream].
bool isTopicVisible(int streamId, String topic) {
switch (topicVisibilityPolicy(streamId, topic)) {
return _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic));
}

/// Whether the given event will change the result of [isTopicVisible]
/// for its stream and topic, compared to the current state.
VisibilityEffect willChangeIfTopicVisible(UserTopicEvent event) {
final streamId = event.streamId;
final topic = event.topicName;
return VisibilityEffect._fromBeforeAfter(
_isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic)),
_isTopicVisible(streamId, event.visibilityPolicy));
}

bool _isTopicVisible(int streamId, UserTopicVisibilityPolicy policy) {
switch (policy) {
case UserTopicVisibilityPolicy.none:
switch (subscriptions[streamId]?.isMuted) {
case false: return true;
Expand All @@ -67,6 +95,28 @@ mixin ChannelStore {
}
}

/// Whether and how a given [UserTopicEvent] will affect the results
/// that [ChannelStore.isTopicVisible] or [ChannelStore.isTopicVisibleInStream]
/// would give for some messages.
enum VisibilityEffect {
/// The event will have no effect on the visibility results.
none,

/// The event will change some visibility results from true to false.
muted,

/// The event will change some visibility results from false to true.
unmuted;

factory VisibilityEffect._fromBeforeAfter(bool before, bool after) {
return switch ((before, after)) {
(false, true) => VisibilityEffect.unmuted,
(true, false) => VisibilityEffect.muted,
_ => VisibilityEffect.none,
};
}
}

/// The implementation of [ChannelStore] that does the work.
///
/// Generally the only code that should need this class is [PerAccountStore]
Expand Down Expand Up @@ -156,10 +206,10 @@ class ChannelStoreImpl with ChannelStore {
switch (event) {
case SubscriptionAddEvent():
for (final subscription in event.subscriptions) {
assert(streams.containsKey(subscription.streamId)
&& streams[subscription.streamId] is! Subscription);
assert(streamsByName.containsKey(subscription.name)
&& streamsByName[subscription.name] is! Subscription);
assert(streams.containsKey(subscription.streamId));
assert(streams[subscription.streamId] is! Subscription);
assert(streamsByName.containsKey(subscription.name));
assert(streamsByName[subscription.name] is! Subscription);
assert(!subscriptions.containsKey(subscription.streamId));
streams[subscription.streamId] = subscription;
streamsByName[subscription.name] = subscription;
Expand Down
6 changes: 6 additions & 0 deletions lib/model/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ class MessageStoreImpl with MessageStore {
}
}

void handleUserTopicEvent(UserTopicEvent event) {
for (final view in _messageListViews) {
view.handleUserTopicEvent(event);
}
}

void handleMessageEvent(MessageEvent event) {
// If the message is one we already know about (from a fetch),
// clobber it with the one from the event system.
Expand Down
76 changes: 76 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
import 'algorithms.dart';
import 'channel.dart';
import 'content.dart';
import 'narrow.dart';
import 'store.dart';
Expand Down Expand Up @@ -158,6 +159,38 @@ mixin _MessageSequence {
_processMessage(messages.length - 1);
}

/// Removes all messages from the list that satisfy [test].
///
/// Returns true if any messages were removed, false otherwise.
bool _removeMessagesWhere(bool Function(Message) test) {
// Before we find a message to remove, there's no need to copy elements.
// This is like the loop below, but simplified for `target == candidate`.
int candidate = 0;
while (true) {
if (candidate == messages.length) return false;
if (test(messages[candidate])) break;
candidate++;
}

int target = candidate;
candidate++;
assert(contents.length == messages.length);
while (candidate < messages.length) {
if (test(messages[candidate])) {
candidate++;
continue;
}
messages[target] = messages[candidate];
contents[target] = contents[candidate];
target++; candidate++;
}
messages.length = target;
contents.length = target;
assert(contents.length == messages.length);
_reprocessAll();
return true;
}

/// Removes the given messages, if present.
///
/// Returns true if at least one message was present, false otherwise.
Expand Down Expand Up @@ -389,6 +422,24 @@ class MessageListView with ChangeNotifier, _MessageSequence {
}
}

/// Whether this event could affect the result that [_messageVisible]
/// would ever have returned for any possible message in this message list.
VisibilityEffect _canAffectVisibility(UserTopicEvent event) {
switch (narrow) {
case CombinedFeedNarrow():
return store.willChangeIfTopicVisible(event);

case ChannelNarrow(:final streamId):
if (event.streamId != streamId) return VisibilityEffect.none;
return store.willChangeIfTopicVisibleInStream(event);

case TopicNarrow():
case DmNarrow():
case MentionsNarrow():
return VisibilityEffect.none;
}
}

/// Whether [_messageVisible] is true for all possible messages.
///
/// This is useful for an optimization.
Expand Down Expand Up @@ -477,6 +528,31 @@ class MessageListView with ChangeNotifier, _MessageSequence {
}
}

void handleUserTopicEvent(UserTopicEvent event) {
switch (_canAffectVisibility(event)) {
case VisibilityEffect.none:
return;

case VisibilityEffect.muted:
if (_removeMessagesWhere((message) =>
(message is StreamMessage
&& message.streamId == event.streamId
&& message.topic == event.topicName))) {
notifyListeners();
}

case VisibilityEffect.unmuted:
// TODO get the newly-unmuted messages from the message store
// For now, we simplify the task by just refetching this message list
// from scratch.
if (fetched) {
_reset();
notifyListeners();
fetchInitial();
}
}
}

void handleDeleteMessageEvent(DeleteMessageEvent event) {
if (_removeMessagesById(event.messageIds)) {
notifyListeners();
Expand Down
2 changes: 2 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,8 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {

case UserTopicEvent():
assert(debugLog("server event: user_topic"));
_messages.handleUserTopicEvent(event);
// Update _channels last, so other handlers can compare to the old value.
_channels.handleUserTopicEvent(event);
notifyListeners();

Expand Down
22 changes: 18 additions & 4 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,14 @@ ZulipStream stream({
}) {
_checkPositive(streamId, 'stream ID');
_checkPositive(firstMessageId, 'message ID');
var effectiveStreamId = streamId ?? _nextStreamId();
var effectiveName = name ?? 'stream $effectiveStreamId';
var effectiveDescription = description ?? 'Description of $effectiveName';
return ZulipStream(
streamId: streamId ?? _nextStreamId(),
name: name ?? 'A stream', // TODO generate example names
description: description ?? 'A description', // TODO generate example descriptions
renderedDescription: renderedDescription ?? '<p>A description</p>', // TODO generate random
streamId: effectiveStreamId,
name: effectiveName,
description: effectiveDescription,
renderedDescription: renderedDescription ?? '<p>$effectiveDescription</p>',
dateCreated: dateCreated ?? 1686774898,
firstMessageId: firstMessageId,
inviteOnly: inviteOnly ?? false,
Expand Down Expand Up @@ -426,6 +429,17 @@ const _unreadMsgs = unreadMsgs;
// Events.
//

UserTopicEvent userTopicEvent(
int streamId, String topic, UserTopicVisibilityPolicy visibilityPolicy) {
return UserTopicEvent(
id: 1,
streamId: streamId,
topicName: topic,
lastUpdated: 1234567890,
visibilityPolicy: visibilityPolicy,
);
}

DeleteMessageEvent deleteMessageEvent(List<StreamMessage> messages) {
assert(messages.isNotEmpty);
final streamId = messages.first.streamId;
Expand Down
103 changes: 97 additions & 6 deletions test/model/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,17 @@ void main() {
}

test('initial', () {
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
final stream2 = eg.stream(streamId: 2, name: 'stream 2');
final stream1 = eg.stream();
final stream2 = eg.stream();
checkUnified(eg.store(initialSnapshot: eg.initialSnapshot(
streams: [stream1, stream2],
subscriptions: [eg.subscription(stream1)],
)));
});

test('added by events', () async {
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
final stream2 = eg.stream(streamId: 2, name: 'stream 2');
final stream1 = eg.stream();
final stream2 = eg.stream();
final store = eg.store();
checkUnified(store);

Expand Down Expand Up @@ -106,8 +106,8 @@ void main() {
});

group('topic visibility', () {
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
final stream2 = eg.stream(streamId: 2, name: 'stream 2');
final stream1 = eg.stream();
final stream2 = eg.stream();

group('getter topicVisibilityPolicy', () {
test('with nothing for stream', () {
Expand Down Expand Up @@ -189,6 +189,97 @@ void main() {
});
});

group('willChangeIfTopicVisible/InStream', () {
UserTopicEvent mkEvent(UserTopicVisibilityPolicy policy) =>
eg.userTopicEvent(stream1.streamId, 'topic', policy);

void checkChanges(PerAccountStore store,
UserTopicVisibilityPolicy newPolicy,
VisibilityEffect expectedInStream, VisibilityEffect expectedOverall) {
final event = mkEvent(newPolicy);
check(store.willChangeIfTopicVisibleInStream(event)).equals(expectedInStream);
check(store.willChangeIfTopicVisible (event)).equals(expectedOverall);
}

test('stream not muted, policy none -> followed, no change', () async {
final store = eg.store();
await store.addStream(stream1);
await store.addSubscription(eg.subscription(stream1));
checkChanges(store, UserTopicVisibilityPolicy.followed,
VisibilityEffect.none, VisibilityEffect.none);
});

test('stream not muted, policy none -> muted, means muted', () async {
final store = eg.store();
await store.addStream(stream1);
await store.addSubscription(eg.subscription(stream1));
checkChanges(store, UserTopicVisibilityPolicy.muted,
VisibilityEffect.muted, VisibilityEffect.muted);
});

test('stream muted, policy none -> followed, means none/unmuted', () async {
final store = eg.store();
await store.addStream(stream1);
await store.addSubscription(eg.subscription(stream1, isMuted: true));
checkChanges(store, UserTopicVisibilityPolicy.followed,
VisibilityEffect.none, VisibilityEffect.unmuted);
});

test('stream muted, policy none -> muted, means muted/none', () async {
final store = eg.store();
await store.addStream(stream1);
await store.addSubscription(eg.subscription(stream1, isMuted: true));
checkChanges(store, UserTopicVisibilityPolicy.muted,
VisibilityEffect.muted, VisibilityEffect.none);
});

final policies = [
UserTopicVisibilityPolicy.muted,
UserTopicVisibilityPolicy.none,
UserTopicVisibilityPolicy.unmuted,
];
for (final streamMuted in [null, false, true]) {
for (final oldPolicy in policies) {
for (final newPolicy in policies) {
final streamDesc = switch (streamMuted) {
false => "stream not muted",
true => "stream muted",
null => "stream unsubscribed",
};
test('$streamDesc, topic ${oldPolicy.name} -> ${newPolicy.name}', () async {
final store = eg.store();
await store.addStream(stream1);
if (streamMuted != null) {
await store.addSubscription(
eg.subscription(stream1, isMuted: streamMuted));
}
store.handleEvent(mkEvent(oldPolicy));
final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic');
final oldVisible = store.isTopicVisible(stream1.streamId, 'topic');

final event = mkEvent(newPolicy);
final willChangeInStream = store.willChangeIfTopicVisibleInStream(event);
final willChange = store.willChangeIfTopicVisible(event);

store.handleEvent(event);
final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic');
final newVisible = store.isTopicVisible(stream1.streamId, 'topic');

VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) {
if (newVisible == oldVisible) return VisibilityEffect.none;
if (newVisible) return VisibilityEffect.unmuted;
return VisibilityEffect.muted;
}
check(willChangeInStream)
.equals(fromOldNew(oldVisibleInStream, newVisibleInStream));
check(willChange)
.equals(fromOldNew(oldVisible, newVisible));
});
}
}
}
});

void compareTopicVisibility(PerAccountStore store, List<UserTopicItem> expected) {
final expectedStore = eg.store(initialSnapshot: eg.initialSnapshot(
userTopics: expected,
Expand Down
Loading