Skip to content

Commit b18cecb

Browse files
committed
internal_link: parse internal links into narrows
The core process on parsing internal links (here in `lib/model/internal_link.dart`) relied heavily on the existing code in the Zulip mobile app - from `src/utils/internalLinks.js`. In fact the `_parseStreamOperand` function here is a line for line port in order to capture the same semantics when processing streams. Where the implementation differs is this new process is less restrictive on the order of operator/operand pairs: supporting `#narrow/topic/_/stream_` where mobile only accepted `#narrow/stream/_/topic/_`. Also, the mobile implementation accepted as valid narrows DM operators with an email address as the operand (`#narrow/dm/a.40b.2Ecom.2Ec.2Ed.2Ecom`) but created an invalid narrow object (with NaNs for targets) whereas this implementation rejects them as invalid narrows. Likewise the test cases are also taken from the mobile code (`src/utils/__test__/internalLinks-test.js`) and replicated here, save for the special narrow types (`#narrow/is/starred`) which are not yet implemented.
1 parent ca26716 commit b18cecb

File tree

3 files changed

+443
-1
lines changed

3 files changed

+443
-1
lines changed

lib/model/internal_link.dart

+173-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11

2-
import 'store.dart';
2+
import 'package:json_annotation/json_annotation.dart';
33

44
import '../api/model/narrow.dart';
55
import 'narrow.dart';
6+
import 'store.dart';
7+
8+
part 'internal_link.g.dart';
69

710
const _hashReplacements = {
811
"%": ".",
@@ -20,6 +23,13 @@ String _encodeHashComponent(String str) {
2023
.replaceAllMapped(_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0]!]!);
2124
}
2225

26+
/// Decode a dot-encoded string.
27+
// The Zulip webapp uses this encoding in narrow-links:
28+
// https://github.com/zulip/zulip/blob/1577662a6/static/js/hash_util.js#L18-L25
29+
String _decodeHashComponent(String str) {
30+
return Uri.decodeComponent(str.replaceAll('.', '%'));
31+
}
32+
2333
/// A URL to the given [Narrow], on `store`'s realm.
2434
///
2535
/// To include /near/{messageId} in the link, pass a non-null [nearMessageId].
@@ -79,3 +89,165 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
7989

8090
return store.account.realmUrl.replace(fragment: fragment.toString());
8191
}
92+
93+
/// A [Narrow] from a given URL, on `store`'s realm.
94+
///
95+
/// Returns `null` if any of the operator/operand pairs are invalid.
96+
///
97+
/// Since narrow links can combine operators in ways our Narrow type can't
98+
/// represent, this can also return null for valid narrow links.
99+
///
100+
/// This can also return null for some valid narrow links that our Narrow
101+
/// type *could* accurately represent. We should try to understand these
102+
/// better, but some kinds will be rare, even unheard-of:
103+
/// #narrow/stream/1-announce/stream/1-announce (duplicated operator)
104+
///
105+
/// The passed `url` must appear to be a link to a Zulip narrow on the given
106+
/// `realm`.
107+
Narrow? parseInternalLink(Uri url, PerAccountStore store) {
108+
if (!url.hasFragment) return null;
109+
if (!url.hasScheme || url.host.isEmpty) return null;
110+
if (!url.hasEmptyPath && (url.path != '/')) return null;
111+
112+
if (url.origin != store.account.realmUrl.origin) return null;
113+
114+
final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
115+
switch (category) {
116+
case 'narrow':
117+
if (segments.isEmpty || !segments.length.isEven) return null;
118+
return _interpretNarrowSegments(segments, store);
119+
}
120+
return null;
121+
}
122+
123+
// Helper to split fragment that handles trailing slashes
124+
(String, List<String>) _getCategoryAndSegmentsFromFragment(String fragment) {
125+
final [category, ...segments] = fragment.split('/');
126+
if (segments.length > 1 && segments.last == '') segments.removeLast();
127+
return (category, segments);
128+
}
129+
130+
Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
131+
assert(segments.isNotEmpty);
132+
assert(segments.length.isEven);
133+
134+
ApiNarrowStream? streamElement;
135+
ApiNarrowTopic? topicElement;
136+
ApiNarrowDm? dmElement;
137+
138+
for (var i=0; i<segments.length; i+=2) {
139+
final (operator, negated) = _parseOperator(segments[i]);
140+
final operand = segments[i+1];
141+
switch (operator) {
142+
case NarrowOperator.unknown:
143+
return null;
144+
case NarrowOperator.stream:
145+
if (streamElement != null) return null;
146+
final streamId = _parseStreamOperand(operand, store);
147+
if (streamId == null) return null;
148+
streamElement = ApiNarrowStream(streamId, negated: negated);
149+
case NarrowOperator.subject:
150+
case NarrowOperator.topic:
151+
if (topicElement != null) return null;
152+
final topic = _decodeHashComponent(operand);
153+
topicElement = ApiNarrowTopic(topic, negated: negated);
154+
case NarrowOperator.near:
155+
// TODO(#82): support for near
156+
continue;
157+
case NarrowOperator.dm:
158+
case NarrowOperator.pmWith:
159+
if (dmElement != null) return null;
160+
final dmIds = _parsePmOperand(operand);
161+
if (dmIds == null) return null;
162+
dmElement = ApiNarrowDm(dmIds, negated: negated);
163+
}
164+
}
165+
166+
if (dmElement != null) {
167+
if (streamElement != null || topicElement != null) return null;
168+
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.account.userId);
169+
} else if (streamElement != null) {
170+
final streamId = streamElement.operand;
171+
if (topicElement != null) {
172+
if (streamElement.negated || topicElement.negated) return null; // TODO(#252): return SearchNarrow
173+
return TopicNarrow(streamId, topicElement.operand);
174+
} else {
175+
if (streamElement.negated) return null; // TODO(#252): return SearchNarrow
176+
return StreamNarrow(streamId);
177+
}
178+
}
179+
return null;
180+
}
181+
182+
@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
183+
enum NarrowOperator {
184+
// 'dm' is new in server-7.0; means the same as 'pm-with'
185+
dm,
186+
near,
187+
pmWith,
188+
stream,
189+
subject,
190+
topic,
191+
unknown;
192+
193+
static NarrowOperator fromRawString(String raw) => _byRawString[raw] ?? unknown;
194+
195+
static final _byRawString = _$NarrowOperatorEnumMap.map((key, value) => MapEntry(value, key));
196+
}
197+
198+
(NarrowOperator, bool) _parseOperator(String input) {
199+
final String operator;
200+
final bool negated;
201+
if (input.startsWith('-')) {
202+
operator = input.substring(1);
203+
negated = true;
204+
} else {
205+
operator = input;
206+
negated = false;
207+
}
208+
return (NarrowOperator.fromRawString(operator), negated);
209+
}
210+
211+
/// Parse the operand of a `stream` operator, returning a stream ID.
212+
///
213+
/// The ID might point to a stream that's hidden from our user (perhaps
214+
/// doesn't exist). If so, most likely the user doesn't have permission to
215+
/// see the stream's existence -- like with a guest user for any stream
216+
/// they're not in, or any non-admin with a private stream they're not in.
217+
/// Could be that whoever wrote the link just made something up.
218+
///
219+
/// Returns null if the operand has an unexpected shape, or has the old shape
220+
/// (stream name but no ID) and we don't know of a stream by the given name.
221+
int? _parseStreamOperand(String operand, PerAccountStore store) {
222+
// "New" (2018) format: ${stream_id}-${stream_name} .
223+
final match = RegExp(r'^(\d+)(?:-.*)?$').firstMatch(operand);
224+
final newFormatStreamId = (match != null) ? int.parse(match.group(1)!) : null;
225+
if (newFormatStreamId != null && store.streams.containsKey(newFormatStreamId)) {
226+
return newFormatStreamId;
227+
}
228+
229+
// Old format: just stream name. This case is relevant indefinitely,
230+
// so that links in old conversations continue to work.
231+
final streamName = _decodeHashComponent(operand);
232+
final stream = store.streamsByName[streamName];
233+
if (stream != null) return stream.streamId;
234+
235+
if (newFormatStreamId != null) {
236+
// Neither format found a Stream, so it's hidden or doesn't exist. But
237+
// at least we have a stream ID; give that to the caller.
238+
return newFormatStreamId;
239+
}
240+
241+
// Unexpected shape, or the old shape and we don't know of a stream with
242+
// the given name.
243+
return null;
244+
}
245+
246+
List<int>? _parsePmOperand(String operand) {
247+
final rawIds = operand.split('-')[0].split(',');
248+
try {
249+
return rawIds.map(int.parse).toList();
250+
} on FormatException {
251+
return null;
252+
}
253+
}

lib/model/internal_link.g.dart

+19
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)