Skip to content

Commit 2b98b45

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.
1 parent 3ce3ead commit 2b98b45

File tree

3 files changed

+731
-1
lines changed

3 files changed

+731
-1
lines changed

lib/model/internal_link.dart

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

2-
import 'store.dart';
2+
import 'package:flutter/cupertino.dart';
3+
import 'package:json_annotation/json_annotation.dart';
34

45
import '../api/model/narrow.dart';
56
import 'narrow.dart';
7+
import 'store.dart';
8+
9+
part 'internal_link.g.dart';
610

711
const _hashReplacements = {
812
"%": ".",
@@ -20,6 +24,14 @@ String _encodeHashComponent(String str) {
2024
.replaceAllMapped(_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0]!]!);
2125
}
2226

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

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

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)