11
2- import 'store.dart' ;
2+ import 'package:flutter/cupertino.dart' ;
3+ import 'package:json_annotation/json_annotation.dart' ;
34
45import '../api/model/narrow.dart' ;
56import 'narrow.dart' ;
7+ import 'store.dart' ;
8+
9+ part 'internal_link.g.dart' ;
610
711const _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+ }
0 commit comments