|
1 | 1 | import 'package:flutter/foundation.dart';
|
| 2 | +import 'package:json_annotation/json_annotation.dart'; |
2 | 3 |
|
3 | 4 | import '../api/model/narrow.dart';
|
4 | 5 | import 'narrow.dart';
|
5 | 6 | import 'store.dart';
|
6 | 7 |
|
| 8 | +part 'internal_link.g.dart'; |
| 9 | + |
7 | 10 | const _hashReplacements = {
|
8 | 11 | "%": ".",
|
9 | 12 | "(": ".28",
|
@@ -106,3 +109,177 @@ Uri? tryResolveOnRealmUrl(String urlString, Uri realmUrl) {
|
106 | 109 | return null;
|
107 | 110 | }
|
108 | 111 | }
|
| 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 | +} |
0 commit comments