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