Skip to content
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

Merged
merged 9 commits into from
Feb 21, 2025

Conversation

chrisbobbe
Copy link
Collaborator

Fixes: #1028

@chrisbobbe chrisbobbe added the maintainer review PR ready for review by Zulip maintainers label Oct 26, 2024
@chrisbobbe chrisbobbe requested a review from PIG208 October 26, 2024 00:25
Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall! Left some comments.

generateMessages: (i) => eg.streamMessage(
stream: someStream,
topic: someTopic)),
];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can refactor these test cases (and the ones added later) with a helper that takes these arguments. See #870 (comment).

const String recentZulipVersion = '8.0';
const int recentZulipFeatureLevel = 185;
const String recentZulipVersion = '9.0';
const int recentZulipFeatureLevel = 271;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to drop this now.

Comment on lines 204 to 208
final fetchFuture = model.fetchInitial();
check(model).fetched.isFalse();

checkNotNotified();
await fetchFuture;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can use await model.fetchInitial() here and the test below

///
/// See API doc:
/// https://zulip.com/api/construct-narrow#message-ids
void _adjustTopicPermalinkNarrow(Narrow narrow_, Message? someFetchedMessageOrNull) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the caller passes the same narrow as the narrow that lives on the message list. Should we remove narrow_ and just use narrow instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's odd for this function to have a narrow_ param; let's remove that.

It's convenient in the function's body to not keep using narrow each time we need it, because then we'd have to repeat things like narrow as TopicNarrow even in places where it's clear to us that the value can't be anything other than a TopicNarrow. I think what's happening there is: when we read narrow, the analyzer sees that as calling a getter, not accessing a variable, and it doesn't assume the getter will return the same value / Narrow subtype from one call to the next. And while in theory the analyzer could conclude that this class's narrow getter doesn't have that surprising behavior—it's just a field with the getter defined implicitly—it doesn't want to go and check for subclasses that might override the getter with that surprising behavior.

How about a narrow_ variable at the top of the function body, with a comment.

Copy link
Member

@PIG208 PIG208 Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that explanation makes sense. I remember that making narrow private might fix the issue, but I'm not sure. The proposed change sounds good to me too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm nevermind, I think the local variable would be a better solution here.

See #808 (comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting; I like final narrow = this.narrow in that example; I think I like it better than final narrow_ = narrow.

}) async {
addTearDown(testBinding.reset);
streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))];
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
streams: streams, subscriptions: subscriptions, unreadMsgs: unreadMsgs));
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
connection = store.connection as FakeApiConnection;
if (zulipFeatureLevel != null) {
connection.zulipFeatureLevel = zulipFeatureLevel;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think eg.selfAccount carries a different zulipFeatureLevel if we only override the feature level on the connection.

@chrisbobbe chrisbobbe force-pushed the pr-follow-with-links branch from 08b8e2f to 0131961 Compare October 30, 2024 20:12
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

@PIG208 PIG208 added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Oct 30, 2024
@PIG208 PIG208 assigned gnprice and unassigned PIG208 Oct 30, 2024
@PIG208
Copy link
Member

PIG208 commented Oct 30, 2024

Thanks! This revision looks good to me.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @chrisbobbe, and thanks @PIG208 for the previous reviews!

Generally this looks great. Comments below, most of them small.

..messages.length.equals(30)
..haveOldest.isTrue();
});
group('smoke', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

msglist test: Refactor fetchInitial smoke test, preparing for more narrows

Not NFC just because the test is identified differently in the
output (under a new "smoke" group, and with the name
"CombinedFeedNarrow").

We've sometimes gone back and forth on this, I think; but I'd rather consider this kind of change NFC. So the names of the test cases, and their grouping, don't count as "functional", even though they are observable with flutter test.

I think a good definition of what does count as "functional", for test code, is: a functional change is one that could affect whether the tests would pass or fail, on some hypothetical future change to the code under test.

}) async {
addTearDown(testBinding.reset);
streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))];
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
final effectiveFeatureLevel = zulipFeatureLevel ?? eg.recentZulipFeatureLevel;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
final effectiveFeatureLevel = zulipFeatureLevel ?? eg.recentZulipFeatureLevel;
zulipFeatureLevel ??= eg.recentZulipFeatureLevel;

and then subsequent lines get to say zulipFeatureLevel: zulipFeatureLevel instead of needing a different, longer name

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting; I'll keep this pattern in mind. I'd always thought it was a code smell to reassign a function param.

Copy link
Member

@gnprice gnprice Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah — just like mutating a local variable, it requires a bit of care to avoid making things confusing. But I think doing so (as here) right at the top of the function body, where it's effectively still parsing its arguments, is one pattern where it's generally pretty clean.

In this case it's also what this function is already doing with two other parameters on the previous line, so this might as well follow the same pattern.

}
if (!hasDmElement && !hasWithElement) return narrow;
Copy link
Member

Choose a reason for hiding this comment

The 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.

}).toList();
}
if (hasWithElement && !supportsOperatorWith) {
result.removeWhere((element) => element is ApiNarrowWith);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mutates result. If the argument narrow didn't happen to have an ApiNarrowDm in it, then that mutates the argument.

Comment on lines 26 to 42
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);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are probably cleanest as one loop that builds up result from empty, iterating through narrow.

(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 narrow, so we can't avoid producing a fresh list and don't need further conditions like if (hasDmElement) as an optimization.)

Comment on lines 522 to 523
case StreamMessage(:final streamId, :final topic):
this.narrow = TopicNarrow(streamId, topic);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
case StreamMessage(:final streamId, :final topic):
this.narrow = TopicNarrow(streamId, topic);
case StreamMessage():
this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull);

This feels a bit more direct.

Comment on lines 212 to 215
check(model).narrow.isA<TopicNarrow>()
..streamId.equals(otherStream.streamId)
..topic.equals(otherTopic)
..with_.isNull();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
check(model).narrow.isA<TopicNarrow>()
..streamId.equals(otherStream.streamId)
..topic.equals(otherTopic)
..with_.isNull();
check(model).narrow
.equals(TopicNarrow(otherStream.streamId, otherTopic));

TopicNarrow has an == override, so we may as well use it here.

Comment on lines 209 to 195
checkNotNotified();
await model.fetchInitial();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
checkNotNotified();
await model.fetchInitial();
await model.fetchInitial();

and no blank line above

The checkNotNotified is redundant because we haven't done anything yet up to that point. (We called prepare, but it ends by ensuring the notification count is clean; other test cases rely on that too.)

The connection.prepare belongs right before the fetchInitial call, because it's preparing for a request that call makes.

Comment on lines 228 to 229
for (final useLegacy in [false, true]) {
testWidgets('without message move; ${useLegacy ? 'legacy' : 'modern'}', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legacy case doesn't really change anything in this test, does it? It seems like all it does is mean we don't send the with element to the server.

The fact that we don't send that is already covered by the API tests. So we can leave it out here.

if (!narrow.any((element) => element is ApiNarrowDm)) {
return narrow;
// TODO(server-7) remove [ApiNarrowDm] reference in dartdoc
ApiNarrow resolveApiNarrowElements(ApiNarrow narrow, int zulipFeatureLevel) {
Copy link
Member

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:

/// Adapt the given narrow to be sent to the given Zulip server version.
///
/// Any elements that take a different name on old vs. new servers
/// will be resolved to the specific name to use.
/// Any elements that are unknown to old servers and can
/// reasonably be omitted will be omitted.
ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) {

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review and patience! Revision pushed. 🎉

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! I think this is close; comments below.

).toJson());
final fetchFuture = model.fetchInitial();
check(model).fetched.isFalse();
group('fetchInitial', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

msglist test: Make groups for fetchInitial and fetchOlder tests

NFC, similarly to #1029 (comment)

Comment on lines 37 to 38
case ApiNarrowWith():
assert(!supportsOperatorWith);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there's both an ApiNarrowWith and an ApiNarrowDm? Then I think this assertion can fail.

Comment on lines 28 to 29
if (!hasDmElement && (!hasWithElement || supportsOperatorWith)) {
return narrow;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my previous comment just below: this condition ought to be equivalent to "the loop below will produce a result different from narrow" — that's the basis for the optimization this represents.

I think this condition is correct but the logic below doesn't match it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right—thanks for the catch.

ApiNarrowWith? withElement;
ApiNarrowDm? dmElement;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put these, and the cases below, in the same order as each other and as the respective ApiNarrowElement subclasses

continue;

case _NarrowOperator.with_:
if (withElement != null) return null;
withElement = ApiNarrowWith(int.parse(operand, radix: 10));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on parse failure should return null, not throw

// and if it is, it'll appear in the result, making it non-empty.)
this.narrow = narrow.sansWith();
case StreamMessage():
this.narrow = TopicNarrow.ofMessage(someFetchedMessageOrNull);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over on the widget state there's a comment which should get updated:

  void _modelChanged() {
    if (model!.narrow != widget.narrow) {
      // A message move event occurred, where propagate mode is
      // [PropagateMode.changeAll] or [PropagateMode.changeLater].
      widget.onNarrowChanged(model!.narrow);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh thanks for the catch.

numAfter: 0,
);
});
final otherStream = eg.stream();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: otherChannel, to match the neighboring someChannel

final fetchFuture = model.fetchOlder();
checkNotifiedOnce();
check(model).fetchingOlder.isTrue();
test('topic permalink, message was moved', () async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test case and the one above it can probably be left out — it doesn't seem like they add anything that isn't covered either by the neighboring tests, or the two test cases added below.

Comment on lines 309 to 313
'narrow': jsonEncode([
ApiNarrowStream(someStream.streamId),
ApiNarrowTopic(eg.t(someTopic)),
ApiNarrowWith(1),
]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can use narrow.apiEncode() like the other test case does, right? Or is there a difference I'm missing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right! That makes sense.

// server sends the /with/<id> message in its current, different location
messages: [eg.streamMessage(id: 1, stream: otherStream, topic: otherTopic)],
streams: [someStream, otherStream],
subscriptions: [eg.subscription(someStream), eg.subscription(otherStream)],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think the subscriptions aren't needed here

(some other tests need them because they use CombinedFeedNarrow)

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed, this time with tests for resolveApiNarrowForServer, since some bugs snuck in (thank you for catching them in #1029 (comment) !).

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks good; just a few comments.

final zulipFeatureLevel = 176;
doTest('dm (legacy)', zulipFeatureLevel: zulipFeatureLevel,
[dm], [dm.resolve(legacy: true)]);
doTest('topic, not permalink', [stream, topic], [stream, topic]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this meant to pass zulipFeatureLevel too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, yes.

@@ -150,6 +173,22 @@ class ApiNarrowPmWith extends ApiNarrowDm {
ApiNarrowPmWith._(super.operand, {super.negated});
}

/// An [ApiNarrowElement] with the 'with' operator.
///
/// If part of [ApiNarrow] use [resolveApiNarrowElements].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: update name for revision

@@ -78,6 +78,8 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
fragment.write('$streamId-$slugifiedName');
case ApiNarrowTopic():
fragment.write(_encodeHashComponent(element.operand.apiName));
case ApiNarrowWith():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: match order of cases

import 'model_checks.dart';

void main() {
group('resolveApiNarrowForServer', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should get a comment pointing to the existing set of tests that cover this method, which are in test/api/route/messages_test.dart, particularly the Narrow.toJson test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah indeed! Thanks for pointing out that existing coverage—which I see I even added to, since an earlier revision, but forgot about that because I wrote that revision a while ago. 🙂

@gnprice
Copy link
Member

gnprice commented Feb 19, 2025

(Oh and there are some unused imports the analyzer is complaining about.)

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! Looks good; a few small comments.

{'operator': 'pm-with', 'operand': [123, 234]},
]));

connection.zulipFeatureLevel = 270;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put this section above the FL 170 section — that way there's more of a monotonic range here from newest to oldish to older

Comment on lines 217 to 215
]));

// Unlikely to occur in the wild but should still be handled correctly
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
]));
// Unlikely to occur in the wild but should still be handled correctly
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([
]));
checkNarrow([ApiNarrowDm([123, 234]), ApiNarrowWith(1)], jsonEncode([

I think the apology isn't needed 🙂

Comment on lines 207 to 208
]));

connection.zulipFeatureLevel = 176;

checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: and then without those other comments, the change to the ZFL can be comfortably kept in the same stanza with the checks that rely on it

Suggested change
]));
connection.zulipFeatureLevel = 176;
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([
]));
connection.zulipFeatureLevel = 176;
checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([

I think that substantially helps to mitigate the risk of confusion from this use of mutable state, where one otherwise might see one of those later checkNarrow calls in isolation and wonder why the expected behavior is what it is

default:
}
}
if (!hasDmElement && (!hasWithElement || supportsOperatorWith)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use a De Morgan transform of this:

Suggested change
if (!hasDmElement && (!hasWithElement || supportsOperatorWith)) {
if (!hasDmElement && !(hasWithElement && !supportsOperatorWith)) {

That way it more directly corresponds to the condition that's written in the switch cases below.

(You could even then apply De Morgan to the outer operator too — write "not (A or B)" instead of "not-A and not-B" — and that might be clearer still.)

And refactor the implementation to prepare for a new [ApiNarrow]
feature.

We're about to add ApiNarrowWith, which new in Zulip Server 9. This
function seems like a good place to resolve ApiNarrows into the
expected form for servers before 9 (without "with") and 9 or later
(with "with"). First we have to make its name and dartdoc more
generic, as done here.
We'll use this for some new tests, coming up.

Also use it now for one test that's only been checking the topic in
the app bar. That test now checks the channel name too, as it was
before b1767b2, which dropped that specificity when we split the
app bar's channel name and topic onto two rows.
@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

@gnprice
Copy link
Member

gnprice commented Feb 21, 2025

Thanks! Looks good; merging.

@gnprice gnprice merged commit c8494a8 into zulip:main Feb 21, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Follow /with/ links through message moves
3 participants