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
Compose area: Enforce mandatory topics #4798
Conversation
This looks good to me, but I'm going to wait for @gnprice or @chrisbobbe to give a second opinion before merging :) |
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.
Thanks @thisisnitish (and @WesleyAC for the review)!
See one comment below, about a migration.
Please format the commit message according to our style guide. I think the change is probably simple enough that it can be described in just a summary line (with no body text), but the summary line should be formatted properly with a prefix, a period at the end, etc., as described in the doc.
src/realm/realmReducer.js
Outdated
@@ -24,6 +24,7 @@ const initialState = { | |||
twentyFourHourTime: false, | |||
canCreateStreams: true, | |||
isAdmin: false, | |||
isTopicMandatory: false, |
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.
Let's add a dropCache
migration in src/boot/store.js for this. Something like,
// Add `isTopicMandatory` to `state.realm`.
'30': dropCache,
(or a higher number than 30, if new migrations happen to get added after I post this review). See dropCache
's jsdoc for what it's for.
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.
Does this actually matter? My understanding is that an app update will drop the cache anyways, since it'll force a restart, and we aren't persisting this data in any case. I could be misunderstanding how long we intend for this data to persist, though.
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.
we aren't persisting this data in any case
Ah: we are persisting this data to disk. realmReducer
deals with state.realm
, and 'realm' is in cacheKeys
. Here's the jsdoc on cacheKeys
:
/**
* Properties on the global state which we persist for caching's sake.
*
* These represent information for which the ground truth is on the
* server, but which we persist locally so that we have it cached and
* don't have to re-download it.
*/
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.
That data won't get cleared automatically on an app restart, but it's easy and harmless for us to tell it to clear on a version upgrade, by using a dropCache
migration.
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.
Hmm, but since this is getting overwritten on the /register
response, doesn't that mean that it's going to get overwritten on app load anyways, since we're going to call /register
then, before we display anything?
(To be clear, I think it's reasonable to have a dropCache
in either case, I'm just trying to make sure I have a good understanding of the data flow)
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.
(To be clear, I think it's reasonable to have a
dropCache
in either case, I'm just trying to make sure I have a good understanding of the data flow)
Makes sense! 🙂
We do expect this to get clobbered with an up-to-date value when a /register
completes. But there are two important possible states to consider while that /register
is in progress:
- We have no cached server data to show, so we show
FullScreenLoading
. - We have some stale data for this account, cached from a previous session, so we show an interactable UI with the "Connecting..." banner (
LoadingBanner
).
The conditional for what to show is provided by useHaveServerDataGate
.
(The logic for what to show used to be more complicated and pretty hard to verify, depending on navigating imperatively at the right time, and setting the initial nav state correctly. If you want, you can read through #4454—particularly, a023b75—for where we looked at that logic closely and refactored it by removing 'loading' as a navigation route; we then consolidated a reusable "have-server-data gate" in #4603 for use with components besides MainTabsScreen
, and we fixed a bug in that "gate" logic in 98d19b8 just a few weeks ago.)
In that second state—
- We have some stale data for this account, cached from a previous session, so we show an interactable UI with the "Connecting..." banner.
—it's possible, in theory (and I guess in practice, if the /register
takes super long and we fix various UI perf issues) to go poking around the app and try to do things with the stale data that you'd also want to do with up-to-date data. Like, I guess, try to send a message without a topic. So it's good to do something explicit like this to ensure we have the right data from the beginning.
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.
So it's good to do something explicit like this to ensure we have the right data from the beginning.
Hmm, even more explicit than dropCache
would be if the migration were written to add the key explicitly. I think dropCache
would work, but only because of the "shallow-merge" quirk in redux-persist
I noticed over at #4679 (comment). (And that same quirk may have the effect that things would work even if we omitted a migration altogether—but as I said in that linked thread, it's nice to be as explicit as possible with migrations.)
I ~copied the dropCache
suggestion from #4543 (comment).
So, now I'm thinking something like
// Add `isTopicMandatory` to `state.realm`.
'30': state => ({
...state,
realm: {
...state.realm,
isTopicMandatory: false,
},
}),
Sorry for the confusion! And thanks Wesley for causing me to think through this again. 🙂
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.
And that same ["shallow-merge"] quirk may have the effect that things would work even if we omitted a migration altogether
Yes, that's right. Effectively it means that when we introduce a new top-level property to any of our top-level state subtrees -- anything of the form state.foo.bar
-- then without doing anything about it in a migration, we get the same effect as if we had a migration to give it the same value that we have for it in the initial state, as specified in the reducer.
A key bit of the background to how it works out that way is that the top-level subtrees state.foo
are the units of what we store with redux-persist
.
I think
dropCache
would work, but only because of the "shallow-merge" quirk
The reason dropCache
works is similar in spirit but I think it's more basic: it's that if a given top-level subtree is missing entirely, then at rehydrate time we just let the initial state (as specified in the reducer) stand untouched.
So in a situation like this, where we've introduced a new property state.foo.bar
on one of the top-level state subtrees state.foo
, the result is that any of:
- no migration
- a
dropCache
migration - a migration that explicitly adds the new property, like in Chris's last comment above
will produce the same, correct results.
It's good to be explicit, as with option 3. On the other hand, it means we duplicate the detail of what the default is -- new installs will get the default from the reducer's initial state, while old installs upgrading will get it from the migration. It also means more boilerplate, in a spot that isn't really type-checked (and is fundamentally tricky to properly type-check) and where getting it wrong could be pretty unpleasant.
So I'm kind of inclined to actually make our practice for this situation be option 1: no migration at all.
However! If we do that, I think it's important to still be explicit about making that choice. That helps us be in the habit of thinking about migrations when updating our types, so that we explicitly think through whether the update is one that falls into this situation and doesn't need a migration. So that could look like adding a comment here, the same place we'd add a migration, that looks like:
// Add `isTopicMandatory` to `state.realm`.
// No migration; just use default from initial state.
Scanning back through previous migrations, I think the only one to date that we'd actually have skipped under this approach is 28, adding state.settings.browser
. All the others either do something more complicated than adding a property, or add a property somewhere deeper in the tree (like in each element of state.accounts
.)
As we add more things like miscellaneous settings, though, this could be something that becomes more common.
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.
Made a chat thread to help make this discussion easier to find: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/data.20migrations/near/1230029
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.
Thanks @thisisnitish! Comments below, in addition to Chris's above.
src/api/initialDataTypes.js
Outdated
@@ -120,6 +120,7 @@ export type RawInitialDataRealmUser = {| | |||
is_admin: boolean, | |||
realm_users: Array<{| ...User, avatar_url?: string | null |}>, | |||
realm_non_active_users: Array<{| ...User, avatar_url?: string | null |}>, | |||
realm_mandatory_topics: boolean, |
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.
We already have this on InitialDataRealm
, so no need to add it elsewhere.
The right place for it is indeed InitialDataRealm
. The organization of these different types is described at the place where they're all combined together:
// Initial data snapshot sent in response to a `/register` request,
// after validation and transformation.
export type InitialData = {|
// The server sends different subsets of the full available data,
// depending on what event types the client subscribes to with the
// `fetch_event_types` field of the `/register` request. We name these
// subsets after the event types that cause them to be included.
This field is controlled by the realm
event type being present; from the docs at https://zulip.com/api/register-queue :
realm_mandatory_topics
: boolean Present ifrealm
is present infetch_event_types
.
so InitialDataRealm
is the right place.
It'd belong on InitialDataRealmUser
(and therefore here on RawInitialDataRealmUser
, which that's based on) if it were instead controlled by realm_user
.
(This organization isn't totally obvious, even though there is that one comment about it. I may go and add some short jsdoc on the individual types here.)
src/reduxTypes.js
Outdated
@@ -256,6 +256,7 @@ export type RealmState = {| | |||
twentyFourHourTime: boolean, | |||
canCreateStreams: boolean, | |||
isAdmin: boolean, | |||
isTopicMandatory: boolean, |
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.
Let's keep this name very similar to the name in the API -- that helps simplify understanding what the code is doing, without the reader having to trace through all the layers.
The API's name for this is realm_mandatory_topics
, and we're already on the "realm" state. So mandatoryTopics
would be a good name.
src/reduxTypes.js
Outdated
@@ -256,6 +256,7 @@ export type RealmState = {| | |||
twentyFourHourTime: boolean, | |||
canCreateStreams: boolean, | |||
isAdmin: boolean, | |||
isTopicMandatory: boolean, |
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.
See the note in the jsdoc on this RealmState
type about how the properties are organized: stuff about the server, then about the org/realm, then about the user.
This property is about the org aka the realm -- that's why its name in the API is prefixed realm_
and it's controlled by the realm
event type -- so it belongs in the "realm" section.
(I realize this is kind of easy to miss. I may try moving that jsdoc material inline into the type definition, next to the respective properties.)
src/realm/realmReducer.js
Outdated
@@ -24,6 +24,7 @@ const initialState = { | |||
twentyFourHourTime: false, | |||
canCreateStreams: true, | |||
isAdmin: false, | |||
isTopicMandatory: false, |
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.
And that same ["shallow-merge"] quirk may have the effect that things would work even if we omitted a migration altogether
Yes, that's right. Effectively it means that when we introduce a new top-level property to any of our top-level state subtrees -- anything of the form state.foo.bar
-- then without doing anything about it in a migration, we get the same effect as if we had a migration to give it the same value that we have for it in the initial state, as specified in the reducer.
A key bit of the background to how it works out that way is that the top-level subtrees state.foo
are the units of what we store with redux-persist
.
I think
dropCache
would work, but only because of the "shallow-merge" quirk
The reason dropCache
works is similar in spirit but I think it's more basic: it's that if a given top-level subtree is missing entirely, then at rehydrate time we just let the initial state (as specified in the reducer) stand untouched.
So in a situation like this, where we've introduced a new property state.foo.bar
on one of the top-level state subtrees state.foo
, the result is that any of:
- no migration
- a
dropCache
migration - a migration that explicitly adds the new property, like in Chris's last comment above
will produce the same, correct results.
It's good to be explicit, as with option 3. On the other hand, it means we duplicate the detail of what the default is -- new installs will get the default from the reducer's initial state, while old installs upgrading will get it from the migration. It also means more boilerplate, in a spot that isn't really type-checked (and is fundamentally tricky to properly type-check) and where getting it wrong could be pretty unpleasant.
So I'm kind of inclined to actually make our practice for this situation be option 1: no migration at all.
However! If we do that, I think it's important to still be explicit about making that choice. That helps us be in the habit of thinking about migrations when updating our types, so that we explicitly think through whether the update is one that falls into this situation and doesn't need a migration. So that could look like adding a comment here, the same place we'd add a migration, that looks like:
// Add `isTopicMandatory` to `state.realm`.
// No migration; just use default from initial state.
Scanning back through previous migrations, I think the only one to date that we'd actually have skipped under this approach is 28, adding state.settings.browser
. All the others either do something more complicated than adding a property, or add a property somewhere deeper in the tree (like in each element of state.accounts
.)
As we add more things like miscellaneous settings, though, this could be something that becomes more common.
Hey @chrisbobbe and @WesleyAC , I've added a summary, Please have a look and let me know what I can do more 🙂✌️. |
I think you haven't force-pushed your new revision yet. 🙂 Also, I see that you've changed the title of this PR. Have you added more commits that make the new title an accurate description? Also, please address Greg's review, too, if you haven't yet. |
5a43248
to
2e08d04
Compare
Hey @chrisbobbe, I've force pushed the commit. Yes, I've changed the title of the PR so that it looks more descriptive, and I've added the suitable commit message. Please have a look and Yes I've to address the Greg's review, I'll do it 🙂👍🏻. |
The prefix should be "the name of the subsystem and a colon", and it looks like that summary line is 97 characters long. Please take another look at the commit-message style guidelines, linked above. Also, if it's helpful, you can read the history of the mobile project to see example commit messages. Also, it looks like the PR is still only setting up some data plumbing. This is totally fine; breaking larger work into smaller chunks is generally a good strategy. 🙂 But the PR's new title, "Confirms that messages have topics, If there is "no topic" don't allow", isn't accurate, because that user-facing behavior isn't implemented in the PR. |
Sorry @chrisbobbe, @WesleyAC and @gnprice for being late, actually I was busy in some personal things, now getting back on track 😊. |
2e08d04
to
24cfc98
Compare
Hey @chrisbobbe sorry for being late again, now I have edited the commit message again and also the PR title and I think the commit is also short. Please have a look and btw I'm addressing the greg review right now 🙂 |
Hey @chrisbobbe, I've read the @gnprice review and at the end as he mentioned to change property name from |
fad95f0
to
69c026f
Compare
Thanks, @thisisnitish! I've made a few small tweaks to your commit, and also added some more commits that
Please take a look! Also, @gnprice or @WesleyAC, this should be ready for review |
69c026f
to
2a87c77
Compare
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.
Thanks @chrisbobbe for the revised and extended version of this! Looks good, with small comments below.
src/reduxTypes.js
Outdated
@@ -266,6 +266,7 @@ export type RealmState = $ReadOnly<{| | |||
twentyFourHourTime: boolean, | |||
canCreateStreams: boolean, | |||
isAdmin: boolean, | |||
mandatoryTopics: boolean, |
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.
nit: this goes in the previous group in this definition:
#4798 (comment)
src/config.js
Outdated
// match | ||
// https://github.com/zulip/zulip/blob/5.0-dev/zerver/lib/actions.py#L2714 | ||
// or similar logic at the latest `main`. | ||
noTopicTopic: '(no topic)', |
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.
Centralizing a single definition for this constant makes a lot of sense!
I don't love "config" as a home for it. It's not really so much about a choice of how to configure the app, as it is about something we expect from the server.
Hmm, I guess in fact that suggests a good place for it would be under src/api/
somewhere -- that's where we generally put facts about how the Zulip server behaves. Usually those are in the form of code to invoke, or type definitions, but a constant like this would fit too.
I think a file like src/api/constants.js
, or src/api/apiConstants.js
, would work well.
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.
Oh, and zulip/zulip#3639 is the issue for the fact that this is hardcoded in the server (and therefore untranslated.)
2a87c77
to
f204d16
Compare
Thanks for the review, @gnprice! Revision pushed. |
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.
Thanks @chrisbobbe! A few comments below, all I think easy to resolve.
(Well, one points at a hairier issue, but I think a TODO comment is a fine solution for this PR.)
src/api/constants.js
Outdated
*/ | ||
// This is hardcoded in the server, and therefore untranslated; that's | ||
// zulip/zulip#3639. | ||
export const NO_TOPIC_TOPIC: '(no topic)' = '(no topic)'; |
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.
I think this is one where the type is best written as string
rather than the specific string: as far as we want the type-checker to be concerned, this is just some string, much like how a timeout duration would be just some number. It's not a string that we want to have participate in determining our types, like the tag strings that we put in the type
property of a Message
or an Action
.
src/api/constants.js
Outdated
/** | ||
* The topic servers understand to mean "there is no topic". This should | ||
* match | ||
* https://github.com/zulip/zulip/blob/5.0-dev/zerver/lib/actions.py#L2714 | ||
* or similar logic at the latest `main`. | ||
*/ |
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.
nit: one-line summary:
/** | |
* The topic servers understand to mean "there is no topic". This should | |
* match | |
* https://github.com/zulip/zulip/blob/5.0-dev/zerver/lib/actions.py#L2714 | |
* or similar logic at the latest `main`. | |
*/ | |
/** | |
* The topic servers understand to mean "there is no topic". | |
* | |
* This should match | |
* https://github.com/zulip/zulip/blob/5.0-dev/zerver/lib/actions.py#L2714 | |
* or similar logic at the latest `main`. | |
*/ |
src/compose/ComposeBox.js
Outdated
handleSend = () => { | ||
const { dispatch } = this.props; | ||
const { dispatch, mandatoryTopics, _ } = this.props; | ||
const { message } = this.state; | ||
const destinationNarrow = this.getDestinationNarrow(); | ||
|
||
if ( | ||
isTopicNarrow(destinationNarrow) | ||
&& topicOfNarrow(destinationNarrow) === apiConstants.NO_TOPIC_TOPIC | ||
&& mandatoryTopics | ||
) { | ||
showErrorAlert(_('Message not sent'), _('Please specify a topic.')); | ||
return; | ||
} | ||
|
||
this.props.onSend(message, destinationNarrow); |
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.
So, this code actually runs not only when you send a message you've just composed in the normal way, but also when you hit save/submit on a message you've just gone and edited. (Maybe that's a sign we should rename this method, and also the onSend
callback -- more like handleSubmit
/ onSubmit
.)
How does this behave in the editing case? Seems like it'll give a confusing error message, if nothing else. What's even the desired semantics in that case -- if a message is already to (no topic)
, and then the setting is enabled, and then you go to edit it, can you edit it without changing its topic?
… I kind of don't want to let those questions block this PR, because that's a pretty rare case (editing is already uncommon, and editing with a target topic of (no topic)
when that's forbidden is surely much more so), and because they seem like questions that may lead to a discussion of what the web app's behavior is and should be. Better to get the main issue fixed here, and leave that for another time.
But let's leave at least a TODO comment, like // TODO how should this behave in the isEditing case?
. That way this at least doesn't much add to the confusingness of this code with respect to its reuse for editing vs. composing: the reader sees it's specifically about sending a message, but the comment warns that doesn't mean the function is only for the sending case.
f204d16
to
ae5945c
Compare
Thanks for the review! Revision pushed. |
We'll use this soon for enforcing the "require topics in stream messages" setting: https://zulip.com/help/require-topics. [chris: rewrote commit message, tweaked/relocated migration comment]
Servers reject topics [1] (and, I would guess, streams too) if they are the empty string OR if they trim to the empty string. So, handle the trimming part. [1] See the comment added in the zulip-mobile commit e221fc1.
And be sure to send '(no topic)' to the server if no topic is provided. Servers reject topics that are the empty string or trim to the empty string; see the comment added in the zulip-mobile commit e221fc1.
(For stream messages, that is, not PMs.) Fixes: zulip#4378
ae5945c
to
2c3a94a
Compare
Thanks! Looks good; merging. |
Thanks for the review! |
Thanks @chrisbobbe and @gnprice!, I was really worried about this issue, finally we did this. Thanks @chrisbobbe again for helping this much. Thanks 😊 |
Fix: Add the relevant property in Files, which fixes the first bullet point of #4378, which tells that messages must have topics.
Fixes: first bullet point of #4378
Changes:
realm_mandatory_topics
toInitialDataTypes
RealmState
isTopicMandatory
to initialState and set to falserealmReducer
[chris: also added commits to use the setting and fix #4378]
Fixes: #4378