Skip to content

Commit 22d23d2

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/__tests__/internalLinks-test.js`) and replicated here, save for the special narrow types (`#narrow/is/starred`) which are not yet implemented. Also, the "without realm info" cases were removed as they were made moot with every test case being passed through `tryResolveOnRealmUrl` (the mobile cases were also passed through `new Url()` with a base).
1 parent 8eb7435 commit 22d23d2

File tree

3 files changed

+583
-0
lines changed

3 files changed

+583
-0
lines changed

lib/model/internal_link.dart

+177
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import 'package:flutter/foundation.dart';
2+
import 'package:json_annotation/json_annotation.dart';
23

34
import '../api/model/narrow.dart';
45
import 'narrow.dart';
56
import 'store.dart';
67

8+
part 'internal_link.g.dart';
9+
710
const _hashReplacements = {
811
"%": ".",
912
"(": ".28",
@@ -106,3 +109,177 @@ Uri? tryResolveOnRealmUrl(String urlString, Uri realmUrl) {
106109
return null;
107110
}
108111
}
112+
113+
/// A [Narrow] from a given URL, on `store`'s realm.
114+
///
115+
/// `url` must already be passed through [tryResolveOnRealmUrl].
116+
///
117+
/// Returns `null` if any of the operator/operand pairs are invalid.
118+
///
119+
/// Since narrow links can combine operators in ways our [Narrow] type can't
120+
/// represent, this can also return null for valid narrow links.
121+
///
122+
/// This can also return null for some valid narrow links that our Narrow
123+
/// type *could* accurately represent. We should try to understand these
124+
/// better, but some kinds will be rare, even unheard-of:
125+
/// #narrow/stream/1-announce/stream/1-announce (duplicated operator)
126+
// TODO(#252): handle all valid narrow links, returning a search narrow
127+
Narrow? parseInternalLink(Uri url, PerAccountStore store) {
128+
if (!_isInternalLink(url, store.account.realmUrl)) return null;
129+
130+
final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
131+
switch (category) {
132+
case 'narrow':
133+
if (segments.isEmpty || !segments.length.isEven) return null;
134+
return _interpretNarrowSegments(segments, store);
135+
}
136+
return null;
137+
}
138+
139+
/// Check if `url` is an internal link on the given `realmUrl`.
140+
bool _isInternalLink(Uri url, Uri realmUrl) {
141+
try {
142+
if (url.origin != realmUrl.origin) return false;
143+
} on StateError {
144+
return false;
145+
}
146+
return (url.hasEmptyPath || url.path == '/')
147+
&& !url.hasQuery
148+
&& url.hasFragment;
149+
}
150+
151+
/// Split `fragment` of arbitrary segments and handle trailing slashes
152+
(String, List<String>) _getCategoryAndSegmentsFromFragment(String fragment) {
153+
final [category, ...segments] = fragment.split('/');
154+
if (segments.length > 1 && segments.last == '') segments.removeLast();
155+
return (category, segments);
156+
}
157+
158+
Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
159+
assert(segments.isNotEmpty);
160+
assert(segments.length.isEven);
161+
162+
ApiNarrowStream? streamElement;
163+
ApiNarrowTopic? topicElement;
164+
ApiNarrowDm? dmElement;
165+
166+
for (var i = 0; i < segments.length; i += 2) {
167+
final (operator, negated) = _parseOperator(segments[i]);
168+
if (negated) return null;
169+
final operand = segments[i + 1];
170+
switch (operator) {
171+
case _NarrowOperator.stream:
172+
if (streamElement != null) return null;
173+
final streamId = _parseStreamOperand(operand, store);
174+
if (streamId == null) return null;
175+
streamElement = ApiNarrowStream(streamId, negated: negated);
176+
177+
case _NarrowOperator.topic:
178+
case _NarrowOperator.subject:
179+
if (topicElement != null) return null;
180+
final String? topic = decodeHashComponent(operand);
181+
if (topic == null) return null;
182+
topicElement = ApiNarrowTopic(topic, negated: negated);
183+
184+
case _NarrowOperator.dm:
185+
case _NarrowOperator.pmWith:
186+
if (dmElement != null) return null;
187+
final dmIds = _parseDmOperand(operand);
188+
if (dmIds == null) return null;
189+
dmElement = ApiNarrowDm(dmIds, negated: negated);
190+
191+
case _NarrowOperator.near:
192+
continue; // TODO(#82): support for near
193+
194+
case _NarrowOperator.unknown:
195+
return null;
196+
}
197+
}
198+
199+
if (dmElement != null) {
200+
if (streamElement != null || topicElement != null) return null;
201+
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.account.userId);
202+
} else if (streamElement != null) {
203+
final streamId = streamElement.operand;
204+
if (topicElement != null) {
205+
return TopicNarrow(streamId, topicElement.operand);
206+
} else {
207+
return StreamNarrow(streamId);
208+
}
209+
}
210+
return null;
211+
}
212+
213+
@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
214+
enum _NarrowOperator {
215+
// 'dm' is new in server-7.0; means the same as 'pm-with'
216+
dm,
217+
near,
218+
pmWith,
219+
stream,
220+
subject,
221+
topic,
222+
unknown;
223+
224+
static _NarrowOperator fromRawString(String raw) => _byRawString[raw] ?? unknown;
225+
226+
static final _byRawString = _$_NarrowOperatorEnumMap.map((key, value) => MapEntry(value, key));
227+
}
228+
229+
(_NarrowOperator, bool) _parseOperator(String input) {
230+
final String operator;
231+
final bool negated;
232+
if (input.startsWith('-')) {
233+
operator = input.substring(1);
234+
negated = true;
235+
} else {
236+
operator = input;
237+
negated = false;
238+
}
239+
return (_NarrowOperator.fromRawString(operator), negated);
240+
}
241+
242+
/// Parse the operand of a `stream` operator, returning a stream ID.
243+
///
244+
/// The ID might point to a stream that's hidden from our user (perhaps
245+
/// doesn't exist). If so, most likely the user doesn't have permission to
246+
/// see the stream's existence -- like with a guest user for any stream
247+
/// they're not in, or any non-admin with a private stream they're not in.
248+
/// Could be that whoever wrote the link just made something up.
249+
///
250+
/// Returns null if the operand has an unexpected shape, or has the old shape
251+
/// (stream name but no ID) and we don't know of a stream by the given name.
252+
int? _parseStreamOperand(String operand, PerAccountStore store) {
253+
// "New" (2018) format: ${stream_id}-${stream_name} .
254+
final match = RegExp(r'^(\d+)(?:-.*)?$').firstMatch(operand);
255+
final newFormatStreamId = (match != null) ? int.parse(match.group(1)!, radix: 10) : null;
256+
if (newFormatStreamId != null && store.streams.containsKey(newFormatStreamId)) {
257+
return newFormatStreamId;
258+
}
259+
260+
// Old format: just stream name. This case is relevant indefinitely,
261+
// so that links in old conversations continue to work.
262+
final String? streamName = decodeHashComponent(operand);
263+
if (streamName == null) return null;
264+
final stream = store.streamsByName[streamName];
265+
if (stream != null) return stream.streamId;
266+
267+
if (newFormatStreamId != null) {
268+
// Neither format found a stream, so it's hidden or doesn't exist. But
269+
// at least we have a stream ID; give that to the caller.
270+
return newFormatStreamId;
271+
}
272+
273+
// Unexpected shape, or the old shape and we don't know of a stream with
274+
// the given name.
275+
return null;
276+
}
277+
278+
List<int>? _parseDmOperand(String operand) {
279+
final rawIds = operand.split('-')[0].split(',');
280+
try {
281+
return rawIds.map((rawId) => int.parse(rawId, radix: 10)).toList();
282+
} on FormatException {
283+
return null;
284+
}
285+
}

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)