Skip to content
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
31 changes: 13 additions & 18 deletions src/boot/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import ZulipAsyncStorage from './ZulipAsyncStorage';
import createMigration from '../redux-persist-migrate/index';
import { provideLoggingContext } from './loggingContext';
import { tryGetActiveAccount } from '../account/accountsSelectors';
import { keyFromNarrow } from '../utils/narrow';
import { objectFromEntries } from '../jsBackport';

if (process.env.NODE_ENV === 'development') {
Expand Down Expand Up @@ -226,29 +225,25 @@ const migrations: { [string]: (GlobalState) => GlobalState } = {
// `AvatarURL`.
'18': dropCache,

// Change format of keys representing narrows, from JSON to our format.
'19': state => ({
// Change format of keys representing narrows: from JSON to our format,
// then for PM narrows adding user IDs.
'21': state => ({
...dropCache(state),
drafts: objectFromEntries(
// Note this will migrate straight to our current format, even after
// that changes from when this migration was written! That saves us
// from duplicating `keyFromNarrow` here... but calls for care in
// migrations for future changes to `keyFromNarrow`.
Object.keys(state.drafts).map(key => [keyFromNarrow(JSON.parse(key)), state.drafts[key]]),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like keyFromNarrow and objectFromEntries need their imports removed (but then don't forget to bring back objectFromEntries's for migration 22 🙂).

),
// The old format was a rather hairy format that we don't want to
// permanently keep around the code to parse. For PMs, there's an
// extra wrinkle in that any conversion would require using additional
// information to look up the IDs. Drafts are inherently short-term,
// and are already discarded whenever switching between accounts;
// so we just drop them here.
drafts: {},
}),

// Change format of keys representing PM narrows, adding user IDs.
'20': state => ({
// Change format of keys representing PM narrows, dropping emails.
'22': state => ({
...dropCache(state),
drafts: objectFromEntries(
Object.keys(state.drafts)
// Just drop any old-style, email-only PM keys. Converting them
// would require using additional information to look up the IDs,
// which would make this more complex than any of our other
// migrations. Drafts are inherently short-term, and are already
// discarded whenever switching between accounts.
.filter(key => !key.startsWith('pm:s:'))
.map(key => key.replace(/^pm:d:(.*?):.*/s, 'pm:$1'))
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Jan 5, 2021

Choose a reason for hiding this comment

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

Good, I think '21' is safe from the kinds of potential issues mentioned in '19':

      // Note this will migrate straight to our current format, even after
      // that changes from when this migration was written!  That saves us
      // from duplicating `keyFromNarrow` here... but calls for care in
      // migrations for future changes to `keyFromNarrow`.

In particular:

  • If ['21'] is the sequence of migrations that run at startup, drafts in '21''s input will be in the pm:d: format, which this string replacement handles correctly.
  • If ['20', '21'] is the sequence of migrations that run at startup, drafts in '21''s input will be...empty. (Drafts in '20''s input would have all had pm:s: keys, and '20' would have removed them all.) Drafts being empty in '21''s input is fine; it just gives an empty output.
  • If [(...,) '19', '20', '21'] is the sequence of migrations that run at startup, drafts in '21''s input will have keys that are already in the up-to-date pm: format (because of the quirk with '19'; then, following that, because '20' didn't filter out the pm: format). This is fine; the .replace in '21' neither throws an error with that input nor changes it at all.

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.

Thanks for checking this!

  • If ['20', '21'] is the sequence of migrations that run at startup, drafts in '21''s input will be...empty.

(It'll have no PMs, that is -- it'll still have drafts for stream messages.)

  • If [(...,) '19', '20', '21'] is the sequence of migrations that run at startup, drafts in '21''s input will have keys that are already in the up-to-date pm: format

Hmm. I think there's actually a bug here, though! In migration 19:

      Object.keys(state.drafts).map(key => [keyFromNarrow(JSON.parse(key)), state.drafts[key]]),

the JSON.parse for a PM narrow will return something like { operator: 'pm-with', operand: 'foo@example.com,bar@example.org' }. In fact this is really two bugs:

  • That's the old shape of Narrow, which keyFromNarrow no longer accepts.
  • That has emails and no user IDs. So there's nothing keyFromNarrow could possibly do to convert it to the new format.

The bug was introduced with #4346, where Narrow changed and so the type expected by keyFromNarrow changed. The reason Flow didn't catch that there was this hole in the encapsulation is that JSON.parse there.

This probably means that my plan in #4339 (at #4339 (comment) ) wasn't the right one in retrospect. It would have been better to simply drop the drafts, or else bite the bullet and duplicate keyFromNarrow.

Happily no general users can have been affected at all, because we haven't made a release since this was introduced.

I think I'll fix this by rolling up migrations 19 and 20 into a new migration 21, which just drops drafts entirely (plus dropCache). That'll have the same effect in practice as if I'd gone with a "drop drafts" plan in #4339 from the beginning -- each user will have drafts dropped once. And the current migration 20 is already meant to drop all PM drafts, so only stream drafts are newly affected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Oh OK, great—I'm glad you found this! 🙂

I think I'll fix this by rolling up migrations 19 and 20 into a new migration 21

SGTM.

.map(key => [key, state.drafts[key]]),
),
}),
Expand Down
9 changes: 3 additions & 6 deletions src/chat/narrowsSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
getOutbox,
} from '../directSelectors';
import { getCaughtUpForNarrow } from '../caughtup/caughtUpSelectors';
import { getAllUsersByEmail, getAllUsersById, getOwnUser } from '../users/userSelectors';
import { getAllUsersById, getOwnUser } from '../users/userSelectors';
import {
isStreamOrTopicNarrow,
isMessageInNarrow,
Expand Down Expand Up @@ -134,17 +134,14 @@ export const getStreamInNarrow: Selector<Subscription | {| ...Stream, in_home_vi
export const isNarrowValid: Selector<boolean, Narrow> = createSelector(
(state, narrow) => narrow,
state => getStreams(state),
state => getAllUsersByEmail(state),
state => getAllUsersById(state),
(narrow, streams, allUsersByEmail, allUsersById) =>
(narrow, streams, allUsersById) =>
caseNarrowDefault(
narrow,
{
stream: streamName => streams.find(s => s.name === streamName) !== undefined,
topic: streamName => streams.find(s => s.name === streamName) !== undefined,
pm: (emails, ids) =>
emails.every(email => allUsersByEmail.get(email) !== undefined)
&& ids.every(id => allUsersById.get(id) !== undefined),
pm: ids => ids.every(id => allUsersById.get(id) !== undefined),
},
() => true,
),
Expand Down
2 changes: 1 addition & 1 deletion src/compose/getComposeInputPlaceholder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default (
caseNarrowDefault(
narrow,
{
pm: (emails, ids) => {
pm: ids => {
if (ids.length > 1) {
return { text: 'Message group' };
}
Expand Down
5 changes: 3 additions & 2 deletions src/message/fetchActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { realmInit } from '../realm/realmActions';
import { startEventPolling } from '../events/eventActions';
import { logout } from '../account/accountActions';
import { ZulipVersion } from '../utils/zulipVersion';
import { getAllUsersById } from '../users/userSelectors';

const messageFetchStart = (narrow: Narrow, numBefore: number, numAfter: number): Action => ({
type: MESSAGE_FETCH_START,
Expand Down Expand Up @@ -88,7 +89,7 @@ export const fetchMessages = (fetchArgs: {|
try {
const { messages, found_newest, found_oldest } = await api.getMessages(getAuth(getState()), {
...fetchArgs,
narrow: apiNarrowOfNarrow(fetchArgs.narrow),
narrow: apiNarrowOfNarrow(fetchArgs.narrow, getAllUsersById(getState())),
useFirstUnread: fetchArgs.anchor === FIRST_UNREAD_ANCHOR, // TODO: don't use this; see #4203
});
dispatch(
Expand Down Expand Up @@ -236,7 +237,7 @@ export const fetchMessagesInNarrow = (
const fetchPrivateMessages = () => async (dispatch: Dispatch, getState: GetState) => {
const auth = getAuth(getState());
const { messages, found_newest, found_oldest } = await api.getMessages(auth, {
narrow: apiNarrowOfNarrow(ALL_PRIVATE_NARROW),
narrow: apiNarrowOfNarrow(ALL_PRIVATE_NARROW, getAllUsersById(getState())),
anchor: LAST_MESSAGE_ANCHOR,
numBefore: 100,
numAfter: 0,
Expand Down
2 changes: 1 addition & 1 deletion src/outbox/outboxActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const extractTypeToAndSubjectFromNarrow = (
ownUser: UserOrBot,
): DataFromNarrow =>
caseNarrowPartial(narrow, {
pm: (emails, ids) => ({
pm: ids => ({
type: 'private',
display_recipient: recipientsFromIds(ids, allUsersById, ownUser),
subject: '',
Expand Down
2 changes: 1 addition & 1 deletion src/title-buttons/titleButtonFromNarrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const InfoButton: ComponentType<Props> = props =>
{
stream: streamName => <InfoNavButtonStream {...props} />,
topic: (streamName, topic) => <InfoNavButtonStream {...props} />,
pm: (emails, ids) =>
pm: ids =>
ids.length === 1 ? (
<InfoNavButtonPrivate userId={ids[0]} color={props.color} />
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/title/Title.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default class Title extends PureComponent<Props> {
allPrivate: () => <TitleSpecial code="private" color={color} />,
stream: () => <TitleStream narrow={narrow} color={color} />,
topic: () => <TitleStream narrow={narrow} color={color} />,
pm: (emails, ids) =>
pm: ids =>
ids.length === 1 ? (
<TitlePrivate userId={ids[0]} color={color} />
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/unread/unreadSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const getUnreadCountForNarrow: Selector<number, Narrow> = createSelector(
);
},

pm: (emails, ids) => {
pm: ids => {
if (ids.length > 1) {
const unreadsKey = pmUnreadsKeyFromPmKeyIds(ids, ownUserId);
const unread = unreadHuddles.find(x => x.user_ids_string === unreadsKey);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/__tests__/narrow-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('pm1to1NarrowFromUser', () => {
test('produces a 1:1 narrow', () => {
const narrow = pm1to1NarrowFromUser(eg.otherUser);
expect(is1to1PmNarrow(narrow)).toBeTrue();
expect(caseNarrowPartial(narrow, { pm: (emails, ids) => ids })).toEqual([eg.otherUser.user_id]);
expect(caseNarrowPartial(narrow, { pm: ids => ids })).toEqual([eg.otherUser.user_id]);
});

test('if operator is "pm-with" and only one email, then it is a private narrow', () => {
Expand Down
64 changes: 34 additions & 30 deletions src/utils/narrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
export opaque type Narrow =
| {| type: 'stream', streamName: string |}
| {| type: 'topic', streamName: string, topic: string |}
| {| type: 'pm', userIds: $ReadOnlyArray<number>, emails: $ReadOnlyArray<string> |}
| {| type: 'pm', userIds: $ReadOnlyArray<number> |}
| {| type: 'search', query: string |}
| {| type: 'all' | 'starred' | 'mentioned' | 'all-pm' |};

Expand All @@ -59,8 +59,8 @@ export const HOME_NARROW_STR: string = keyFromNarrow(HOME_NARROW);
* accidentally disagreeing on whether to include the self-user, or on how
* to sort the list (by user ID vs. email), or neglecting to sort it at all.
*/
const pmNarrowInternal = (userIds: $ReadOnlyArray<number>, emails: string[]): Narrow =>
Object.freeze({ type: 'pm', userIds, emails });
const pmNarrowInternal = (userIds: $ReadOnlyArray<number>): Narrow =>
Object.freeze({ type: 'pm', userIds });

/**
* A PM narrow, either 1:1 or group.
Expand All @@ -73,7 +73,7 @@ const pmNarrowInternal = (userIds: $ReadOnlyArray<number>, emails: string[]): Na
* different form of input.
*/
export const pmNarrowFromRecipients = (recipients: PmKeyRecipients): Narrow =>
pmNarrowInternal(recipients.map(r => r.id), recipients.map(r => r.email));
pmNarrowInternal(recipients);

/**
* A PM narrow, either 1:1 or group.
Expand All @@ -85,7 +85,7 @@ export const pmNarrowFromRecipients = (recipients: PmKeyRecipients): Narrow =>
* single specific user.
*/
export const pmNarrowFromUsers = (recipients: PmKeyUsers): Narrow =>
pmNarrowInternal(recipients.map(r => r.user_id), recipients.map(r => r.email));
pmNarrowInternal(recipients.map(r => r.user_id));

/**
* FOR TESTS ONLY. Like pmNarrowFromUsers, but without validation.
Expand All @@ -105,7 +105,7 @@ export const pmNarrowFromUsers = (recipients: PmKeyUsers): Narrow =>
// annoying thing is just that that requires an ownUserId value.
export const pmNarrowFromUsersUnsafe = (recipients: UserOrBot[]): Narrow => {
recipients.sort((a, b) => a.user_id - b.user_id);
return pmNarrowInternal(recipients.map(r => r.user_id), recipients.map(r => r.email));
return pmNarrowInternal(recipients.map(r => r.user_id));
};

/**
Expand All @@ -115,8 +115,7 @@ export const pmNarrowFromUsersUnsafe = (recipients: UserOrBot[]): Narrow => {
* statically has just one other user it's a bit more convenient because it
* doesn't require going through our `recipient` helpers.
*/
export const pm1to1NarrowFromUser = (user: UserOrBot): Narrow =>
pmNarrowInternal([user.user_id], [user.email]);
export const pm1to1NarrowFromUser = (user: UserOrBot): Narrow => pmNarrowInternal([user.user_id]);

export const specialNarrow = (operand: string): Narrow => {
if (operand === 'starred') {
Expand Down Expand Up @@ -153,7 +152,7 @@ export const SEARCH_NARROW = (query: string): Narrow => Object.freeze({ type: 's

type NarrowCases<T> = {|
home: () => T,
pm: (emails: $ReadOnlyArray<string>, ids: $ReadOnlyArray<number>) => T,
pm: (ids: $ReadOnlyArray<number>) => T,
starred: () => T,
mentioned: () => T,
allPrivate: () => T,
Expand All @@ -171,7 +170,7 @@ export function caseNarrow<T>(narrow: Narrow, cases: NarrowCases<T>): T {
switch (narrow.type) {
case 'stream': return cases.stream(narrow.streamName);
case 'topic': return cases.topic(narrow.streamName, narrow.topic);
case 'pm': return cases.pm(narrow.emails, narrow.userIds);
case 'pm': return cases.pm(narrow.userIds);
case 'search': return cases.search(narrow.query);
case 'all': return cases.home();
case 'starred': return cases.starred();
Expand Down Expand Up @@ -237,8 +236,7 @@ export function caseNarrowDefault<T>(
* something like `base64Utf8Encode` to encode into the permitted subset.
*/
// The arbitrary Unicode codepoints come from (a) stream names and topics,
// and (b) our use of `\x00` as a delimiter. Also perhaps email addresses,
// if it's possible for those to have exciting characters in them.
// and (b) our use of `\x00` as a delimiter.
export function keyFromNarrow(narrow: Narrow): string {
// The ":s" (for "string") in several of these is to keep them disjoint,
// out of an abundance of caution, from future keys that use numeric IDs.
Expand All @@ -254,7 +252,8 @@ export function keyFromNarrow(narrow: Narrow): string {
topic: (streamName, topic) => `topic:s:${streamName}\x00${topic}`,

// An earlier version had `pm:s:`.
pm: (emails, ids) => `pm:d:${ids.join(',')}:${emails.join(',')}`,
// Another had `pm:d:`.
pm: ids => `pm:${ids.join(',')}`,

home: () => 'all',
starred: () => 'starred',
Expand Down Expand Up @@ -295,17 +294,8 @@ export const parseNarrow = (narrowStr: string): Narrow => {
}

case 'pm:': {
// The `/s` regexp flag means the `.` patterns match absolutely
// anything. By default they reject certain "newline" characters,
// which might be impossible in email addresses but it's simplest
// not to have to care.
const match = /^d:(.*?):(.*)/s.exec(rest);
if (!match) {
throw makeError();
}
const ids = match[1].split(',').map(s => Number.parseInt(s, 10));
const emails = match[2].split(',');
return pmNarrowInternal(ids, emails);
const ids = rest.split(',').map(s => Number.parseInt(s, 10));
return pmNarrowInternal(ids);
}

case 'search:': {
Expand Down Expand Up @@ -336,10 +326,10 @@ export const isHomeNarrow = (narrow?: Narrow): boolean =>
!!narrow && caseNarrowDefault(narrow, { home: () => true }, () => false);

export const is1to1PmNarrow = (narrow?: Narrow): boolean =>
!!narrow && caseNarrowDefault(narrow, { pm: (emails, ids) => ids.length === 1 }, () => false);
!!narrow && caseNarrowDefault(narrow, { pm: ids => ids.length === 1 }, () => false);

export const isGroupPmNarrow = (narrow?: Narrow): boolean =>
!!narrow && caseNarrowDefault(narrow, { pm: (emails, ids) => ids.length > 1 }, () => false);
!!narrow && caseNarrowDefault(narrow, { pm: ids => ids.length > 1 }, () => false);

/**
* The "PM key recipients" IDs for a PM narrow; else error.
Expand All @@ -348,7 +338,7 @@ export const isGroupPmNarrow = (narrow?: Narrow): boolean =>
* `PmKeyUsers`, but contains only their user IDs.
*/
export const userIdsOfPmNarrow = (narrow: Narrow): $ReadOnlyArray<number> =>
caseNarrowPartial(narrow, { pm: (emails, ids) => ids });
caseNarrowPartial(narrow, { pm: ids => ids });

/**
* The stream name for a stream or topic narrow; else error.
Expand Down Expand Up @@ -399,14 +389,28 @@ export const isSearchNarrow = (narrow?: Narrow): boolean =>
/**
* Convert the narrow into the form used in the Zulip API at get-messages.
*/
export const apiNarrowOfNarrow = (narrow: Narrow): ApiNarrow =>
export const apiNarrowOfNarrow = (
narrow: Narrow,
allUsersById: Map<number, UserOrBot>,
): ApiNarrow =>
caseNarrow(narrow, {
stream: streamName => [{ operator: 'stream', operand: streamName }],
topic: (streamName, topic) => [
{ operator: 'stream', operand: streamName },
{ operator: 'topic', operand: topic },
],
pm: emails => [{ operator: 'pm-with', operand: emails.join(',') }],
pm: ids => {
const emails = [];
for (const id of ids) {
const email = allUsersById.get(id)?.email;
if (email === undefined) {
throw new Error('apiNarrowOfNarrow: missing user');
}
emails.push(email);
}
// TODO(server-2.1): just send IDs instead
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Jan 5, 2021

Choose a reason for hiding this comment

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

If it makes a big difference to the amount of work the server has to do (and if that's important to minimize), we could check the stored server version in Redux and courteously send IDs to the newer servers, while still sending emails to older ones. But it might not make much of a difference, and we may be dropping support for those older server versions soon-ish anyway. 🙂

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 don't think it materially affects the work the server has to do. It'll want to look up the users in the database in any event (for one thing, to make sure they're valid users that are in your realm); and the database will have indexes so that looking up by ID or by email are both fast, because those are both common operations.

return [{ operator: 'pm-with', operand: emails.join(',') }];
},
search: query => [{ operator: 'search', operand: query }],
home: () => [],
starred: () => [{ operator: 'is', operand: 'starred' }],
Expand Down Expand Up @@ -441,7 +445,7 @@ export const isMessageInNarrow = (
message.type === 'stream'
&& streamName === streamNameOfStreamMessage(message)
&& topic === message.subject,
pm: (emails, ids) => {
pm: ids => {
if (message.type !== 'private') {
return false;
}
Expand Down
7 changes: 5 additions & 2 deletions src/utils/recipient.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const recipientsOfPrivateMessage = (
*
* See also `pmNarrowFromRecipients`, which requires a value of this type.
*/
export opaque type PmKeyRecipients: $ReadOnlyArray<PmRecipientUser> = $ReadOnlyArray<PmRecipientUser>;
export opaque type PmKeyRecipients: $ReadOnlyArray<number> = $ReadOnlyArray<number>;

/**
* A list of users identifying a PM conversation, as per pmKeyRecipientsFromMessage.
Expand Down Expand Up @@ -180,7 +180,10 @@ export const pmKeyRecipientsFromMessage = (
if (message.type !== 'private') {
throw new Error('pmKeyRecipientsFromMessage: expected PM, got stream message');
}
return filterRecipients(recipientsOfPrivateMessage(message), ownUser.user_id);
return filterRecipientsAsUserIds(
recipientsOfPrivateMessage(message).map(r => r.id),
ownUser.user_id,
);
};

/**
Expand Down