Skip to content

Commit

Permalink
internal_link: parse internal links into narrows
Browse files Browse the repository at this point in the history
  • Loading branch information
sirpengi committed Sep 15, 2023
1 parent ca26716 commit dcc28e0
Show file tree
Hide file tree
Showing 3 changed files with 443 additions and 1 deletion.
174 changes: 173 additions & 1 deletion lib/model/internal_link.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@

import 'store.dart';
import 'package:json_annotation/json_annotation.dart';

import '../api/model/narrow.dart';
import 'narrow.dart';
import 'store.dart';

part 'internal_link.g.dart';

const _hashReplacements = {
"%": ".",
Expand All @@ -20,6 +23,13 @@ String _encodeHashComponent(String str) {
.replaceAllMapped(_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0]!]!);
}

/// Decode a dot-encoded string.
// The Zulip webapp uses this encoding in narrow-links:
// https://github.com/zulip/zulip/blob/1577662a6/static/js/hash_util.js#L18-L25
String _decodeHashComponent(String str) {
return Uri.decodeComponent(str.replaceAll('.', '%'));
}

/// A URL to the given [Narrow], on `store`'s realm.
///
/// To include /near/{messageId} in the link, pass a non-null [nearMessageId].
Expand Down Expand Up @@ -79,3 +89,165 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {

return store.account.realmUrl.replace(fragment: fragment.toString());
}

/// A [Narrow] from a given URL, on `store`'s realm.
///
/// Returns `null` if any of the operator/operand pairs are invalid.
///
/// Since narrow links can combine operators in ways our Narrow type can't
/// represent, this can also return null for valid narrow links.
///
/// This can also return null for some valid narrow links that our Narrow
/// type *could* accurately represent. We should try to understand these
/// better, but some kinds will be rare, even unheard-of:
/// #narrow/stream/1-announce/stream/1-announce (duplicated operator)
///
/// The passed `url` must appear to be a link to a Zulip narrow on the given
/// `realm`.
Narrow? parseInternalLink(Uri url, PerAccountStore store) {
if (!url.hasFragment) return null;
if (!url.hasScheme || url.host.isEmpty) return null;
if (!url.hasEmptyPath && (url.path != '/')) return null;

if (url.origin != store.account.realmUrl.origin) return null;

final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
switch (category) {
case 'narrow':
if (segments.isEmpty || !segments.length.isEven) return null;
return _interpretNarrowSegments(segments, store);
}
return null;
}

// Helper to split fragment that handles trailing slashes
(String, List<String>) _getCategoryAndSegmentsFromFragment(String fragment) {
final [category, ...segments] = fragment.split('/');
if (segments.length > 1 && segments.last == '') segments.removeLast();
return (category, segments);
}

Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
assert(segments.isNotEmpty);
assert(segments.length.isEven);

ApiNarrowStream? streamElement;
ApiNarrowTopic? topicElement;
ApiNarrowDm? dmElement;

for (var i=0; i<segments.length; i+=2) {
final (operator, negated) = _parseOperator(segments[i]);
final operand = segments[i+1];
switch (operator) {
case NarrowOperator.unknown:
return null;
case NarrowOperator.stream:
if (streamElement != null) return null;
final streamId = _parseStreamOperand(operand, store);
if (streamId == null) return null;
streamElement = ApiNarrowStream(streamId, negated: negated);
case NarrowOperator.subject:
case NarrowOperator.topic:
if (topicElement != null) return null;
final topic = _decodeHashComponent(operand);
topicElement = ApiNarrowTopic(topic, negated: negated);
case NarrowOperator.near:
// TODO(#82): support for near
continue;
case NarrowOperator.dm:
case NarrowOperator.pmWith:
if (dmElement != null) return null;
final dmIds = _parsePmOperand(operand);
if (dmIds == null) return null;
dmElement = ApiNarrowDm(dmIds, negated: negated);
}
}

if (dmElement != null) {
if (streamElement != null || topicElement != null) return null;
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.account.userId);
} else if (streamElement != null) {
final streamId = streamElement.operand;
if (topicElement != null) {
if (streamElement.negated || topicElement.negated) return null; // TODO(#252): return SearchNarrow
return TopicNarrow(streamId, topicElement.operand);
} else {
if (streamElement.negated) return null; // TODO(#252): return SearchNarrow
return StreamNarrow(streamId);
}
}
return null;
}

@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
enum NarrowOperator {
// 'dm' is new in server-7.0; means the same as 'pm-with'
dm,
near,
pmWith,
stream,
subject,
topic,
unknown;

static NarrowOperator fromRawString(String raw) => _byRawString[raw] ?? unknown;

static final _byRawString = _$NarrowOperatorEnumMap.map((key, value) => MapEntry(value, key));
}

(NarrowOperator, bool) _parseOperator(String input) {
final String operator;
final bool negated;
if (input.startsWith('-')) {
operator = input.substring(1);
negated = true;
} else {
operator = input;
negated = false;
}
return (NarrowOperator.fromRawString(operator), negated);
}

/// Parse the operand of a `stream` operator, returning a stream ID.
///
/// The ID might point to a stream that's hidden from our user (perhaps
/// doesn't exist). If so, most likely the user doesn't have permission to
/// see the stream's existence -- like with a guest user for any stream
/// they're not in, or any non-admin with a private stream they're not in.
/// Could be that whoever wrote the link just made something up.
///
/// Returns null if the operand has an unexpected shape, or has the old shape
/// (stream name but no ID) and we don't know of a stream by the given name.
int? _parseStreamOperand(String operand, PerAccountStore store) {
// "New" (2018) format: ${stream_id}-${stream_name} .
final match = RegExp(r'^(\d+)(?:-.*)?$').firstMatch(operand);
final newFormatStreamId = (match != null) ? int.parse(match.group(1)!) : null;
if (newFormatStreamId != null && store.streams.containsKey(newFormatStreamId)) {
return newFormatStreamId;
}

// Old format: just stream name. This case is relevant indefinitely,
// so that links in old conversations continue to work.
final streamName = _decodeHashComponent(operand);
final stream = store.streamsByName[streamName];
if (stream != null) return stream.streamId;

if (newFormatStreamId != null) {
// Neither format found a Stream, so it's hidden or doesn't exist. But
// at least we have a stream ID; give that to the caller.
return newFormatStreamId;
}

// Unexpected shape, or the old shape and we don't know of a stream with
// the given name.
return null;
}

List<int>? _parsePmOperand(String operand) {
final rawIds = operand.split('-')[0].split(',');
try {
return rawIds.map(int.parse).toList();
} on FormatException {
return null;
}
}
19 changes: 19 additions & 0 deletions lib/model/internal_link.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit dcc28e0

Please sign in to comment.