Skip to content

Commit b17769f

Browse files
committed
msglist: Follow /with/ links through message moves
Fixes: zulip#1028
1 parent 176304a commit b17769f

File tree

12 files changed

+306
-25
lines changed

12 files changed

+306
-25
lines changed

lib/api/model/narrow.dart

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,33 @@ typedef ApiNarrow = List<ApiNarrowElement>;
1414
/// reasonably be omitted will be omitted.
1515
ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) {
1616
final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7)
17+
final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9)
1718

1819
bool hasDmElement = false;
20+
bool hasWithElement = false;
1921
for (final element in narrow) {
2022
switch (element) {
21-
case ApiNarrowDm(): hasDmElement = true;
23+
case ApiNarrowDm(): hasDmElement = true;
24+
case ApiNarrowWith(): hasWithElement = true;
2225
default:
2326
}
2427
}
25-
if (!hasDmElement) return narrow;
28+
if (!hasDmElement && (!hasWithElement || supportsOperatorWith)) {
29+
return narrow;
30+
}
2631

27-
return narrow.map((element) => switch (element) {
28-
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm),
29-
_ => element,
30-
}).toList();
32+
final result = <ApiNarrowElement>[];
33+
for (final element in narrow) {
34+
switch (element) {
35+
case ApiNarrowDm():
36+
result.add(element.resolve(legacy: !supportsOperatorDm));
37+
case ApiNarrowWith() when !supportsOperatorWith:
38+
break; // drop unsupported element
39+
default:
40+
result.add(element);
41+
}
42+
}
43+
return result;
3144
}
3245

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

176+
/// An [ApiNarrowElement] with the 'with' operator.
177+
///
178+
/// If part of [ApiNarrow] use [resolveApiNarrowElements].
179+
class ApiNarrowWith extends ApiNarrowElement {
180+
@override String get operator => 'with';
181+
182+
@override final int operand;
183+
184+
ApiNarrowWith(this.operand, {super.negated});
185+
186+
factory ApiNarrowWith.fromJson(Map<String, dynamic> json) => ApiNarrowWith(
187+
json['operand'] as int,
188+
negated: json['negated'] as bool? ?? false,
189+
);
190+
}
191+
163192
class ApiNarrowIs extends ApiNarrowElement {
164193
@override String get operator => 'is';
165194

lib/model/internal_link.dart

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
7878
fragment.write('$streamId-$slugifiedName');
7979
case ApiNarrowTopic():
8080
fragment.write(_encodeHashComponent(element.operand.apiName));
81+
case ApiNarrowWith():
82+
fragment.write(element.operand.toString());
8183
case ApiNarrowDmModern():
8284
final suffix = element.operand.length >= 3 ? 'group' : 'dm';
8385
fragment.write('${element.operand.join(',')}-$suffix');
@@ -160,6 +162,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
160162
ApiNarrowStream? streamElement;
161163
ApiNarrowTopic? topicElement;
162164
ApiNarrowDm? dmElement;
165+
ApiNarrowWith? withElement;
163166
Set<IsOperand> isElementOperands = {};
164167

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

194+
case _NarrowOperator.with_:
195+
if (withElement != null) return null;
196+
final messageId = int.tryParse(operand, radix: 10);
197+
if (messageId == null) return null;
198+
withElement = ApiNarrowWith(messageId);
199+
191200
case _NarrowOperator.is_:
192201
// It is fine to have duplicates of the same [IsOperand].
193202
isElementOperands.add(IsOperand.fromRawString(operand));
194203

195204
case _NarrowOperator.near: // TODO(#82): support for near
196-
case _NarrowOperator.with_: // TODO(#683): support for with
197205
continue;
198206

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

204212
if (isElementOperands.isNotEmpty) {
205-
if (streamElement != null || topicElement != null || dmElement != null) return null;
213+
if (streamElement != null || topicElement != null || dmElement != null || withElement != null) {
214+
return null;
215+
}
206216
if (isElementOperands.length > 1) return null;
207217
switch (isElementOperands.single) {
208218
case IsOperand.mentioned:
@@ -219,13 +229,14 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
219229
return null;
220230
}
221231
} else if (dmElement != null) {
222-
if (streamElement != null || topicElement != null) return null;
232+
if (streamElement != null || topicElement != null || withElement != null) return null;
223233
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
224234
} else if (streamElement != null) {
225235
final streamId = streamElement.operand;
226236
if (topicElement != null) {
227-
return TopicNarrow(streamId, topicElement.operand);
237+
return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
228238
} else {
239+
if (withElement != null) return null;
229240
return ChannelNarrow(streamId);
230241
}
231242
}

lib/model/message_list.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
511511
numAfter: 0,
512512
);
513513
if (this.generation > generation) return;
514+
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
514515
store.reconcileMessages(result.messages);
515516
store.recentSenders.handleMessages(result.messages); // TODO(#824)
516517
for (final message in result.messages) {
@@ -524,12 +525,47 @@ class MessageListView with ChangeNotifier, _MessageSequence {
524525
notifyListeners();
525526
}
526527

528+
/// Update [narrow] for the result of a "with" narrow (topic permalink) fetch.
529+
///
530+
/// To avoid an extra round trip, the server handles [ApiNarrowWith]
531+
/// by returning results from the indicated message's current stream/topic
532+
/// (if the user has access),
533+
/// even if that differs from the narrow's stream/topic filters
534+
/// because the message was moved.
535+
///
536+
/// If such a "redirect" happened, this helper updates the stream and topic
537+
/// in [narrow] to match the message's current conversation.
538+
/// It also removes the "with" component from [narrow]
539+
/// whether or not a redirect happened.
540+
///
541+
/// See API doc:
542+
/// https://zulip.com/api/construct-narrow#message-ids
543+
void _adjustNarrowForTopicPermalink(Message? someFetchedMessageOrNull) {
544+
final narrow = this.narrow;
545+
if (narrow is! TopicNarrow || narrow.with_ == null) return;
546+
547+
switch (someFetchedMessageOrNull) {
548+
case null:
549+
// This can't be a redirect; a redirect can't produce an empty result.
550+
// (The server only redirects if the message is accessible to the user,
551+
// and if it is, it'll appear in the result, making it non-empty.)
552+
this.narrow = narrow.sansWith();
553+
case StreamMessage():
554+
this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull);
555+
case DmMessage(): // TODO(log)
556+
assert(false);
557+
}
558+
}
559+
527560
/// Fetch the next batch of older messages, if applicable.
528561
Future<void> fetchOlder() async {
529562
if (haveOldest) return;
530563
if (fetchingOlder) return;
531564
if (fetchOlderCoolingDown) return;
532565
assert(fetched);
566+
assert(narrow is! TopicNarrow
567+
// We only intend to send "with" in [fetchInitial]; see there.
568+
|| (narrow as TopicNarrow).with_ == null);
533569
assert(messages.isNotEmpty);
534570
_fetchingOlder = true;
535571
_updateEndMarkers();

lib/model/narrow.dart

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,17 @@ class ChannelNarrow extends Narrow {
9292
}
9393

9494
class TopicNarrow extends Narrow implements SendableNarrow {
95-
const TopicNarrow(this.streamId, this.topic);
95+
const TopicNarrow(this.streamId, this.topic, {this.with_});
9696

9797
factory TopicNarrow.ofMessage(StreamMessage message) {
9898
return TopicNarrow(message.streamId, message.topic);
9999
}
100100

101101
final int streamId;
102102
final TopicName topic;
103+
final int? with_;
104+
105+
TopicNarrow sansWith() => TopicNarrow(streamId, topic);
103106

104107
@override
105108
bool containsMessage(Message message) {
@@ -108,22 +111,33 @@ class TopicNarrow extends Narrow implements SendableNarrow {
108111
}
109112

110113
@override
111-
ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)];
114+
ApiNarrow apiEncode() => [
115+
ApiNarrowStream(streamId),
116+
ApiNarrowTopic(topic),
117+
if (with_ != null) ApiNarrowWith(with_!),
118+
];
112119

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

116123
@override
117-
String toString() => 'TopicNarrow($streamId, ${topic.displayName})';
124+
String toString() {
125+
final fields = [
126+
streamId.toString(),
127+
topic.displayName,
128+
if (with_ != null) 'with: ${with_!}',
129+
];
130+
return 'TopicNarrow(${fields.join(', ')})';
131+
}
118132

119133
@override
120134
bool operator ==(Object other) {
121135
if (other is! TopicNarrow) return false;
122-
return other.streamId == streamId && other.topic == topic;
136+
return other.streamId == streamId && other.topic == topic && other.with_ == with_;
123137
}
124138

125139
@override
126-
int get hashCode => Object.hash('TopicNarrow', streamId, topic);
140+
int get hashCode => Object.hash('TopicNarrow', streamId, topic, with_);
127141
}
128142

129143
/// The narrow for a direct-message conversation.

lib/widgets/message_list.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,8 +503,11 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
503503

504504
void _modelChanged() {
505505
if (model!.narrow != widget.narrow) {
506-
// A message move event occurred, where propagate mode is
507-
// [PropagateMode.changeAll] or [PropagateMode.changeLater].
506+
// Either:
507+
// - A message move event occurred, where propagate mode is
508+
// [PropagateMode.changeAll] or [PropagateMode.changeLater]. Or:
509+
// - We fetched a "with" / topic-permalink narrow, and the response
510+
// redirected us to the new location of the operand message ID.
508511
widget.onNarrowChanged(model!.narrow);
509512
}
510513
setState(() {

test/api/model/narrow_test.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/initial_snapshot.dart';
5+
import 'package:zulip/api/model/model.dart';
6+
import 'package:zulip/api/model/narrow.dart';
7+
8+
import '../../example_data.dart' as eg;
9+
import '../../stdlib_checks.dart';
10+
import 'events_checks.dart';
11+
import 'model_checks.dart';
12+
13+
void main() {
14+
group('resolveApiNarrowForServer', () {
15+
void doTest(
16+
String description,
17+
ApiNarrow input,
18+
ApiNarrow expected, {
19+
int zulipFeatureLevel = eg.recentZulipFeatureLevel
20+
}) {
21+
test('$description, FL $zulipFeatureLevel', () {
22+
final actual = resolveApiNarrowForServer(input, zulipFeatureLevel);
23+
check(actual).jsonEquals(expected);
24+
});
25+
}
26+
27+
final stream = ApiNarrowStream(eg.stream().streamId);
28+
final topic = ApiNarrowTopic(eg.t('topic'));
29+
final dm = ApiNarrowDm([eg.user().userId]);
30+
final with_ = ApiNarrowWith(eg.streamMessage().id);
31+
32+
group('recent FL', () {
33+
doTest('dm (modern)', [dm], [dm.resolve(legacy: false)]);
34+
doTest('topic, not permalink', [stream, topic], [stream, topic]);
35+
doTest('topic permalink (modern)', [stream, topic, with_], [stream, topic, with_]);
36+
37+
// Unlikely to occur in the wild but should still be handled correctly
38+
doTest('dm + with', [dm, with_], [dm.resolve(legacy: false), with_]);
39+
});
40+
41+
// TODO(server-7)
42+
group('FL 176', () {
43+
final zulipFeatureLevel = 176;
44+
doTest('dm (legacy)', zulipFeatureLevel: zulipFeatureLevel,
45+
[dm], [dm.resolve(legacy: true)]);
46+
doTest('topic, not permalink', [stream, topic], [stream, topic]);
47+
doTest('topic permalink (legacy)', zulipFeatureLevel: zulipFeatureLevel,
48+
[stream, topic, with_], [stream, topic]);
49+
50+
// Unlikely to occur in the wild but should still be handled correctly
51+
doTest('dm + with', zulipFeatureLevel: zulipFeatureLevel,
52+
[dm, with_], [dm.resolve(legacy: true)]);
53+
});
54+
55+
// TODO(server-9)
56+
group('FL 270', () {
57+
final zulipFeatureLevel = 270;
58+
doTest('topic permalink (legacy)', zulipFeatureLevel: zulipFeatureLevel,
59+
[stream, topic, with_], [stream, topic]);
60+
doTest('topic, not permalink', [stream, topic], [stream, topic]);
61+
doTest('dm (modern)', zulipFeatureLevel: zulipFeatureLevel,
62+
[dm], [dm.resolve(legacy: false)]);
63+
64+
// Unlikely to occur in the wild but should still be handled correctly
65+
doTest('dm + with', zulipFeatureLevel: zulipFeatureLevel,
66+
[dm, with_], [dm.resolve(legacy: false)]);
67+
});
68+
});
69+
}

test/api/route/messages_test.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ void main() {
188188
{'operator': 'stream', 'operand': 12},
189189
{'operator': 'topic', 'operand': 'stuff'},
190190
]));
191+
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
192+
{'operator': 'stream', 'operand': 12},
193+
{'operator': 'topic', 'operand': 'stuff'},
194+
{'operator': 'with', 'operand': 1},
195+
]));
191196
checkNarrow(const MentionsNarrow().apiEncode(), jsonEncode([
192197
{'operator': 'is', 'operand': 'mentioned'},
193198
]));
@@ -203,6 +208,11 @@ void main() {
203208
checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([
204209
{'operator': 'pm-with', 'operand': [123, 234]},
205210
]));
211+
connection.zulipFeatureLevel = 270;
212+
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
213+
{'operator': 'stream', 'operand': 12},
214+
{'operator': 'topic', 'operand': 'stuff'},
215+
]));
206216
connection.zulipFeatureLevel = eg.futureZulipFeatureLevel;
207217
});
208218
});

test/example_data.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,8 @@ Subscription subscription(
289289
/// Useful in test code that mentions a lot of topics in a compact format.
290290
TopicName t(String apiName) => TopicName(apiName);
291291

292-
TopicNarrow topicNarrow(int channelId, String topicName) {
293-
return TopicNarrow(channelId, TopicName(topicName));
292+
TopicNarrow topicNarrow(int channelId, String topicName, {int? with_}) {
293+
return TopicNarrow(channelId, TopicName(topicName), with_: with_);
294294
}
295295

296296
UserTopicItem userTopicItem(

0 commit comments

Comments
 (0)