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 Feb 21, 2025
1 parent 5f3a682 commit c8494a8
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 25 deletions.
41 changes: 35 additions & 6 deletions lib/api/model/narrow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,33 @@ typedef ApiNarrow = List<ApiNarrowElement>;
/// reasonably be omitted will be omitted.
ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) {
final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7)
final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9)

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 && !supportsOperatorWith))) {
return narrow;
}

return narrow.map((element) => switch (element) {
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm),
_ => element,
}).toList();
final result = <ApiNarrowElement>[];
for (final element in narrow) {
switch (element) {
case ApiNarrowDm():
result.add(element.resolve(legacy: !supportsOperatorDm));
case ApiNarrowWith() when !supportsOperatorWith:
break; // drop unsupported element
default:
result.add(element);
}
}
return result;
}

/// An element in the list representing a narrow in the Zulip API.
Expand Down Expand Up @@ -160,6 +173,22 @@ class ApiNarrowPmWith extends ApiNarrowDm {
ApiNarrowPmWith._(super.operand, {super.negated});
}

/// An [ApiNarrowElement] with the 'with' operator.
///
/// If part of [ApiNarrow] use [resolveApiNarrowForServer].
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,
);
}

class ApiNarrowIs extends ApiNarrowElement {
@override String get operator => 'is';

Expand Down
19 changes: 15 additions & 4 deletions lib/model/internal_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
fragment.write('${element.operand.join(',')}-$suffix');
case ApiNarrowDm():
assert(false, 'ApiNarrowDm should have been resolved');
case ApiNarrowWith():
fragment.write(element.operand.toString());
case ApiNarrowIs():
fragment.write(element.operand.toString());
case ApiNarrowMessageId():
Expand Down Expand Up @@ -160,6 +162,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
ApiNarrowStream? streamElement;
ApiNarrowTopic? topicElement;
ApiNarrowDm? dmElement;
ApiNarrowWith? withElement;
Set<IsOperand> isElementOperands = {};

for (var i = 0; i < segments.length; i += 2) {
Expand Down Expand Up @@ -188,12 +191,17 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
if (dmIds == null) return null;
dmElement = ApiNarrowDm(dmIds, negated: negated);

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

case _NarrowOperator.is_:
// It is fine to have duplicates of the same [IsOperand].
isElementOperands.add(IsOperand.fromRawString(operand));

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

case _NarrowOperator.unknown:
Expand All @@ -202,7 +210,9 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
}

if (isElementOperands.isNotEmpty) {
if (streamElement != null || topicElement != null || dmElement != null) return null;
if (streamElement != null || topicElement != null || dmElement != null || withElement != null) {
return null;
}
if (isElementOperands.length > 1) return null;
switch (isElementOperands.single) {
case IsOperand.mentioned:
Expand All @@ -219,13 +229,14 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
return null;
}
} else if (dmElement != null) {
if (streamElement != null || topicElement != null) return null;
if (streamElement != null || topicElement != null || withElement != null) return null;
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
} 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 {
if (withElement != null) return null;
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 @@ -511,6 +511,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
numAfter: 0,
);
if (this.generation > generation) return;
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
store.reconcileMessages(result.messages);
store.recentSenders.handleMessages(result.messages); // TODO(#824)
for (final message in result.messages) {
Expand All @@ -524,12 +525,47 @@ 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 _adjustNarrowForTopicPermalink(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():
this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull);
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;
if (fetchOlderCoolingDown) 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
24 changes: 19 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 TopicName topic;
final int? with_;

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

@override
bool containsMessage(Message message) {
Expand All @@ -108,22 +111,33 @@ 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.displayName})';
String toString() {
final fields = [
streamId.toString(),
topic.displayName,
if (with_ != null) 'with: ${with_!}',
];
return 'TopicNarrow(${fields.join(', ')})';
}

@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
7 changes: 5 additions & 2 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,11 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

void _modelChanged() {
if (model!.narrow != widget.narrow) {
// A message move event occurred, where propagate mode is
// [PropagateMode.changeAll] or [PropagateMode.changeLater].
// Either:
// - A message move event occurred, where propagate mode is
// [PropagateMode.changeAll] or [PropagateMode.changeLater]. Or:
// - We fetched a "with" / topic-permalink narrow, and the response
// redirected us to the new location of the operand message ID.
widget.onNarrowChanged(model!.narrow);
}
setState(() {
Expand Down
4 changes: 4 additions & 0 deletions test/api/model/narrow_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
void main() {
// resolveApiNarrowForServer is covered in test/api/route/messages_test.dart,
// in the ApiNarrow.toJson test.
}
28 changes: 28 additions & 0 deletions test/api/route/messages_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,43 @@ void main() {
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
{'operator': 'with', 'operand': 1},
]));
checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([
{'operator': 'dm', 'operand': [123, 234]},
]));
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([
{'operator': 'dm', 'operand': [123, 234]},
{'operator': 'with', 'operand': 1},
]));

connection.zulipFeatureLevel = 270;
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([
{'operator': 'dm', 'operand': [123, 234]},
]));
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([
{'operator': 'dm', 'operand': [123, 234]},
]));

connection.zulipFeatureLevel = 176;
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([
{'operator': 'pm-with', 'operand': [123, 234]},
]));
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([
{'operator': 'pm-with', 'operand': [123, 234]},
]));

connection.zulipFeatureLevel = eg.futureZulipFeatureLevel;
});
});
Expand Down
4 changes: 2 additions & 2 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,8 @@ Subscription subscription(
/// Useful in test code that mentions a lot of topics in a compact format.
TopicName t(String apiName) => TopicName(apiName);

TopicNarrow topicNarrow(int channelId, String topicName) {
return TopicNarrow(channelId, TopicName(topicName));
TopicNarrow topicNarrow(int channelId, String topicName, {int? with_}) {
return TopicNarrow(channelId, TopicName(topicName), with_: with_);
}

UserTopicItem userTopicItem(
Expand Down
13 changes: 7 additions & 6 deletions test/model/internal_link_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,14 @@ void main() {
final testCases = [
('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')),
('/#narrow/stream/mobile/subject/topic/near/378333', eg.topicNarrow(3, 'topic')),
('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic')),
('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic', with_: 1)),
('/#narrow/stream/mobile/topic/topic/', eg.topicNarrow(3, 'topic')),
('/#narrow/stream/stream/topic/topic/near/1', eg.topicNarrow(5, 'topic')),
('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic')),
('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic', with_: 22)),
('/#narrow/stream/stream/subject/topic/near/1', eg.topicNarrow(5, 'topic')),
('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic')),
('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic', with_: 333)),
('/#narrow/stream/stream/subject/topic', eg.topicNarrow(5, 'topic')),
('/#narrow/stream/stream/subject/topic/with/asdf', null), // invalid `with`
];
testExpectedNarrows(testCases, streams: streams);
});
Expand All @@ -313,7 +314,7 @@ void main() {
final testCases = [
('/#narrow/dm/1,2-group', expectedNarrow),
('/#narrow/dm/1,2-group/near/1', expectedNarrow),
('/#narrow/dm/1,2-group/with/2', expectedNarrow),
('/#narrow/dm/1,2-group/with/2', null),
('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null),
('/#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/4', null),
];
Expand All @@ -326,7 +327,7 @@ void main() {
final testCases = [
('/#narrow/pm-with/1,2-group', expectedNarrow),
('/#narrow/pm-with/1,2-group/near/1', expectedNarrow),
('/#narrow/pm-with/1,2-group/with/2', expectedNarrow),
('/#narrow/pm-with/1,2-group/with/2', null),
('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/near/3', null),
('/#narrow/pm-with/a.40b.2Ecom.2Ec.2Ed.2Ecom/with/3', null),
];
Expand All @@ -342,7 +343,7 @@ void main() {
('/#narrow/is/$operand', narrow),
('/#narrow/is/$operand/is/$operand', narrow),
('/#narrow/is/$operand/near/1', narrow),
('/#narrow/is/$operand/with/2', narrow),
('/#narrow/is/$operand/with/2', null),
('/#narrow/channel/7-test-here/is/$operand', null),
('/#narrow/channel/check/topic/test/is/$operand', null),
('/#narrow/topic/test/is/$operand', null),
Expand Down
Loading

0 comments on commit c8494a8

Please sign in to comment.