Skip to content

mute: Maintain muted-topics state by stream ID, not name#5223

Merged
gnprice merged 28 commits intozulip:mainfrom
gnprice:pr-mute-state
Feb 9, 2022
Merged

mute: Maintain muted-topics state by stream ID, not name#5223
gnprice merged 28 commits intozulip:mainfrom
gnprice:pr-mute-state

Conversation

@gnprice
Copy link
Copy Markdown
Member

@gnprice gnprice commented Feb 8, 2022

This represents one of the last significant pieces left for #3918.

Much of the prep work is in converting some tests to our current style, which is more robust to making this kind of change without having to go and make a lot of messy changes to test data. In particular we add type-checking to about 20% of our remaining test code that lacked it, including unreadSelectors-test which was the largest such file. And the old mute tests turn out to have been completely nonsense (which went undiscovered because they lacked types), so we fix that.

The changes here are focused on how we store the data internally and convert it at the edge, independent of what form the server provides it in -- so this is independent of zulip/zulip#21015 which is for the server to start providing this data with stream IDs instead of stream names. In the future when that issue is resolved, we can start using the new protocol with new servers, but we'll still want to have this logic handling the old protocol until we've dropped support for all server versions that exist today.

Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks, glad to have this! See a few small comments below.

origValuePrototype === (Object.prototype: $FlowIssue)
|| origValuePrototype === (Array.prototype: $FlowIssue),
'unexpected class',
`stringify: unexpected class: ${origValuePrototype.constructor.name}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍

Comment thread src/mute/muteModel.js

case REGISTER_COMPLETE:
return action.data.muted_topics || initialState;
return convert(action.data.muted_topics ?? [], getStreamsByName(globalState));
Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe Feb 8, 2022

Choose a reason for hiding this comment

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

This is where the mute reducer depends on the state after another reducer has been applied (the streams reducer), right? Should we comment about that here? It looks like the requirement is covered in a new test ('when mute data is provided init state with it: end-to-end') so that's good.

I'm wary of falling into a pattern where some reducers depend on the output of other reducers when processing the same action. Taken too far, there's some risk of getting tangled up into a dependency cycle, right?

I don't see another good way of doing what we want to do here, though. Happily, this case doesn't seem like it'll hang around forever. Eventually, all supported servers will give us the data by ID and we can drop this dependency. Could add a comment to mention that, in src/boot/reducers.js, right? (Either now or later when we have a threshold for when servers start sending the new shape.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good thought, yeah, this should have a comment.

I'm wary of falling into a pattern where some reducers depend on the output of other reducers when processing the same action. Taken too far, there's some risk of getting tangled up into a dependency cycle, right?

In principle, yes. I'm not too worried about this for a couple of reasons:

  • I think there's a fairly natural rough layering of the things in the model: first of all there is realm-wide information like version, URL, and settings; then there are users, and streams; then relationships between those like subscriptions and muted-topics (and between users like muted-users); then messages which are sent by users and to streams or users. So mostly we should be able to just follow that.
  • The web app manages it. 🙂 Here's what that looks like -- it's actually mixed in with initializing all the UI, like setting click handlers and so on:
    https://github.com/zulip/zulip/blob/8e0633578863a5c144ffce86b462d0b318282383/static/js/ui_init.js#L544-L629

If we do ever run into a cycle between these wanting to run after each other, like A depends on B depends on A, we can always resolve it by breaking up REGISTER_COMPLETE into two actions: A handles the first action to set up whatever it is B actually needs, then B runs, then A handles the second action to do whatever it needed B for. (And if there isn't such a way to break up what either A or B needs to do, then that'd be a more fundamental problem with whatever data-schema choices we were considering that led to that, and we'd figure out an alternative.)

Eventually, all supported servers will give us the data by ID and we can drop this dependency. Could add a comment to mention that, in src/boot/reducers.js, right? (Either now or later when we have a threshold for when servers start sending the new shape.)

It's true that will happen eventually. It'll be a couple of years at least, though -- the server doesn't support that yet, and won't in 5.0 which is about to come out, so 18 months after whenever we release 6.0. So it is important that this be a reasonable design for us to live with.

For the reasons above, though, I think having this kind of ordering is fine. It's natural for many things to depend on users and streams; also on the server version and realm URL, and potentially some server or realm settings. So we might add some more ordering like this, and might always have some.

Comment on lines +3 to +4
// No need for Immutable, because -- the server API actually doesn't support
// incremental changes! When something changes, it sends the entire new list.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No need for Immutable, because […]

I wondered! 😅

for (const [topic, msgIds] of streamData) {
const isMuted = isTopicMuted(
streamId,
(subscriptionsById.get(streamId) || NULL_SUBSCRIPTION).name,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

-          […] NULL_SUBSCRIPTION […]

🎉

Comment thread src/utils/defaultMap.js
Comment on lines +28 to +29
value = this.default();
this.map.set(key, value);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Huh—😮—looking at the defaultdict doc, I guess this is what it does too.

I guess callers should be careful to make sure default_ stays pretty steady/predictable in what it returns across the lifetime of the DefaultMap. Otherwise I could imagine some annoying bugs where a value at a key accidentally depends on some state at the time the key was first read, if the key was missing then.

I like how you've named this method getOrCreate and mentioned the "create" part in the jsdoc; I don't think this needs anything else to be clear enough about the behavior.

Looking at the examples in the doc, it seems normal to pass something like the list constructor. Which is very steady/predictable in what it returns. 😛

Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe Feb 8, 2022

Choose a reason for hiding this comment

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

It looks like Immutable.Map's .get() takes an optional notSetValue. I'm not sure if that's a much better API to model this on—callers may accidentally vary what they pass for notSetValue with each .get() call and run into bugs that way. But at least there's not this odd possibility of depending on some state at first-read-if-not-set time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, I think the right way to use defaultdict always involves giving it a factory that makes something like an empty container, or returns a constant -- something that manifestly doesn't depend on any data that can change.

It'd be possible to do something twisty like a factory that ends up depending on some state, but that wouldn't be a good use case for defaultdict -- and I don't think it's a tempting one in practice, as I've never seen any code like that.

It looks like Immutable.Map's .get() takes an optional notSetValue.

That does something quite different -- it doesn't alter the underlying collection (nor return a modified collection). Its Python analog would be dict.get, on the plain dict type:
https://docs.python.org/3/library/stdtypes.html#dict.get

What is more like defaultdict or this DefaultMap is the notSetValue parameter to Immutable.Map#update:
https://immutable-js.com/docs/v4.0.0/Map/#update()
Hmm, oddly that doesn't seem to have a description. But I believe the behavior is basically as if it were temporarily a DefaultMap with a factory returning notSetValue.

I think that notSetValue API choice is a reasonable compromise for the sake of having just the one Map type and having that feature still exist. But I think it is quite prone to bugs of this kind:

I'm not sure if that's a much better API—callers may accidentally vary what they pass for notSetValue with each .get() call and run into bugs that way.

Comment thread src/utils/defaultMap.js
// @flow strict-local

/**
* A wrapper around Map, akin to Python's `defaultdict`.
Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe Feb 8, 2022

Choose a reason for hiding this comment

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

src/utils/defaultMap.js

For the filename, I could go either way between DefaultMap and defaultMap. I didn't mean to set a precedent with zulipVersion, and sometimes I've thought ZulipVersion might be better. 😛 This is a small thing and not worth fretting over though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm, yeah, I didn't actually really think about that.

If I'm naming the file after the class, I think it's best for it to have the same name as the class. I'll make it DefaultMap.js.

chrisbobbe pushed a commit to chrisbobbe/zulip-mobile that referenced this pull request Feb 9, 2022
Call sites updated automatically, with:

  $ perl -i -0pe '
        s<\{ \.\.\.eg\.makeStream\(\{ (.*?) \}\), (stream_id: \d+) \}>
         <eg.makeStream({ $2, $1 })>g
      ' src/**/*.js

(chris: cherry-picked from Greg's not-yet-merged zulip#5223)
These event actions are based on what's in the event from the server,
not what's in our internal state.  These happen to be the same at the
moment, but won't stay that way; so have this say what it really means.
After all, it appears not only in the initial data but also in events.
The term "mute" is used for both topics and users, so best to
be explicit.
Call sites updated automatically, with:

  $ perl -i -0pe '
        s<\{ \.\.\.eg\.makeStream\(\{ (.*?) \}\), (stream_id: \d+) \}>
         <eg.makeStream({ $2, $1 })>g
      ' src/**/*.js
Call sites updated mostly automatically, with:

  $ perl -i -0pe '
            s<\{ \.\.\.eg\.makeUser\(\{ (.*?) \}\), (email: \S+) \}>
             <eg.makeUser({ $1, $2 })>g
          ' src/**/*.js
  $ perl -i -0pe '
            s<\{ \.\.\.eg\.makeUser\(\), (email: \S+) \}>
             <eg.makeUser({ $1 })>g
          ' src/**/*.js

plus manually handling the one call site each in render-test
and fetchActions-test.
Now all eg.makeUser call sites are specifying their extra properties
in the argument, rather than with their own spread.
…nd use

We already have one test where we want to customize `color`;
we'll add more soon, plus some for `pin_to_top`.
Now all eg.makeSubscription call sites are using the argument to
customize the result, rather than spreading it.
…uxState

This decouples it from changes in the respective data structures for
these various parts of our state.
Call sites updated automatically, like so:

  $ perl -i -0pe '
        s<\{\s* \.\.\.eg\.action\.register_complete,\s*
                data:\s* \{\s*
                  \.\.\.eg\.action\.register_complete\.data,\s*
                  unread_msgs:\s* \{\s*
                    \.\.\.eg\.action\.register_complete\.data\.unread_msgs,\s*
                    (.*?)
                  \},?\s*
                \},?\s*
          \}>
         <eg.mkActionRegisterComplete({ unread_msgs: { $1 } })>xsg
      ' src/**/*.js
  $ perl -i -0pe '
        s<\{\s* \.\.\.eg\.action\.register_complete,\s*
                data:\s* \{\s*
                  \.\.\.eg\.action\.register_complete\.data,
                  (.*?)
                \},?\s*
          \}>
         <eg.mkActionRegisterComplete({ $1 })>xsg
      ' src/**/*.js
  $ perl -i -0pe '
        s<\bdeepFreeze\(\s*
              (
                eg\.mkActionRegisterComplete\(\{ .*? \}\)
              ) ,?\s*
            \)>
         <$1>xsg
      ' src/**/*.js
  $ tools/fmt
…dInNarrow

The work these used to do got moved a while ago into
getShownMessagesForNarrow.
This is some legacy code that's been barely touched since 2017.
And it turns out... not a single one of these test cases has the
data in the form it actually appears in.  Or for that matter in a
form the data *could* possibly appear in in order for this feature
to work.

Happily, when we bring the type-checker to bear on this file, it
immediately tells us about that!  Do so, and fix the brokenness.
Less boilerplate, so it's easier to see what the tests are actually
doing, and also so there's less to edit to adapt to changes.
Following the, er, model of unreadModel.
Now the tests no longer try to construct a MuteState directly;
the data structure is encapsulated in the model.
And mention "stringify", to give a bit more context.
We'll use these in the muted-topics model, because the server
doesn't send incremental updates for them anyway.
That is, split the case where the previous state is void, and so the
action must be the store initialization action, from the case where
it's any other action and the previous state must be of its usual
object type.

This makes for a bit more boilerplate, and one more line to update
when adding or removing a sub-reducer.  But both the split versions
are simpler, and that will help us add new complications that we need.
This will let us alter the data structure so that it relies on
stream IDs instead of stream names.
This represents one of the last significant pieces left for zulip#3918.
In the previous commit, we already stopped looking at this argument
in favor of the stream ID.

This change should be NFC except in the case where the given stream
didn't exist in our data structures, at the several call sites that
were looking it up in order to find the name.  Those now simply go
and look up the mute status, where they presumably find nothing.

In particular if it's possible to reach the call site in
constructTopicActionButtons in that case, then previously we would
crash; now we offer to mute the topic.
Nothing radical, but we have these two instances of this pattern now
and I think they both get a bit simpler to read this way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants