diff --git a/src/__tests__/lib/exampleData.js b/src/__tests__/lib/exampleData.js index f4e22170831..57d86a9097f 100644 --- a/src/__tests__/lib/exampleData.js +++ b/src/__tests__/lib/exampleData.js @@ -3,7 +3,7 @@ import deepFreeze from 'deep-freeze'; import { createStore } from 'redux'; import type { CrossRealmBot, Message, PmRecipientUser, Stream, User } from '../../api/modelTypes'; -import type { Action, GlobalState, RealmState } from '../../reduxTypes'; +import type { Action, GlobalState, MessagesState, RealmState } from '../../reduxTypes'; import type { Auth, Account, Outbox } from '../../types'; import { ZulipVersion } from '../../utils/zulipVersion'; import { @@ -16,6 +16,8 @@ import { import rootReducer from '../../boot/reducers'; import { authOfAccount } from '../../account/accountMisc'; import { HOME_NARROW } from '../../utils/narrow'; +import { NULL_OBJECT } from '../../nullObjects'; +import { objectFromEntries } from '../../jsBackport'; /* ======================================================================== * Utilities @@ -73,8 +75,13 @@ export const randString = () => randInt(2 ** 54).toString(36); * Users and bots */ +type UserOrBotPropertiesArgs = {| + name?: string, + user_id?: number, +|}; + const randUserId: () => number = makeUniqueRandInt('user IDs', 10000); -const userOrBotProperties = ({ name: _name }) => { +const userOrBotProperties = ({ name: _name, user_id }: UserOrBotPropertiesArgs) => { const name = _name ?? randString(); const capsName = name.substring(0, 1).toUpperCase() + name.substring(1); return deepFreeze({ @@ -88,12 +95,12 @@ const userOrBotProperties = ({ name: _name }) => { full_name: `${capsName} User`, is_admin: false, timezone: 'UTC', - user_id: randUserId(), + user_id: user_id ?? randUserId(), }); }; /** Beware! These values may not be representative. */ -export const makeUser = (args: { name?: string } = {}): User => +export const makeUser = (args: UserOrBotPropertiesArgs = NULL_OBJECT): User => deepFreeze({ ...userOrBotProperties(args), @@ -107,7 +114,7 @@ export const makeUser = (args: { name?: string } = {}): User => }); /** Beware! These values may not be representative. */ -export const makeCrossRealmBot = (args: { name?: string } = {}): CrossRealmBot => +export const makeCrossRealmBot = (args: UserOrBotPropertiesArgs = NULL_OBJECT): CrossRealmBot => deepFreeze({ ...userOrBotProperties(args), is_bot: true, @@ -182,16 +189,17 @@ export const stream: Stream = makeStream({ * Messages */ -const displayRecipientFromUser = (user: User): PmRecipientUser => { - const { email, full_name, user_id: id } = user; - return deepFreeze({ - email, - full_name, - id, - is_mirror_dummy: false, - short_name: '', // what is this, anyway? +const displayRecipientFromUsers = (users: User[]): PmRecipientUser[] => + users.map(user => { + const { email, full_name, user_id: id } = user; + return deepFreeze({ + email, + full_name, + id, + is_mirror_dummy: false, + short_name: '', // what is this, anyway? + }); }); -}; /** Boring properties common to all example Message objects. */ const messagePropertiesBase = deepFreeze({ @@ -233,14 +241,14 @@ const messagePropertiesFromSender = (user: User) => { }; /** Beware! These values may not be representative. */ -export const pmMessage = (extra?: $Rest): Message => { +export const pmMessageFromTo = (from: User, to: User[], extra?: $Rest): Message => { const baseMessage: Message = { ...messagePropertiesBase, - ...messagePropertiesFromSender(otherUser), + ...messagePropertiesFromSender(from), content: 'This is an example PM message.', content_type: 'text/markdown', - display_recipient: [displayRecipientFromUser(selfUser)], + display_recipient: displayRecipientFromUsers([from, ...to]), id: 1234567, recipient_id: 2342, stream_id: -1, @@ -252,6 +260,9 @@ export const pmMessage = (extra?: $Rest): Message => { return deepFreeze({ ...baseMessage, ...extra }); }; +export const pmMessage = (extra?: $Rest): Message => + pmMessageFromTo(selfUser, [otherUser], extra); + const messagePropertiesFromStream = (stream1: Stream) => { const { stream_id, name: display_recipient } = stream1; return deepFreeze({ @@ -279,6 +290,13 @@ export const streamMessage = (extra?: $Rest): Message => { return deepFreeze({ ...baseMessage, ...extra }); }; +/** Construct a MessagesState from a list of messages. */ +export const makeMessagesState = (messages: Message[]): MessagesState => { + const state: { [id: number]: Message } = objectFromEntries(messages.map(m => [m.id, m])); + // exact object + indexer properties issue; fixed in v0.111.0 + return (state: $FlowFixMe); +}; + /* ======================================================================== * Outbox messages * @@ -424,6 +442,7 @@ export const action = deepFreeze({ realm_users: [], user_id: 4, realm_user_groups: [], + recent_private_conversations: [], streams: [], never_subscribed: [], subscriptions: [], @@ -491,5 +510,6 @@ export const eventNewMessageActionBase /* \: $Diff, type: typeof EVENT_NEW_MESSAGE, caughtUp: CaughtUpState, + ownId: number, ownEmail: string, |}; diff --git a/src/api/initialDataTypes.js b/src/api/initialDataTypes.js index 6860e2e96b2..c48a8cdeb65 100644 --- a/src/api/initialDataTypes.js +++ b/src/api/initialDataTypes.js @@ -4,6 +4,7 @@ import type { CrossRealmBot, RealmEmojiById, RealmFilter, + RecentPrivateConversation, Stream, Subscription, User, @@ -110,6 +111,14 @@ export type InitialDataRealmUserGroups = {| realm_user_groups?: UserGroup[], |}; +export type InitialDataRecentPmConversations = {| + // * Added in server commit 2.1-dev-384-g4c3c669b41. + // * `user_id` fields are sorted as of commit 2.2-dev-53-g405a529340, which + // was backported to 2.1.1-50-gd452ad31e0 -- meaning that they are _not_ + // sorted in either v2.1.0 or v2.1.1. + recent_private_conversations?: RecentPrivateConversation[], +|}; + type NeverSubscribedStream = {| description: string, invite_only: boolean, @@ -287,6 +296,7 @@ export type InitialData = {| ...InitialDataRealmFilters, ...InitialDataRealmUser, ...InitialDataRealmUserGroups, + ...InitialDataRecentPmConversations, ...InitialDataStream, ...InitialDataSubscription, ...InitialDataUpdateDisplaySettings, diff --git a/src/api/modelTypes.js b/src/api/modelTypes.js index 6f208758880..315b778f52b 100644 --- a/src/api/modelTypes.js +++ b/src/api/modelTypes.js @@ -545,3 +545,28 @@ export type Message = $ReadOnly<{ subject: string, subject_links: $ReadOnlyArray, }>; + +// +// +// +// =================================================================== +// Summaries of messages and conversations. +// +// + +/** + * Describes a single recent PM conversation. + * + * For the structure of this type and the meaning of its properties, see: + * https://github.com/zulip/zulip/blob/4c3c669b41/zerver/lib/events.py#L283-L290 + * + * `user_ids` does not contain the `user_id` of the current user. Consequently, + * a user's conversation with themselves will be listed here as [], which is + * unlike the behaviour found in some other parts of the codebase. + */ +export type RecentPrivateConversation = {| + max_message_id: number, + // When received from the server, these are guaranteed to be sorted only after + // 2.2-dev-53-g405a529340. To be safe, we always sort them on receipt. + user_ids: number[], +|}; diff --git a/src/boot/reducers.js b/src/boot/reducers.js index a112ef84259..f418072e511 100644 --- a/src/boot/reducers.js +++ b/src/boot/reducers.js @@ -21,6 +21,7 @@ import nav from '../nav/navReducer'; import outbox from '../outbox/outboxReducer'; import presence from '../presence/presenceReducer'; import realm from '../realm/realmReducer'; +import recentPrivateConversations from '../pm-conversations/recentPmConversationsReducer'; import session from '../session/sessionReducer'; import settings from '../settings/settingsReducer'; import streams from '../streams/streamsReducer'; @@ -50,6 +51,7 @@ const reducers = { outbox, presence, realm, + recentPrivateConversations, session, settings, streams, diff --git a/src/boot/store.js b/src/boot/store.js index 44c41c3f8ac..080690d6582 100644 --- a/src/boot/store.js +++ b/src/boot/store.js @@ -47,9 +47,9 @@ export const storeKeys: Array<$Keys> = [ * don't have to re-download it. */ // prettier-ignore -export const cacheKeys: Array<$Keys> = [ - 'flags', 'messages', 'mute', 'narrows', 'realm', 'streams', - 'subscriptions', 'unread', 'userGroups', 'users', +export const cacheKeys = [ + 'flags', 'messages', 'mute', 'narrows', 'realm', 'recentPrivateConversations', + 'streams', 'subscriptions', 'unread', 'userGroups', 'users', ]; /** diff --git a/src/config.js b/src/config.js index 1a893b21223..e260a14e358 100644 --- a/src/config.js +++ b/src/config.js @@ -32,6 +32,7 @@ const config: Config = { 'realm_filters', 'realm_user', 'realm_user_groups', + 'recent_private_conversations', 'stream', 'subscription', 'update_display_settings', diff --git a/src/directSelectors.js b/src/directSelectors.js index 7bc77e89e82..201d07695c7 100644 --- a/src/directSelectors.js +++ b/src/directSelectors.js @@ -23,6 +23,7 @@ import type { Subscription, Stream, Outbox, + RecentPrivateConversation, User, UserGroup, UserStatusState, @@ -83,6 +84,9 @@ export const getSubscriptions = (state: GlobalState): Subscription[] => state.su */ export const getStreams = (state: GlobalState): Stream[] => state.streams; +export const getRecentPrivateConversations = (state: GlobalState): RecentPrivateConversation[] => + state.recentPrivateConversations; + export const getPresence = (state: GlobalState): PresenceState => state.presence; export const getOutbox = (state: GlobalState): Outbox[] => state.outbox; diff --git a/src/events/eventToAction.js b/src/events/eventToAction.js index fe5b18e5755..606bc1a706b 100644 --- a/src/events/eventToAction.js +++ b/src/events/eventToAction.js @@ -85,6 +85,8 @@ export default (state: GlobalState, event: $FlowFixMe): EventAction => { ...event, type: EVENT_NEW_MESSAGE, caughtUp: state.caughtUp, + ownId: getOwnUserId(state), + // deprecated; avoid adding new uses (#3764) ownEmail: getOwnEmail(state), }; diff --git a/src/pm-conversations/__tests__/pmConversationsSelectors-test.js b/src/pm-conversations/__tests__/pmConversationsSelectors-test.js index f665209a1ac..b772179f11e 100644 --- a/src/pm-conversations/__tests__/pmConversationsSelectors-test.js +++ b/src/pm-conversations/__tests__/pmConversationsSelectors-test.js @@ -1,17 +1,23 @@ -import deepFreeze from 'deep-freeze'; +// @flow strict-local +import * as eg from '../../__tests__/lib/exampleData'; import { getRecentConversations } from '../pmConversationsSelectors'; import { ALL_PRIVATE_NARROW_STR } from '../../utils/narrow'; +import { ZulipVersion } from '../../utils/zulipVersion'; + +describe('getRecentConversations: legacy', () => { + const zulipVersion = new ZulipVersion('2.0.0'); -describe('getRecentConversations', () => { test('when no messages, return no conversations', () => { - const state = deepFreeze({ - realm: { email: 'me@example.com' }, - users: [{ user_id: 0, email: 'me@example.com' }], + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: eg.selfUser, zulipVersion })], + realm: eg.realmState({ email: eg.selfUser.email }), + users: [eg.selfUser], narrows: { [ALL_PRIVATE_NARROW_STR]: [], }, unread: { + ...eg.baseReduxState.unread, pms: [], huddles: [], }, @@ -23,57 +29,26 @@ describe('getRecentConversations', () => { }); test('returns unique list of recipients, includes conversations with self', () => { - const state = deepFreeze({ - realm: { email: 'me@example.com' }, - users: [ - { user_id: 0, email: 'me@example.com' }, - { user_id: 1, email: 'john@example.com' }, - { user_id: 2, email: 'mark@example.com' }, - ], + const me = eg.makeUser({ user_id: 0, name: 'me' }); + const john = eg.makeUser({ user_id: 1, name: 'john' }); + const mark = eg.makeUser({ user_id: 2, name: 'mark' }); + + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: me, zulipVersion })], + realm: eg.realmState({ email: me.email }), + users: [me, john, mark], narrows: { [ALL_PRIVATE_NARROW_STR]: [0, 1, 2, 3, 4], }, - messages: { - 1: { - id: 1, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - ], - }, - 2: { - id: 2, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 2, email: 'mark@example.com' }, - ], - }, - 3: { - id: 3, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - ], - }, - 4: { - id: 4, - type: 'private', - display_recipient: [{ id: 0, email: 'me@example.com' }], - }, - 0: { - id: 0, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - { id: 2, email: 'mark@example.com' }, - ], - }, - }, + messages: eg.makeMessagesState([ + eg.pmMessageFromTo(john, [me], { id: 1 }), + eg.pmMessageFromTo(mark, [me], { id: 2 }), + eg.pmMessageFromTo(john, [me], { id: 3 }), + eg.pmMessageFromTo(me, [], { id: 4 }), + eg.pmMessageFromTo(john, [me, mark], { id: 0 }), + ]), unread: { + ...eg.baseReduxState.unread, pms: [ { sender_id: 0, @@ -100,25 +75,25 @@ describe('getRecentConversations', () => { const expectedResult = [ { ids: '0', - recipients: 'me@example.com', + recipients: me.email, msgId: 4, unread: 1, }, { ids: '1', - recipients: 'john@example.com', + recipients: john.email, msgId: 3, unread: 2, }, { ids: '2', - recipients: 'mark@example.com', + recipients: mark.email, msgId: 2, unread: 1, }, { ids: '0,1,2', - recipients: 'john@example.com,mark@example.com', + recipients: [john.email, mark.email].join(','), msgId: 0, unread: 1, }, @@ -126,69 +101,31 @@ describe('getRecentConversations', () => { const actual = getRecentConversations(state); - expect(actual).toEqual(expectedResult); + expect(actual).toMatchObject(expectedResult); }); test('returns recipients sorted by last activity', () => { - const state = deepFreeze({ - realm: { email: 'me@example.com' }, - users: [ - { user_id: 0, email: 'me@example.com' }, - { user_id: 1, email: 'john@example.com' }, - { user_id: 2, email: 'mark@example.com' }, - ], + const me = eg.makeUser({ user_id: 0, name: 'me' }); + const john = eg.makeUser({ user_id: 1, name: 'john' }); + const mark = eg.makeUser({ user_id: 2, name: 'mark' }); + + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: me, zulipVersion })], + realm: eg.realmState({ email: me.email }), + users: [me, john, mark], narrows: { [ALL_PRIVATE_NARROW_STR]: [1, 2, 3, 4, 5, 6], }, - messages: { - 2: { - id: 2, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - ], - }, - 1: { - id: 1, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 2, email: 'mark@example.com' }, - ], - }, - 4: { - id: 4, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - ], - }, - 3: { - id: 3, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 2, email: 'mark@example.com' }, - ], - }, - 5: { - id: 5, - type: 'private', - display_recipient: [ - { id: 0, email: 'me@example.com' }, - { id: 1, email: 'john@example.com' }, - { id: 2, email: 'mark@example.com' }, - ], - }, - 6: { - id: 6, - type: 'private', - display_recipient: [{ id: 0, email: 'me@example.com' }], - }, - }, + messages: eg.makeMessagesState([ + eg.pmMessageFromTo(john, [me], { id: 2 }), + eg.pmMessageFromTo(mark, [me], { id: 1 }), + eg.pmMessageFromTo(john, [me], { id: 4 }), + eg.pmMessageFromTo(mark, [me], { id: 3 }), + eg.pmMessageFromTo(mark, [me, john], { id: 5 }), + eg.pmMessageFromTo(me, [], { id: 6 }), + ]), unread: { + ...eg.baseReduxState.unread, pms: [ { sender_id: 0, @@ -215,25 +152,25 @@ describe('getRecentConversations', () => { const expectedResult = [ { ids: '0', - recipients: 'me@example.com', + recipients: me.email, msgId: 6, unread: 1, }, { ids: '0,1,2', - recipients: 'john@example.com,mark@example.com', + recipients: [john.email, mark.email].join(','), msgId: 5, unread: 1, }, { ids: '1', - recipients: 'john@example.com', + recipients: john.email, msgId: 4, unread: 2, }, { ids: '2', - recipients: 'mark@example.com', + recipients: mark.email, msgId: 3, unread: 1, }, @@ -244,3 +181,158 @@ describe('getRecentConversations', () => { expect(actual).toEqual(expectedResult); }); }); + +describe('getRecentConversations: modern', () => { + const zulipVersion = new ZulipVersion('2.2.0'); + + test('when no recent conversations, return no conversations', () => { + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: eg.selfUser, zulipVersion })], + realm: eg.realmState({ email: eg.selfUser.email }), + users: [eg.selfUser], + }); + + expect(getRecentConversations(state)).toEqual([]); + }); + + test('returns unique list of recipients, includes conversations with self', () => { + const users = [eg.selfUser, eg.makeUser({ name: 'john' }), eg.makeUser({ name: 'mark' })]; + const recentPrivateConversations = [ + { max_message_id: 4, user_ids: [] }, + { max_message_id: 3, user_ids: [users[1].user_id] }, + { max_message_id: 2, user_ids: [users[2].user_id] }, + { max_message_id: 0, user_ids: [users[1].user_id, users[2].user_id] }, + ]; + const unread = { + ...eg.baseReduxState.unread, + huddles: [ + { + user_ids_string: [eg.selfUser.user_id, users[1].user_id, users[2].user_id] + .sort((a, b) => a - b) + .join(), + unread_message_ids: [5], + }, + ], + pms: [ + { + sender_id: eg.selfUser.user_id, + unread_message_ids: [4], + }, + { + sender_id: users[1].user_id, + unread_message_ids: [1, 3], + }, + { + sender_id: users[2].user_id, + unread_message_ids: [2], + }, + ], + }; + + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: eg.selfUser, zulipVersion })], + realm: eg.realmState({ email: eg.selfUser.email }), + users, + recentPrivateConversations, + unread, + }); + + expect(getRecentConversations(state)).toEqual([ + { + ids: eg.selfUser.user_id.toString(), + recipients: eg.selfUser.email, + msgId: 4, + unread: 1, + }, + { + ids: users[1].user_id.toString(), + recipients: users[1].email, + msgId: 3, + unread: 2, + }, + { + ids: users[2].user_id.toString(), + recipients: users[2].email, + msgId: 2, + unread: 1, + }, + { + ids: [eg.selfUser.user_id, users[1].user_id, users[2].user_id].sort((a, b) => a - b).join(), + recipients: [users[1].email, users[2].email].join(), + msgId: 0, + unread: 1, + }, + ]); + }); + + test('returns recipients sorted by last activity', () => { + const users = [eg.selfUser, eg.makeUser({ name: 'john' }), eg.makeUser({ name: 'mark' })]; + const recentPrivateConversations = [ + { max_message_id: 6, user_ids: [] }, + { max_message_id: 5, user_ids: [users[1].user_id, users[2].user_id] }, + { max_message_id: 4, user_ids: [users[1].user_id] }, + { max_message_id: 3, user_ids: [users[2].user_id] }, + ]; + const unread = { + streams: [], + huddles: [ + { + user_ids_string: [eg.selfUser.user_id, users[1].user_id, users[2].user_id] + .sort((a, b) => a - b) + .join(), + unread_message_ids: [5], + }, + ], + pms: [ + { + sender_id: eg.selfUser.user_id, + unread_message_ids: [4], + }, + { + sender_id: users[1].user_id, + unread_message_ids: [1, 3], + }, + { + sender_id: users[2].user_id, + unread_message_ids: [2], + }, + ], + mentions: [], + }; + + const state = eg.reduxState({ + accounts: [eg.makeAccount({ user: eg.selfUser, zulipVersion })], + realm: eg.realmState({ email: eg.selfUser.email }), + users, + recentPrivateConversations, + unread, + }); + + expect(getRecentConversations(state)).toEqual([ + { + ids: eg.selfUser.user_id.toString(), + recipients: eg.selfUser.email, + msgId: 6, + unread: 1, + }, + { + ids: [eg.selfUser.user_id, users[1].user_id, users[2].user_id].sort((a, b) => a - b).join(), + recipients: [users[1].email, users[2].email].join(), + msgId: 5, + unread: 1, + }, + { + ids: users[1].user_id.toString(), + recipients: users[1].email, + msgId: 4, + unread: 2, + }, + { + ids: users[2].user_id.toString(), + recipients: users[2].email, + msgId: 3, + unread: 1, + }, + ]); + }); +}); diff --git a/src/pm-conversations/__tests__/recentPmConversationsReducer-test.js b/src/pm-conversations/__tests__/recentPmConversationsReducer-test.js new file mode 100644 index 00000000000..9c2eb8181dd --- /dev/null +++ b/src/pm-conversations/__tests__/recentPmConversationsReducer-test.js @@ -0,0 +1,28 @@ +/* @flow strict-local */ +import deepFreeze from 'deep-freeze'; + +import recentPmConversationsReducer from '../recentPmConversationsReducer'; +import * as eg from '../../__tests__/lib/exampleData'; + +describe('recentPmConversationsReducer', () => { + describe('EVENT_NEW_MESSAGE', () => { + test('reorder correctly upon receiving a new message', () => { + const users = [eg.makeUser({ name: 'john' }), eg.makeUser({ name: 'mark' })]; + const newMessage = eg.pmMessageFromTo(users[1], [eg.selfUser], { id: 2 }); + const initialState = deepFreeze([ + { max_message_id: 1, user_ids: [users[0].user_id] }, + { max_message_id: 0, user_ids: [users[1].user_id] }, + ]); + + const action = deepFreeze({ + ...eg.eventNewMessageActionBase, + message: newMessage, + }); + + expect(recentPmConversationsReducer(initialState, action)).toEqual([ + { max_message_id: 2, user_ids: [users[1].user_id] }, + { max_message_id: 1, user_ids: [users[0].user_id] }, + ]); + }); + }); +}); diff --git a/src/pm-conversations/pmConversationsSelectors.js b/src/pm-conversations/pmConversationsSelectors.js index f8efbd8ba21..da1bd6c8823 100644 --- a/src/pm-conversations/pmConversationsSelectors.js +++ b/src/pm-conversations/pmConversationsSelectors.js @@ -1,46 +1,62 @@ /* @flow strict-local */ import { createSelector } from 'reselect'; -import type { Message, PmConversationData, Selector, User } from '../types'; +import type { + Message, + PmConversationData, + RecentPrivateConversation, + Selector, + User, + UserOrBot, +} from '../types'; +import { getServerVersion } from '../account/accountsSelectors'; import { getPrivateMessages } from '../message/messageSelectors'; -import { getOwnUser } from '../users/userSelectors'; +import { getRecentPrivateConversations } from '../directSelectors'; +import { getOwnUser, getAllUsersById } from '../users/userSelectors'; import { getUnreadByPms, getUnreadByHuddles } from '../unread/unreadSelectors'; import { normalizeRecipientsSansMe, pmUnreadsKeyFromMessage } from '../utils/recipient'; +import { ZulipVersion } from '../utils/zulipVersion'; -export const getRecentConversations: Selector = createSelector( - getOwnUser, - getPrivateMessages, - getUnreadByPms, - getUnreadByHuddles, - ( - ownUser: User, - messages: Message[], - unreadPms: { [number]: number }, - unreadHuddles: { [string]: number }, - ): PmConversationData[] => { - const recipients = messages.map(msg => ({ - ids: pmUnreadsKeyFromMessage(msg, ownUser.user_id), - emails: normalizeRecipientsSansMe(msg.display_recipient, ownUser.email), - msgId: msg.id, - })); +/** + * Given a list of PmConversationPartial or PmConversationData, trim it down to + * contain only the most recent message from any conversation, and return them + * sorted by recency. + */ +const collateByRecipient = ( + items: $ReadOnlyArray, +): T[] => { + const latestByRecipient = new Map(); + items.forEach(item => { + const prev = latestByRecipient.get(item.recipients); + if (!prev || item.msgId > prev.msgId) { + latestByRecipient.set(item.recipients, item); + } + }); - const latestByRecipient = new Map(); - recipients.forEach(recipient => { - const prev = latestByRecipient.get(recipient.emails); - if (!prev || recipient.msgId > prev.msgId) { - latestByRecipient.set(recipient.emails, { - ids: recipient.ids, - recipients: recipient.emails, - msgId: recipient.msgId, - }); - } - }); + const sortedByMostRecent = Array.from(latestByRecipient.values()).sort( + (a, b) => +b.msgId - +a.msgId, + ); + + return sortedByMostRecent; +}; - const sortedByMostRecent = Array.from(latestByRecipient.values()).sort( - (a, b) => +b.msgId - +a.msgId, - ); +type PmConversationPartial = $Diff; +type UnreadAttacher = ($ReadOnlyArray) => PmConversationData[]; - return sortedByMostRecent.map(recipient => ({ +/** + * Auxiliary function: fragment of the selector(s) for PmConversationData. + * Transforms PmConversationPartial[] to PmConversationData[]. + * + * Note that, since this will only ever be called by other selectors, the inner + * function doesn't need to do any memoization of its own. + */ +const getAttachUnread: Selector = createSelector( + getUnreadByPms, + getUnreadByHuddles, + (unreadPms: { [number]: number }, unreadHuddles: { [string]: number }) => ( + partials: $ReadOnlyArray, + ) => + partials.map(recipient => ({ ...recipient, unread: // This business of looking in one place and then the other is kind @@ -51,10 +67,114 @@ export const getRecentConversations: Selector = createSele end up converted to strings, so this access with string keys works. We should probably use a Map for this and similar maps. */ unreadPms[recipient.ids] || unreadHuddles[recipient.ids], + })), +); + +/** + * Legacy implementation of {@link getRecentConversations}. Computes an + * approximation to the set of recent conversations, based on the messages we + * already know about. + */ +const getRecentConversationsLegacyImpl: Selector = createSelector( + getOwnUser, + getPrivateMessages, + getAttachUnread, + (ownUser: User, messages: Message[], attachUnread: UnreadAttacher): PmConversationData[] => { + const items = messages.map(msg => ({ + ids: pmUnreadsKeyFromMessage(msg, ownUser.user_id), + recipients: normalizeRecipientsSansMe(msg.display_recipient, ownUser.email), + msgId: msg.id, })); + + const sortedByMostRecent = collateByRecipient(items); + + return attachUnread(sortedByMostRecent); }, ); +/** + * Modern implementation of {@link getRecentConversations}. Returns exactly the + * most recent conversations. Requires server-side support. + */ +const getRecentConversationsImpl: Selector = createSelector( + getOwnUser, + getAllUsersById, + getRecentPrivateConversations, + getAttachUnread, + ( + ownUser: User, + allUsers: Map, + recentPCs: RecentPrivateConversation[], + attachUnread: UnreadAttacher, + ) => { + const getEmail = (id: number): string => { + const user = allUsers.get(id); + if (user) { + return user.email; + } + throw new Error('getRecentConversations: unknown user id'); + }; + + const recipients = recentPCs.map(conversation => { + const userIds = conversation.user_ids.slice(); + if (userIds.length !== 1) { + userIds.push(ownUser.user_id); + userIds.sort((a, b) => a - b); + } + + return { + ids: userIds.join(','), + recipients: normalizeRecipientsSansMe( + userIds.map(id => ({ email: getEmail(id) })), + ownUser.email, + ), + msgId: conversation.max_message_id, + }; + }); + + return attachUnread(recipients); + }, +); + +/** + * The server version in which 'recent_private_conversations' was first made + * available. + */ +const DIVIDING_LINE = new ZulipVersion('2.1-dev-384-g4c3c669b41'); + +// Private. Selector to choose between other selectors. (This avoids needlessly +// recomputing the old version when we're on a new server, or vice versa.) +const getMetaselector: Selector> = createSelector( + getServerVersion, + version => { + // If we're talking to a new enough version of the Zulip server, we don't + // need the legacy impl; the modern one will always return a superset of + // its content. + if (version && version.isAtLeast(DIVIDING_LINE)) { + return getRecentConversationsImpl; + } + + // If we're _not_ talking to a newer version of the Zulip server, then + // there's no point in using the modern version; it will only return + // messages received in the current session, which should all be in the + // legacy impl's data as well. + return getRecentConversationsLegacyImpl; + }, +); + +/** + * Get a list of the most recent private conversations, including the most + * recent message from each. + * + * Switches between implementations as appropriate for the current server + * version. + */ +export const getRecentConversations: Selector = createSelector( + state => state, + getMetaselector, + (state, metaselector) => metaselector(state), +); + export const getUnreadConversations: Selector = createSelector( getRecentConversations, conversations => conversations.filter(c => c.unread > 0), diff --git a/src/pm-conversations/recentPmConversationsReducer.js b/src/pm-conversations/recentPmConversationsReducer.js new file mode 100644 index 00000000000..24f49d02f81 --- /dev/null +++ b/src/pm-conversations/recentPmConversationsReducer.js @@ -0,0 +1,61 @@ +/* @flow strict-local */ +import isEqual from 'lodash.isequal'; + +import type { Action, RecentPrivateConversationsState } from '../types'; +import { REALM_INIT, EVENT_NEW_MESSAGE } from '../actionConstants'; +import { NULL_ARRAY } from '../nullObjects'; + +const initialState: RecentPrivateConversationsState = NULL_ARRAY; + +const realmInit = (state, action) => { + // If this is a pre-2.1 server, we'll have to get by without. + if (action.data.recent_private_conversations === undefined) { + return initialState; + } + + // `user_id`s are not guaranteed to be sorted prior to v2.1.2. + // (Take care not to mutate the input.) + return action.data.recent_private_conversations.map(({ user_ids, ...rest }) => ({ + ...rest, + user_ids: [...user_ids].sort((a, b) => a - b), + })); +}; + +const eventNewMessage = (state, action) => { + if (action.message.type !== 'private') { + return state; + } + + const userIds = action.message.display_recipient + .map(recipient => recipient.id) + .filter(s => s !== action.ownId); + const index = state.findIndex(item => isEqual(item.user_ids, userIds)); + const oldMaxMsgId = index < 0 ? null : state[index].max_message_id; + const item = { + user_ids: userIds, + max_message_id: Math.max(action.message.id, oldMaxMsgId ?? -Infinity), + }; + + const unsorted = + index < 0 + ? [item, ...state] /* force linebreak */ + : [item, ...state.slice(0, index), ...state.slice(index + 1)]; + + return unsorted.sort((a, b) => -(a.max_message_id - b.max_message_id)); +}; + +export default ( + state: RecentPrivateConversationsState = initialState, + action: Action, +): RecentPrivateConversationsState => { + switch (action.type) { + case REALM_INIT: + return realmInit(state, action); + + case EVENT_NEW_MESSAGE: + return eventNewMessage(state, action); + + default: + return state; + } +}; diff --git a/src/reduxTypes.js b/src/reduxTypes.js index a7533442acb..50f37bdce0d 100644 --- a/src/reduxTypes.js +++ b/src/reduxTypes.js @@ -22,6 +22,7 @@ import type { RealmEmojiById, RealmFilter, Narrow, + RecentPrivateConversation, Stream, StreamUnreadItem, Subscription, @@ -252,6 +253,13 @@ export type RealmState = {| isAdmin: boolean, |}; +/** + * The PM conversations (group or 1:1) found in the last 1000 PMs. + * + * Sorted by `max_message_id` descending. + */ +export type RecentPrivateConversationsState = RecentPrivateConversation[]; + export type ThemeName = 'default' | 'night'; export type SettingsState = {| @@ -339,6 +347,7 @@ export type GlobalState = {| outbox: OutboxState, presence: PresenceState, realm: RealmState, + recentPrivateConversations: RecentPrivateConversationsState, session: SessionState, settings: SettingsState, streams: StreamsState,