Skip to content

Commit

Permalink
msglist: Follow /with/ links through message moves
Browse files Browse the repository at this point in the history
Fixes: zulip#1028
  • Loading branch information
chrisbobbe committed Oct 30, 2024
1 parent ff6cc8a commit 0131961
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 22 deletions.
40 changes: 33 additions & 7 deletions lib/api/model/narrow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,30 @@ typedef ApiNarrow = List<ApiNarrowElement>;
// TODO(server-7) remove [ApiNarrowDm] reference in dartdoc
ApiNarrow resolveApiNarrowElements(ApiNarrow narrow, int zulipFeatureLevel) {
bool hasDmElement = false;
bool hasWithElement = false;
for (final element in narrow) {
switch (element) {
case ApiNarrowDm(): hasDmElement = true;
case ApiNarrowDm(): hasDmElement = true;
case ApiNarrowWith(): hasWithElement = true;
default:
}
}
if (!hasDmElement) return narrow;
if (!hasDmElement && !hasWithElement) return narrow;

final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7)

return narrow.map((element) => switch (element) {
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm),
_ => element,
}).toList();
final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9)

ApiNarrow result = narrow;
if (hasDmElement) {
result = narrow.map((element) => switch (element) {
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm),
_ => element,
}).toList();
}
if (hasWithElement && !supportsOperatorWith) {
result.removeWhere((element) => element is ApiNarrowWith);
}
return result;
}

/// An element in the list representing a narrow in the Zulip API.
Expand Down Expand Up @@ -72,6 +82,22 @@ class ApiNarrowTopic extends ApiNarrowElement {
);
}

/// An [ApiNarrowElement] with the 'with' operator.
///
/// If part of [ApiNarrow] use [resolveApiNarrowElements].
class ApiNarrowWith extends ApiNarrowElement {
@override String get operator => 'with';

@override final int operand;

ApiNarrowWith(this.operand, {super.negated});

factory ApiNarrowWith.fromJson(Map<String, dynamic> json) => ApiNarrowWith(
json['operand'] as int,
negated: json['negated'] as bool? ?? false,
);
}

/// An [ApiNarrowElement] with the 'dm', or legacy 'pm-with', operator.
///
/// An instance directly of this class must not be serialized with [jsonEncode],
Expand Down
10 changes: 8 additions & 2 deletions lib/model/internal_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
fragment.write('$streamId-$slugifiedName');
case ApiNarrowTopic():
fragment.write(_encodeHashComponent(element.operand));
case ApiNarrowWith():
fragment.write(element.operand.toString());
case ApiNarrowDmModern():
final suffix = element.operand.length >= 3 ? 'group' : 'dm';
fragment.write('${element.operand.join(',')}-$suffix');
Expand Down Expand Up @@ -151,6 +153,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {

ApiNarrowStream? streamElement;
ApiNarrowTopic? topicElement;
ApiNarrowWith? withElement;
ApiNarrowDm? dmElement;
Set<IsOperand> isElementOperands = {};

Expand Down Expand Up @@ -185,9 +188,12 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
isElementOperands.add(IsOperand.fromRawString(operand));

case _NarrowOperator.near: // TODO(#82): support for near
case _NarrowOperator.with_: // TODO(#683): support for with
continue;

case _NarrowOperator.with_:
if (withElement != null) return null;
withElement = ApiNarrowWith(int.parse(operand, radix: 10));

case _NarrowOperator.unknown:
return null;
}
Expand Down Expand Up @@ -216,7 +222,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
} else if (streamElement != null) {
final streamId = streamElement.operand;
if (topicElement != null) {
return TopicNarrow(streamId, topicElement.operand);
return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
} else {
return ChannelNarrow(streamId);
}
Expand Down
36 changes: 36 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
numAfter: 0,
);
if (this.generation > generation) return;
_adjustTopicPermalinkNarrow(result.messages.firstOrNull);
store.reconcileMessages(result.messages);
store.recentSenders.handleMessages(result.messages); // TODO(#824)
for (final message in result.messages) {
Expand All @@ -493,11 +494,46 @@ class MessageListView with ChangeNotifier, _MessageSequence {
notifyListeners();
}

/// Update [narrow] for the result of a "with" narrow (topic permalink) fetch.
///
/// To avoid an extra round trip, the server handles [ApiNarrowWith]
/// by returning results from the indicated message's current stream/topic
/// (if the user has access),
/// even if that differs from the narrow's stream/topic filters
/// because the message was moved.
///
/// If such a "redirect" happened, this helper updates the stream and topic
/// in [narrow] to match the message's current conversation.
/// It also removes the "with" component from [narrow]
/// whether or not a redirect happened.
///
/// See API doc:
/// https://zulip.com/api/construct-narrow#message-ids
void _adjustTopicPermalinkNarrow(Message? someFetchedMessageOrNull) {
final narrow = this.narrow;
if (narrow is! TopicNarrow || narrow.with_ == null) return;

switch (someFetchedMessageOrNull) {
case null:
// This can't be a redirect; a redirect can't produce an empty result.
// (The server only redirects if the message is accessible to the user,
// and if it is, it'll appear in the result, making it non-empty.)
this.narrow = narrow.sansWith();
case StreamMessage(:final streamId, :final topic):
this.narrow = TopicNarrow(streamId, topic);
case DmMessage(): // TODO(log)
assert(false);
}
}

/// Fetch the next batch of older messages, if applicable.
Future<void> fetchOlder() async {
if (haveOldest) return;
if (fetchingOlder) return;
assert(fetched);
assert(narrow is! TopicNarrow
// We only intend to send "with" in [fetchInitial]; see there.
|| (narrow as TopicNarrow).with_ == null);
assert(messages.isNotEmpty);
_fetchingOlder = true;
_updateEndMarkers();
Expand Down
17 changes: 12 additions & 5 deletions lib/model/narrow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,17 @@ class ChannelNarrow extends Narrow {
}

class TopicNarrow extends Narrow implements SendableNarrow {
const TopicNarrow(this.streamId, this.topic);
const TopicNarrow(this.streamId, this.topic, {this.with_});

factory TopicNarrow.ofMessage(StreamMessage message) {
return TopicNarrow(message.streamId, message.topic);
}

final int streamId;
final String topic;
final int? with_;

TopicNarrow sansWith() => TopicNarrow(streamId, topic);

@override
bool containsMessage(Message message) {
Expand All @@ -108,22 +111,26 @@ class TopicNarrow extends Narrow implements SendableNarrow {
}

@override
ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)];
ApiNarrow apiEncode() => [
ApiNarrowStream(streamId),
ApiNarrowTopic(topic),
if (with_ != null) ApiNarrowWith(with_!),
];

@override
StreamDestination get destination => StreamDestination(streamId, topic);

@override
String toString() => 'TopicNarrow($streamId, $topic)';
String toString() => 'TopicNarrow($streamId, $topic, with: $with_)';

@override
bool operator ==(Object other) {
if (other is! TopicNarrow) return false;
return other.streamId == streamId && other.topic == topic;
return other.streamId == streamId && other.topic == topic && other.with_ == with_;
}

@override
int get hashCode => Object.hash('TopicNarrow', streamId, topic);
int get hashCode => Object.hash('TopicNarrow', streamId, topic, with_);
}

/// The narrow for a direct-message conversation.
Expand Down
10 changes: 10 additions & 0 deletions test/api/route/messages_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ void main() {
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
checkNarrow(const TopicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
{'operator': 'with', 'operand': 1},
]));
checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([
{'operator': 'is', 'operand': 'mentioned'},
]));
Expand All @@ -203,6 +208,11 @@ void main() {
checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([
{'operator': 'pm-with', 'operand': [123, 234]},
]));
connection.zulipFeatureLevel = 270;
checkNarrow(const TopicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
connection.zulipFeatureLevel = eg.futureZulipFeatureLevel;
});
});
Expand Down
6 changes: 3 additions & 3 deletions test/model/internal_link_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,12 @@ void main() {
const testCases = [
('/#narrow/stream/check/topic/test', TopicNarrow(1, 'test')),
('/#narrow/stream/mobile/subject/topic/near/378333', TopicNarrow(3, 'topic')),
('/#narrow/stream/mobile/subject/topic/with/1', TopicNarrow(3, 'topic')),
('/#narrow/stream/mobile/subject/topic/with/1', TopicNarrow(3, 'topic', with_: 1)),
('/#narrow/stream/mobile/topic/topic/', TopicNarrow(3, 'topic')),
('/#narrow/stream/stream/topic/topic/near/1', TopicNarrow(5, 'topic')),
('/#narrow/stream/stream/topic/topic/with/22', TopicNarrow(5, 'topic')),
('/#narrow/stream/stream/topic/topic/with/22', TopicNarrow(5, 'topic', with_: 22)),
('/#narrow/stream/stream/subject/topic/near/1', TopicNarrow(5, 'topic')),
('/#narrow/stream/stream/subject/topic/with/333', TopicNarrow(5, 'topic')),
('/#narrow/stream/stream/subject/topic/with/333', TopicNarrow(5, 'topic', with_: 333)),
('/#narrow/stream/stream/subject/topic', TopicNarrow(5, 'topic')),
];
testExpectedNarrows(testCases, streams: streams);
Expand Down
54 changes: 54 additions & 0 deletions test/model/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import '../example_data.dart' as eg;
import '../fake_async.dart';
import '../stdlib_checks.dart';
import 'content_checks.dart';
import 'narrow_checks.dart';
import 'recent_senders_test.dart' as recent_senders_test;
import 'test_store.dart';

Expand Down Expand Up @@ -95,6 +96,9 @@ void main() {
final someStream = eg.stream();
const someTopic = 'some topic';

final otherStream = eg.stream();
const otherTopic = 'other topic';

group('smoke', () {
Future<void> smoke(
Narrow narrow,
Expand Down Expand Up @@ -130,6 +134,22 @@ void main() {
await smoke(TopicNarrow(someStream.streamId, someTopic),
(i) => eg.streamMessage(stream: someStream, topic: someTopic));
});

test('topic permalink, message was not moved', () async {
await smoke(TopicNarrow(someStream.streamId, someTopic, with_: 1),
(int i) => eg.streamMessage(
id: i == 0 ? 1 : null,
stream: someStream,
topic: someTopic));
});

test('topic permalink, message was moved', () async {
await smoke(TopicNarrow(someStream.streamId, someTopic, with_: 1),
(int i) => eg.streamMessage(
id: i == 0 ? 1 : null,
stream: otherStream,
topic: otherTopic));
});
});

test('short history', () async {
Expand Down Expand Up @@ -177,6 +197,40 @@ void main() {
check(model).messages.length.equals(1);
recent_senders_test.checkMatchesMessages(store.recentSenders, messages);
});

group('topic permalinks', () {
test('if redirect, we follow it and remove "with" element', () async {
await prepare(narrow: TopicNarrow(someStream.streamId, someTopic, with_: 1));
connection.prepare(json: newestResult(
foundOldest: false,
messages: [eg.streamMessage(id: 1, stream: otherStream, topic: otherTopic)],
).toJson());

checkNotNotified();
await model.fetchInitial();
checkNotifiedOnce();
check(model).narrow.isA<TopicNarrow>()
..streamId.equals(otherStream.streamId)
..topic.equals(otherTopic)
..with_.isNull();
});

test('if no redirect, we still remove "with" element', () async {
await prepare(narrow: TopicNarrow(someStream.streamId, someTopic, with_: 1));
connection.prepare(json: newestResult(
foundOldest: false,
messages: [eg.streamMessage(id: 1, stream: someStream, topic: someTopic)],
).toJson());

checkNotNotified();
await model.fetchInitial();
checkNotifiedOnce();
check(model).narrow.isA<TopicNarrow>()
..streamId.equals(someStream.streamId)
..topic.equals(someTopic)
..with_.isNull();
});
});
});

group('fetchOlder', () {
Expand Down
11 changes: 6 additions & 5 deletions test/model/narrow_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ extension NarrowChecks on Subject<Narrow> {
Subject<ApiNarrow> get apiEncode => has((x) => x.apiEncode(), 'apiEncode()');
}

extension DmNarrowChecks on Subject<DmNarrow> {
Subject<List<int>> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds');
Subject<List<int>> get otherRecipientIds => has((x) => x.otherRecipientIds, 'otherRecipientIds');
}

extension TopicNarrowChecks on Subject<TopicNarrow> {
Subject<int> get streamId => has((x) => x.streamId, 'streamId');
Subject<String> get topic => has((x) => x.topic, 'topic');
Subject<int?> get with_ => has((x) => x.with_, 'with_');
}

extension DmNarrowChecks on Subject<DmNarrow> {
Subject<List<int>> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds');
Subject<List<int>> get otherRecipientIds => has((x) => x.otherRecipientIds, 'otherRecipientIds');
}
Loading

0 comments on commit 0131961

Please sign in to comment.