1
1
2
- import 'store.dart' ;
2
+ import 'package:flutter/cupertino.dart' ;
3
+ import 'package:json_annotation/json_annotation.dart' ;
3
4
4
5
import '../api/model/narrow.dart' ;
5
6
import 'narrow.dart' ;
7
+ import 'store.dart' ;
8
+
9
+ part 'internal_link.g.dart' ;
6
10
7
11
const _hashReplacements = {
8
12
"%" : "." ,
@@ -20,6 +24,14 @@ String _encodeHashComponent(String str) {
20
24
.replaceAllMapped (_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0 ]! ]! );
21
25
}
22
26
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
+
23
35
/// A URL to the given [Narrow] , on `store` 's realm.
24
36
///
25
37
/// To include /near/{messageId} in the link, pass a non-null [nearMessageId] .
@@ -79,3 +91,192 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
79
91
80
92
return store.account.realmUrl.replace (fragment: fragment.toString ());
81
93
}
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