Skip to content

outbox: Fix some bugs from Narrow confusion; remove Narrow #4299

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

Merged
merged 14 commits into from
Nov 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/__tests__/lib/exampleData.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,9 @@ const outboxMessageBase: $Diff<Outbox, {| id: mixed, timestamp: mixed |}> = deep

avatar_url: selfUser.avatar_url,
content: '<p>Test.</p>',
display_recipient: 'test',
display_recipient: stream.name,
// id: ...,
markdownContent: 'Test.',
narrow: [{ operator: 'stream', operand: 'test' }],
reactions: [],
sender_email: selfUser.email,
sender_full_name: selfUser.full_name,
Expand Down
10 changes: 6 additions & 4 deletions src/chat/__tests__/narrowsSelectors-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ describe('getMessagesForNarrow', () => {
// them to be numbers.
[123]: message /* eslint-disable-line no-useless-computed-key */,
};
const outboxMessage = eg.makeOutboxMessage({
narrow: HOME_NARROW,
});
const outboxMessage = eg.makeOutboxMessage({});

test('if no outbox messages returns messages with no change', () => {
const state = eg.reduxState({
Expand All @@ -36,6 +34,7 @@ describe('getMessagesForNarrow', () => {
},
messages,
outbox: [],
realm: eg.realmState({ email: eg.selfUser.email }),
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like this is setting realm.email in the state because that value gets passed directly as ownEmail to isMessageInNarrow, having been selected by getOwnEmail (that getOwnEmail use having been added in this same commit).

I do notice that getOwnEmail has this in its JSDoc:

 * Prefer using `getOwnUserId` or `getOwnUser`; see #3764.

(#3764)

It looks like getOwnUser itself uses getOwnEmail, so it's not obviously better, I guess.

But I could imagine that being changed someday; I could see getOwnUser instead using, e.g., getOwnUserId (there's even a comment on getOwnUserId suggesting to do so) and getUserForId.

If such a change were made, it would no longer be useful to just set state.realm.email; we'd want to set state.realm.user_id. Possibly that's totally fine, and a bridge we'll happily cross when we get to it. But I wondered if we might like to consider providing a simple way to grab state.realm from the example data such that it contains all the stuff that's specific to the self-user: there's email, user_id, probably more.

Copy link
Member Author

Choose a reason for hiding this comment

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

But I wondered if we might like to consider providing a simple way to grab state.realm from the example data such that it contains all the stuff that's specific to the self-user: there's email, user_id, probably more.

Yeah, I agree -- that would be useful, and we'll want to add something like that. A more "batteries included" example Redux state, one might call it.

The reason I haven't yet gone and done that is that there are some API choices to be made about it:

  • Probably it includes the self-user's email and related properties, as given by eg.selfUser, in state.realm.
  • Probably it also includes eg.selfUser in state.users, and a corresponding account (with eg.realm) in state.accounts.
  • Does it include eg.otherUser in state.users? Also eg.thirdUser? eg.crossRealmBot?
  • There's eg.stream and eg.subscription. Does it include those in state.streams and state.subscriptions?
  • Maybe there's even several different levels of "batteries included" that we want? In addition to probably still the initial state, the thing that's now eg.reduxState.
  • What about when we add new example values along those lines, like the recent thirdUser?
    • If they're included, that potentially changes the behavior of existing tests. If it makes them fail, that's one thing; more insidiously, it could potentially make them pass trivially, even if the code they're meant to test stops behaving the way they're meant to check that it does.
    • If they're left out, then the "batteries included" state comes to include only some batteries, and potentially a less and less predictable subset of them.
    • Probably the right answer is to include new example values, and make a practice of systematically writing tests so that they don't depend on any assumptions that the state doesn't have miscellaneous other data they weren't expecting. That is, if a test makes its own eg.makeStream() call, it can assume that stream isn't in the example state; but it can't make an assumption like "there's only one stream in the state".
      • This requires a bit of work finding a clear pattern to prescribe for how to systematically do that.

This diff (as you saw, there are a good handful of lines like this one) definitely made me think about adding this. 🙂 Not enough that I felt I wanted to do it before shipping this branch, but it moved that feeling closer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Not enough that I felt I wanted to do it before shipping this branch, but it moved that feeling closer.

Makes sense, that's fine! So I'd say you can merge this branch at will.

});

const result = getMessagesForNarrow(state, HOME_NARROW);
Expand All @@ -53,6 +52,7 @@ describe('getMessagesForNarrow', () => {
caughtUp: {
[HOME_NARROW_STR]: { older: false, newer: true },
},
realm: eg.realmState({ email: eg.selfUser.email }),
});

const result = getMessagesForNarrow(state, HOME_NARROW);
Expand All @@ -67,6 +67,7 @@ describe('getMessagesForNarrow', () => {
},
messages,
outbox: [outboxMessage],
realm: eg.realmState({ email: eg.selfUser.email }),
});

const result = getMessagesForNarrow(state, HOME_NARROW);
Expand All @@ -80,7 +81,8 @@ describe('getMessagesForNarrow', () => {
[JSON.stringify(privateNarrow('[email protected]'))]: [123],
},
messages,
outbox: [{ ...outboxMessage, narrow: streamNarrow('denmark') }],
outbox: [outboxMessage],
realm: eg.realmState({ email: eg.selfUser.email }),
});

const result = getMessagesForNarrow(state, privateNarrow('[email protected]'));
Expand Down
6 changes: 5 additions & 1 deletion src/chat/narrowsReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ const eventNewMessage = (state, action) => {
let stateChange = false;
const newState: NarrowsState = {};
Object.keys(state).forEach(key => {
const isInNarrow = isMessageInNarrow(action.message, JSON.parse(key), action.ownEmail);
const { flags } = action.message;
if (!flags) {
throw new Error('EVENT_NEW_MESSAGE message missing flags');
}
const isInNarrow = isMessageInNarrow(action.message, flags, JSON.parse(key), action.ownEmail);
const isCaughtUp = action.caughtUp[key] && action.caughtUp[key].newer;
const messageDoesNotExist = state[key].find(id => action.message.id === id) === undefined;

Expand Down
19 changes: 15 additions & 4 deletions src/chat/narrowsSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import {
getOutbox,
} from '../directSelectors';
import { getCaughtUpForNarrow } from '../caughtup/caughtUpSelectors';
import { getAllUsersByEmail } from '../users/userSelectors';
import { getAllUsersByEmail, getOwnEmail } from '../users/userSelectors';
import {
isPrivateNarrow,
isStreamOrTopicNarrow,
emailsOfGroupNarrow,
narrowContains,
isMessageInNarrow,
} from '../utils/narrow';
import { shouldBeMuted } from '../utils/message';
import { NULL_ARRAY, NULL_SUBSCRIPTION } from '../nullObjects';
Expand All @@ -35,11 +35,22 @@ export const outboxMessagesForNarrow: Selector<Outbox[], Narrow> = createSelecto
(state, narrow) => narrow,
getCaughtUpForNarrow,
state => getOutbox(state),
(narrow, caughtUp, outboxMessages) => {
getOwnEmail,
(narrow, caughtUp, outboxMessages, ownEmail) => {
if (!caughtUp.newer) {
return NULL_ARRAY;
}
const filtered = outboxMessages.filter(item => narrowContains(narrow, item.narrow));
// TODO?: Handle @-mention flags in outbox messages. As is, if you
// @-mention yourself (or a wildcard) and then go look at the
// is:mentioned view while your message is still unsent, we wrongly
// leave it out. Pretty uncommon edge case, though.
//
// No other narrows rely on flags except the "starred" narrow. Outbox
// messages can't be starred, so "no flags" gives that the right answer.
const fakeFlags = [];
const filtered = outboxMessages.filter(message =>
isMessageInNarrow(message, fakeFlags, narrow, ownEmail),
);
return isEqual(filtered, outboxMessages) ? outboxMessages : filtered;
},
);
Expand Down
2 changes: 1 addition & 1 deletion src/events/doEventActionSideEffects.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const messageEvent = (state: GlobalState, message: Message): void => {
activeAccount
&& narrow !== undefined // chat screen is not at top
&& !isHomeNarrow(narrow)
&& isMessageInNarrow(message, narrow, activeAccount.email);
&& isMessageInNarrow(message, flags, narrow, activeAccount.email);
const isSenderSelf = getOwnEmail(state) === message.sender_email;
if (!isUserInSameNarrow && !isSenderSelf) {
playMessageSound();
Expand Down
95 changes: 54 additions & 41 deletions src/outbox/outboxActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
NamedUser,
Narrow,
Outbox,
User,
UserOrBot,
Action,
} from '../types';
import {
Expand All @@ -20,11 +20,10 @@ import {
} from '../actionConstants';
import { getAuth } from '../selectors';
import * as api from '../api';
import { getSelfUserDetail, getUsersByEmail } from '../users/userSelectors';
import { getAllUsersByEmail, getOwnUser } from '../users/userSelectors';
import { getUsersAndWildcards } from '../users/userHelpers';
import { isStreamNarrow, isPrivateOrGroupNarrow } from '../utils/narrow';
import { caseNarrowPartial } from '../utils/narrow';
import { BackoffMachine } from '../utils/async';
import { NULL_USER } from '../nullObjects';

export const messageSendStart = (outbox: Outbox): Action => ({
type: MESSAGE_SEND_START,
Expand Down Expand Up @@ -64,17 +63,16 @@ export const trySendMessages = (dispatch: Dispatch, getState: GetState): boolean
return; // i.e., continue
}

const to = ((): string => {
const { narrow } = item;
// TODO: can this test be `if (item.type === private)`?
if (isPrivateOrGroupNarrow(narrow)) {
return narrow[0].operand;
} else {
// HACK: the server attempts to interpret this argument as JSON, then
// CSV, then a literal. To avoid misparsing, always use JSON.
return JSON.stringify([item.display_recipient]);
}
})();
// prettier-ignore
const to =
item.type === 'private'
// TODO(server-2.0): switch to numeric user IDs, not emails.
? item.display_recipient.map(r => r.email).join(',')
// TODO(server-2.0): switch to numeric stream IDs, not names.
// (This will require wiring the stream ID through to here.)
// HACK: the server attempts to interpret this argument as JSON, then
// CSV, then a literal. To avoid misparsing, always use JSON.
: JSON.stringify([item.display_recipient]);

await api.sendMessage(auth, {
type: item.type,
Expand Down Expand Up @@ -106,14 +104,21 @@ export const sendOutbox = () => async (dispatch: Dispatch, getState: GetState) =
dispatch(toggleOutboxSending(false));
};

const mapEmailsToUsers = (usersByEmail, narrow, selfDetail) =>
narrow[0].operand
.split(',')
.map(item => {
const user = usersByEmail.get(item) || NULL_USER;
return { email: item, id: user.user_id, full_name: user.full_name };
})
.concat({ email: selfDetail.email, id: selfDetail.user_id, full_name: selfDetail.full_name });
// A valid display_recipient with all the thread's users, sorted by ID.
const mapEmailsToUsers = (emails, allUsersByEmail, ownUser) => {
const result = emails.map(email => {
const user = allUsersByEmail.get(email);
if (!user) {
throw new Error('outbox: missing user when preparing to send PM');
}
return { email, id: user.user_id, full_name: user.full_name };
});
if (!result.some(r => r.id === ownUser.user_id)) {
result.push({ email: ownUser.email, id: ownUser.user_id, full_name: ownUser.full_name });
}
result.sort((r1, r2) => r1.id - r2.id);
return result;
};

// TODO type: `string | NamedUser[]` is a bit confusing.
type DataFromNarrow = {|
Expand All @@ -124,19 +129,28 @@ type DataFromNarrow = {|

const extractTypeToAndSubjectFromNarrow = (
narrow: Narrow,
usersByEmail: Map<string, User>,
selfDetail: { email: string, user_id: number, full_name: string },
allUsersByEmail: Map<string, UserOrBot>,
ownUser: UserOrBot,
): DataFromNarrow => {
if (isPrivateOrGroupNarrow(narrow)) {
return {
type: 'private',
display_recipient: mapEmailsToUsers(usersByEmail, narrow, selfDetail),
subject: '',
};
} else if (isStreamNarrow(narrow)) {
return { type: 'stream', display_recipient: narrow[0].operand, subject: '(no topic)' };
}
return { type: 'stream', display_recipient: narrow[0].operand, subject: narrow[1].operand };
const forPm = emails => ({
type: 'private',
display_recipient: mapEmailsToUsers(emails, allUsersByEmail, ownUser),
subject: '',
});
return caseNarrowPartial(narrow, {
pm: email => forPm([email]),
groupPm: forPm,

// TODO: we shouldn't ever be passing a whole-stream narrow here;
// ensure we don't, then remove this case
stream: name => ({ type: 'stream', display_recipient: name, subject: '(no topic)' }),

topic: (streamName, topic) => ({
type: 'stream',
display_recipient: streamName,
subject: topic,
}),
});
};

const getContentPreview = (content: string, state: GlobalState): string => {
Expand All @@ -159,21 +173,20 @@ export const addToOutbox = (narrow: Narrow, content: string) => async (
getState: GetState,
) => {
const state = getState();
const userDetail = getSelfUserDetail(state);
const ownUser = getOwnUser(state);

const localTime = Math.round(new Date().getTime() / 1000);
dispatch(
messageSendStart({
narrow,
isSent: false,
...extractTypeToAndSubjectFromNarrow(narrow, getUsersByEmail(state), userDetail),
...extractTypeToAndSubjectFromNarrow(narrow, getAllUsersByEmail(state), ownUser),
markdownContent: content,
content: getContentPreview(content, state),
timestamp: localTime,
id: localTime,
sender_full_name: userDetail.full_name,
sender_email: userDetail.email,
avatar_url: userDetail.avatar_url,
sender_full_name: ownUser.full_name,
sender_email: ownUser.email,
avatar_url: ownUser.avatar_url,
isOutbox: true,
reactions: [],
}),
Expand Down
2 changes: 2 additions & 0 deletions src/topics/__tests__/topicsSelectors-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('getLastMessageTopic', () => {
test('when no messages in narrow return an empty string', () => {
const state = eg.reduxState({
narrows: {},
realm: eg.realmState({ email: eg.selfUser.email }),
});

const topic = getLastMessageTopic(state, HOME_NARROW);
Expand All @@ -50,6 +51,7 @@ describe('getLastMessageTopic', () => {
[message1.id]: message1,
[message2.id]: message2,
},
realm: eg.realmState({ email: eg.selfUser.email }),
});

const topic = getLastMessageTopic(state, narrow);
Expand Down
7 changes: 3 additions & 4 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { IntlShape } from 'react-intl';
import type { DangerouslyImpreciseStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet';

import type { Auth, Topic, Message, Reaction, ReactionType, Narrow } from './api/apiTypes';
import type { Auth, Topic, Message, Reaction, ReactionType } from './api/apiTypes';
import type { ZulipVersion } from './utils/zulipVersion';

export type * from './generics';
Expand Down Expand Up @@ -171,10 +171,9 @@ export type Outbox = {|
*/
isSent: boolean,

// These fields don't exist in `Message`.
// They're used for sending the message to the server.
// `markdownContent` doesn't exist in `Message`.
// It's used for sending the message to the server.
markdownContent: string,
narrow: Narrow,

// These fields are modeled on `Message`.
avatar_url: string | null,
Expand Down
9 changes: 1 addition & 8 deletions src/utils/__tests__/narrow-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,19 +289,12 @@ describe('isMessageInNarrow', () => {
for (const [messageDescription, expected, message] of cases) {
test(`${expected ? 'contains' : 'excludes'} ${messageDescription}`, () => {
expect(
isMessageInNarrow({ ...message, flags: message.flags ?? [] }, narrow, ownEmail),
isMessageInNarrow(message, message.flags ?? [], narrow, ownEmail),
).toBe(expected);
});
}
});
}

test('message with flags absent throws an error', () => {
const message = eg.streamMessage({
// no flags
});
expect(() => isMessageInNarrow(message, MENTIONED_NARROW, ownEmail)).toThrow();
});
});

describe('getNarrowFromMessage', () => {
Expand Down
38 changes: 17 additions & 21 deletions src/utils/narrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,23 @@ export const isStreamOrTopicNarrow = (narrow?: Narrow): boolean =>
export const isSearchNarrow = (narrow?: Narrow): boolean =>
!!narrow && caseNarrowDefault(narrow, { search: () => true }, () => false);

/** (For search narrows, just returns false.) */
export const isMessageInNarrow = (message: Message, narrow: Narrow, ownEmail: string): boolean => {
/**
* True just if the given message is part of the given narrow.
*
* This function does not support search narrows, and for them always
* returns false.
*
* The message's flags must be in `flags`; `message.flags` is ignored. This
* makes it the caller's responsibility to deal with the ambiguity in our
* Message type of whether the message's flags live in a `flags` property or
* somewhere else.
*/
export const isMessageInNarrow = (
message: Message | Outbox,
flags: $ReadOnlyArray<string>,
narrow: Narrow,
ownEmail: string,
): boolean => {
const matchPmRecipients = (emails: string[]) => {
if (message.type !== 'private') {
return false;
Expand All @@ -262,11 +277,6 @@ export const isMessageInNarrow = (message: Message, narrow: Narrow, ownEmail: st
);
};

const { flags } = message;
if (!flags) {
throw new Error('`message.flags` should be defined.');
}

return caseNarrow(narrow, {
home: () => true,
stream: name => name === message.display_recipient,
Expand Down Expand Up @@ -294,20 +304,6 @@ export const canSendToNarrow = (narrow: Narrow): boolean =>
search: () => false,
});

/** True just if `haystack` contains all possible messages in `needle`. */
export const narrowContains = (haystack: Narrow, needle: Narrow): boolean => {
if (isHomeNarrow(haystack)) {
return true;
}
if (isAllPrivateNarrow(haystack) && isPrivateOrGroupNarrow(needle)) {
return true;
}
if (isStreamNarrow(haystack) && needle[0].operand === haystack[0].operand) {
return true;
}
return JSON.stringify(needle) === JSON.stringify(haystack);
};

export const getNarrowFromMessage = (message: Message | Outbox, ownEmail: string) => {
if (Array.isArray(message.display_recipient)) {
const recipient =
Expand Down