-
Notifications
You must be signed in to change notification settings - Fork 171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
msglist: Follow /with/ links through message moves #1029
base: main
Are you sure you want to change the base?
Changes from all commits
35db74d
9958ae1
a8dd4da
d49d62a
9822bc5
ff6cc8a
0131961
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,18 +4,36 @@ part 'narrow.g.dart'; | |
|
||
typedef ApiNarrow = List<ApiNarrowElement>; | ||
|
||
/// Resolve any [ApiNarrowDm] elements appropriately. | ||
/// Resolve any [ApiNarrowElement]s, such as [ApiNarrowDm]s, appropriately. | ||
/// | ||
/// This encapsulates a server-feature check. | ||
ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { | ||
if (!narrow.any((element) => element is ApiNarrowDm)) { | ||
return narrow; | ||
// TODO(server-7) remove [ApiNarrowDm] reference in dartdoc | ||
ApiNarrow resolveApiNarrowElements(ApiNarrow narrow, int zulipFeatureLevel) { | ||
bool hasDmElement = false; | ||
bool hasWithElement = false; | ||
for (final element in narrow) { | ||
switch (element) { | ||
case ApiNarrowDm(): hasDmElement = true; | ||
case ApiNarrowWith(): hasWithElement = true; | ||
default: | ||
} | ||
} | ||
if (!hasDmElement && !hasWithElement) return narrow; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The goal of the corresponding early return in the existing code is so we don't make a copy of the list in cases where we don't need to. So we should preserve that property. In this version, I think it will end up making a no-op copy if there's a /with/ element on a recent server. |
||
|
||
final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) | ||
return narrow.map((element) => switch (element) { | ||
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), | ||
_ => element, | ||
}).toList(); | ||
final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9) | ||
|
||
ApiNarrow result = narrow; | ||
if (hasDmElement) { | ||
result = narrow.map((element) => switch (element) { | ||
ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), | ||
_ => element, | ||
}).toList(); | ||
} | ||
if (hasWithElement && !supportsOperatorWith) { | ||
result.removeWhere((element) => element is ApiNarrowWith); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This mutates |
||
} | ||
Comment on lines
+26
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think these are probably cleanest as one loop that builds up (By the time we reach this point we should already know that the loop is going to end up producing a result that's different from |
||
return result; | ||
} | ||
|
||
/// An element in the list representing a narrow in the Zulip API. | ||
|
@@ -64,13 +82,29 @@ class ApiNarrowTopic extends ApiNarrowElement { | |
); | ||
} | ||
|
||
/// An [ApiNarrowElement] with the 'with' operator. | ||
/// | ||
/// If part of [ApiNarrow] use [resolveApiNarrowElements]. | ||
class ApiNarrowWith extends ApiNarrowElement { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: let's order this below This type of element is an alternative way to specify a conversation, which supersedes those three when it takes effect. So I want to think of those three jointly as one way of specifying a conversation, and this one as a different way. |
||
@override String get operator => 'with'; | ||
|
||
@override final int operand; | ||
|
||
ApiNarrowWith(this.operand, {super.negated}); | ||
|
||
factory ApiNarrowWith.fromJson(Map<String, dynamic> json) => ApiNarrowWith( | ||
json['operand'] as int, | ||
negated: json['negated'] as bool? ?? false, | ||
); | ||
} | ||
|
||
/// An [ApiNarrowElement] with the 'dm', or legacy 'pm-with', operator. | ||
/// | ||
/// An instance directly of this class must not be serialized with [jsonEncode], | ||
/// and more generally its [operator] getter must not be called. | ||
/// Instead, call [resolve] and use the object it returns. | ||
/// | ||
/// If part of [ApiNarrow] use [resolveDmElements]. | ||
/// If part of [ApiNarrow] use [resolveApiNarrowElements]. | ||
class ApiNarrowDm extends ApiNarrowElement { | ||
@override String get operator { | ||
assert(false, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,7 +58,7 @@ String? decodeHashComponent(String str) { | |
// you do so by passing the `anchor` param. | ||
Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { | ||
// TODO(server-7) | ||
final apiNarrow = resolveDmElements( | ||
final apiNarrow = resolveApiNarrowElements( | ||
narrow.apiEncode(), store.connection.zulipFeatureLevel!); | ||
final fragment = StringBuffer('narrow'); | ||
for (ApiNarrowElement element in apiNarrow) { | ||
|
@@ -77,6 +77,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { | |
fragment.write('$streamId-$slugifiedName'); | ||
case ApiNarrowTopic(): | ||
fragment.write(_encodeHashComponent(element.operand)); | ||
case ApiNarrowWith(): | ||
fragment.write(element.operand.toString()); | ||
case ApiNarrowDmModern(): | ||
final suffix = element.operand.length >= 3 ? 'group' : 'dm'; | ||
fragment.write('${element.operand.join(',')}-$suffix'); | ||
|
@@ -151,6 +153,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) { | |
|
||
ApiNarrowStream? streamElement; | ||
ApiNarrowTopic? topicElement; | ||
ApiNarrowWith? withElement; | ||
ApiNarrowDm? dmElement; | ||
Set<IsOperand> isElementOperands = {}; | ||
|
||
|
@@ -185,9 +188,12 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) { | |
isElementOperands.add(IsOperand.fromRawString(operand)); | ||
|
||
case _NarrowOperator.near: // TODO(#82): support for near | ||
case _NarrowOperator.with_: // TODO(#683): support for with | ||
continue; | ||
|
||
case _NarrowOperator.with_: | ||
if (withElement != null) return null; | ||
withElement = ApiNarrowWith(int.parse(operand, radix: 10)); | ||
|
||
case _NarrowOperator.unknown: | ||
return null; | ||
} | ||
|
@@ -216,7 +222,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) { | |
} else if (streamElement != null) { | ||
final streamId = streamElement.operand; | ||
if (topicElement != null) { | ||
return TopicNarrow(streamId, topicElement.operand); | ||
return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if we find a Generally if we can't represent the exact semantics of a link with our The one case where we might want to make a hack of accepting a link even though we'll give it only approximate semantics is where it's a link that's going to be frequently generated by Zulip, i.e. either the server or one of our clients. That's basically the story of why in main we accept |
||
} else { | ||
return ChannelNarrow(streamId); | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -480,6 +480,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { | |||||||||
numAfter: 0, | ||||||||||
); | ||||||||||
if (this.generation > generation) return; | ||||||||||
_adjustTopicPermalinkNarrow(result.messages.firstOrNull); | ||||||||||
store.reconcileMessages(result.messages); | ||||||||||
store.recentSenders.handleMessages(result.messages); // TODO(#824) | ||||||||||
for (final message in result.messages) { | ||||||||||
|
@@ -493,11 +494,46 @@ class MessageListView with ChangeNotifier, _MessageSequence { | |||||||||
notifyListeners(); | ||||||||||
} | ||||||||||
|
||||||||||
/// Update [narrow] for the result of a "with" narrow (topic permalink) fetch. | ||||||||||
/// | ||||||||||
/// To avoid an extra round trip, the server handles [ApiNarrowWith] | ||||||||||
/// by returning results from the indicated message's current stream/topic | ||||||||||
/// (if the user has access), | ||||||||||
/// even if that differs from the narrow's stream/topic filters | ||||||||||
/// because the message was moved. | ||||||||||
/// | ||||||||||
/// If such a "redirect" happened, this helper updates the stream and topic | ||||||||||
/// in [narrow] to match the message's current conversation. | ||||||||||
/// It also removes the "with" component from [narrow] | ||||||||||
/// whether or not a redirect happened. | ||||||||||
/// | ||||||||||
/// See API doc: | ||||||||||
/// https://zulip.com/api/construct-narrow#message-ids | ||||||||||
void _adjustTopicPermalinkNarrow(Message? someFetchedMessageOrNull) { | ||||||||||
final narrow = this.narrow; | ||||||||||
if (narrow is! TopicNarrow || narrow.with_ == null) return; | ||||||||||
|
||||||||||
switch (someFetchedMessageOrNull) { | ||||||||||
case null: | ||||||||||
// This can't be a redirect; a redirect can't produce an empty result. | ||||||||||
// (The server only redirects if the message is accessible to the user, | ||||||||||
// and if it is, it'll appear in the result, making it non-empty.) | ||||||||||
this.narrow = narrow.sansWith(); | ||||||||||
case StreamMessage(:final streamId, :final topic): | ||||||||||
this.narrow = TopicNarrow(streamId, topic); | ||||||||||
Comment on lines
+522
to
+523
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
This feels a bit more direct. |
||||||||||
case DmMessage(): // TODO(log) | ||||||||||
assert(false); | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
/// Fetch the next batch of older messages, if applicable. | ||||||||||
Future<void> fetchOlder() async { | ||||||||||
if (haveOldest) return; | ||||||||||
if (fetchingOlder) return; | ||||||||||
assert(fetched); | ||||||||||
assert(narrow is! TopicNarrow | ||||||||||
// We only intend to send "with" in [fetchInitial]; see there. | ||||||||||
|| (narrow as TopicNarrow).with_ == null); | ||||||||||
assert(messages.isNotEmpty); | ||||||||||
_fetchingOlder = true; | ||||||||||
_updateEndMarkers(); | ||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -92,14 +92,17 @@ class ChannelNarrow extends Narrow { | |
} | ||
|
||
class TopicNarrow extends Narrow implements SendableNarrow { | ||
const TopicNarrow(this.streamId, this.topic); | ||
const TopicNarrow(this.streamId, this.topic, {this.with_}); | ||
|
||
factory TopicNarrow.ofMessage(StreamMessage message) { | ||
return TopicNarrow(message.streamId, message.topic); | ||
} | ||
|
||
final int streamId; | ||
final String topic; | ||
final int? with_; | ||
|
||
TopicNarrow sansWith() => TopicNarrow(streamId, topic); | ||
|
||
@override | ||
bool containsMessage(Message message) { | ||
|
@@ -108,22 +111,26 @@ class TopicNarrow extends Narrow implements SendableNarrow { | |
} | ||
|
||
@override | ||
ApiNarrow apiEncode() => [ApiNarrowStream(streamId), ApiNarrowTopic(topic)]; | ||
ApiNarrow apiEncode() => [ | ||
ApiNarrowStream(streamId), | ||
ApiNarrowTopic(topic), | ||
if (with_ != null) ApiNarrowWith(with_!), | ||
]; | ||
|
||
@override | ||
StreamDestination get destination => StreamDestination(streamId, topic); | ||
|
||
@override | ||
String toString() => 'TopicNarrow($streamId, $topic)'; | ||
String toString() => 'TopicNarrow($streamId, $topic, with: $with_)'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: in toString, leave the optional field out when it's null (or more generally, when it's the default) These narrow objects appear fairly often in test output, so the noise is worth a few more tokens of logic here in order to suppress. |
||
|
||
@override | ||
bool operator ==(Object other) { | ||
if (other is! TopicNarrow) return false; | ||
return other.streamId == streamId && other.topic == topic; | ||
return other.streamId == streamId && other.topic == topic && other.with_ == with_; | ||
} | ||
|
||
@override | ||
int get hashCode => Object.hash('TopicNarrow', streamId, topic); | ||
int get hashCode => Object.hash('TopicNarrow', streamId, topic, with_); | ||
} | ||
|
||
/// The narrow for a direct-message conversation. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name doesn't sound right to me for the expanded scope of this function. (In particular "elements" in the name feels redundant; it doesn't seem to convey any more information than
resolveApiNarrow
would.)Here's a version: