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 14, 2025
1 parent 15e9705 commit 6e195fa
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 23 deletions.
43 changes: 37 additions & 6 deletions lib/api/model/narrow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,35 @@ 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():
assert(!supportsOperatorWith);
// drop element; it's unsupported
break;
default:
result.add(element);
}
}
return result;
}

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

/// 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,
);
}

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

Expand Down
17 changes: 13 additions & 4 deletions lib/model/internal_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
fragment.write('$streamId-$slugifiedName');
case ApiNarrowTopic():
fragment.write(_encodeHashComponent(element.operand.apiName));
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 @@ -159,6 +161,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {

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

Expand Down Expand Up @@ -193,16 +196,21 @@ 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;
}
}

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 +227,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_!,
];
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
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(eg.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(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
{'operator': 'stream', 'operand': 12},
{'operator': 'topic', 'operand': 'stuff'},
]));
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 @@ -289,8 +289,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
12 changes: 6 additions & 6 deletions test/model/internal_link_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,12 @@ 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')),
];
testExpectedNarrows(testCases, streams: streams);
Expand All @@ -313,7 +313,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 +326,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 +342,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
45 changes: 45 additions & 0 deletions test/model/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ void main() {
final someChannel = 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 @@ -134,6 +137,22 @@ void main() {
await smoke(TopicNarrow(someChannel.streamId, eg.t(someTopic)),
(i) => eg.streamMessage(stream: someChannel, topic: someTopic));
});

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

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

test('short history', () async {
Expand Down Expand Up @@ -181,6 +200,32 @@ 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(someChannel.streamId, eg.t(someTopic), with_: 1));
connection.prepare(json: newestResult(
foundOldest: false,
messages: [eg.streamMessage(id: 1, stream: otherStream, topic: otherTopic)],
).toJson());
await model.fetchInitial();
checkNotifiedOnce();
check(model).narrow
.equals(TopicNarrow(otherStream.streamId, eg.t(otherTopic)));
});

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

group('fetchOlder', () {
Expand Down
1 change: 1 addition & 0 deletions test/model/narrow_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ extension DmNarrowChecks on Subject<DmNarrow> {
extension TopicNarrowChecks on Subject<TopicNarrow> {
Subject<int> get streamId => has((x) => x.streamId, 'streamId');
Subject<TopicName> get topic => has((x) => x.topic, 'topic');
Subject<int?> get with_ => has((x) => x.with_, 'with_');
}
Loading

0 comments on commit 6e195fa

Please sign in to comment.