diff --git a/src/__tests__/lib/exampleData.js b/src/__tests__/lib/exampleData.js index 50adce83c02..ce317b60513 100644 --- a/src/__tests__/lib/exampleData.js +++ b/src/__tests__/lib/exampleData.js @@ -557,6 +557,7 @@ export const action = deepFreeze({ alert_words: [], max_message_id: 100, muted_topics: [], + muted_users: [], presences: {}, max_icon_file_size: 3, realm_add_emoji_by_admins_only: true, @@ -671,6 +672,8 @@ export const action = deepFreeze({ foundOldest: undefined, ownUserId: selfUser.user_id, }, + // If a given action is only relevant to a single test file, no need to + // provide a generic example of it here; just define it there. }); // Ensure every `eg.action.foo` is some well-typed action. (We don't simply @@ -682,13 +685,27 @@ export const action = deepFreeze({ /* ======================================================================== * Action fragments * - * Partial actions, for those action types whose interior will almost always - * need to be filled in with more data. + * Partial actions, for those action types where (a) there's some + * boilerplate data that's useful to supply here, but (b) there's some other + * places where a given test will almost always need to fill in specific + * data of its own. + * + * The properties where each test will want to fill in its own specific data + * should be left out of these fragments. That way, Flow can ensure the + * test explicitly supplies the data. */ export const eventNewMessageActionBase /* \: $Diff */ = { type: EVENT_NEW_MESSAGE, + // These properties are boring for most or all tests. id: 1001, caughtUp: {}, ownUserId: selfUser.user_id, + + // The details of this property are typically important to what a test is + // testing, so we provide it explicitly in each test. + // message: Message, }; + +// If a given action is only relevant to a single test file, no need to +// provide a generic fragment for it here; just define the test data there. diff --git a/src/actionConstants.js b/src/actionConstants.js index 75cd8a3c5ae..396fb65141e 100644 --- a/src/actionConstants.js +++ b/src/actionConstants.js @@ -38,6 +38,7 @@ export const EVENT_USER_ADD: 'EVENT_USER_ADD' = 'EVENT_USER_ADD'; export const EVENT_USER_REMOVE: 'EVENT_USER_REMOVE' = 'EVENT_USER_REMOVE'; export const EVENT_USER_UPDATE: 'EVENT_USER_UPDATE' = 'EVENT_USER_UPDATE'; export const EVENT_MUTED_TOPICS: 'EVENT_MUTED_TOPICS' = 'EVENT_MUTED_TOPICS'; +export const EVENT_MUTED_USERS: 'EVENT_MUTED_USERS' = 'EVENT_MUTED_USERS'; export const EVENT_USER_GROUP_ADD: 'EVENT_USER_GROUP_ADD' = 'EVENT_USER_GROUP_ADD'; export const EVENT_USER_GROUP_REMOVE: 'EVENT_USER_GROUP_REMOVE' = 'EVENT_USER_GROUP_REMOVE'; diff --git a/src/actionTypes.js b/src/actionTypes.js index 753c81b3e17..062048fd5d1 100644 --- a/src/actionTypes.js +++ b/src/actionTypes.js @@ -46,6 +46,7 @@ import { EVENT_ALERT_WORDS, INIT_TOPICS, EVENT_MUTED_TOPICS, + EVENT_MUTED_USERS, EVENT_REALM_FILTERS, EVENT_USER_REMOVE, EVENT_USER_UPDATE, @@ -56,7 +57,13 @@ import { EVENT, } from './actionConstants'; -import type { MessageEvent, PresenceEvent, StreamEvent, SubmessageEvent } from './api/eventTypes'; +import type { + MessageEvent, + MutedUsersEvent, + PresenceEvent, + StreamEvent, + SubmessageEvent, +} from './api/eventTypes'; import type { Orientation, @@ -428,6 +435,11 @@ type EventMutedTopicsAction = {| muted_topics: MuteState, |}; +type EventMutedUsersAction = {| + ...MutedUsersEvent, + type: typeof EVENT_MUTED_USERS, +|}; + type EventUserGroupAddAction = {| ...ServerEvent, type: typeof EVENT_USER_GROUP_ADD, @@ -512,6 +524,7 @@ export type EventAction = | EventAlertWordsAction | EventMessageDeleteAction | EventMutedTopicsAction + | EventMutedUsersAction | EventNewMessageAction | EventSubmessageAction | EventPresenceAction diff --git a/src/api/eventTypes.js b/src/api/eventTypes.js index fb2c06b2636..e75eafbea1d 100644 --- a/src/api/eventTypes.js +++ b/src/api/eventTypes.js @@ -10,7 +10,7 @@ * @flow strict-local */ -import type { Message, Stream, UserId, UserPresence } from './modelTypes'; +import type { Message, MutedUser, Stream, UserId, UserPresence } from './modelTypes'; export class EventTypes { static alert_words: 'alert_words' = 'alert_words'; @@ -18,6 +18,7 @@ export class EventTypes { static heartbeat: 'heartbeat' = 'heartbeat'; static message: 'message' = 'message'; static muted_topics: 'muted_topics' = 'muted_topics'; + static muted_users: 'muted_users' = 'muted_users'; static presence: 'presence' = 'presence'; static reaction: 'reaction' = 'reaction'; static realm_bot: 'realm_bot' = 'realm_bot'; @@ -70,6 +71,12 @@ export type MessageEvent = {| local_message_id?: number, |}; +export type MutedUsersEvent = {| + ...EventCommon, + type: typeof EventTypes.muted_users, + muted_users: MutedUser[], +|}; + /** A new submessage. See the `Submessage` type for details. */ export type SubmessageEvent = {| ...EventCommon, diff --git a/src/api/initialDataTypes.js b/src/api/initialDataTypes.js index 5998bea18c1..fc891f7a079 100644 --- a/src/api/initialDataTypes.js +++ b/src/api/initialDataTypes.js @@ -2,6 +2,7 @@ import type { CrossRealmBot, + MutedUser, RealmEmojiById, RealmFilter, RecentPrivateConversation, @@ -34,6 +35,11 @@ export type InitialDataMutedTopics = {| muted_topics: MuteTuple[], |}; +/** Added in server version 4.0, feature level 48 */ +export type InitialDataMutedUsers = {| + muted_users?: MutedUser[], +|}; + export type InitialDataPresence = {| presences: {| [email: string]: UserPresence |}, |}; @@ -311,6 +317,7 @@ export type InitialData = {| ...InitialDataAlertWords, ...InitialDataMessage, ...InitialDataMutedTopics, + ...InitialDataMutedUsers, ...InitialDataPresence, ...InitialDataRealm, ...InitialDataRealmEmoji, diff --git a/src/api/modelTypes.js b/src/api/modelTypes.js index ac165273675..d274e54c418 100644 --- a/src/api/modelTypes.js +++ b/src/api/modelTypes.js @@ -261,6 +261,14 @@ export type UserPresence = {| [client: string]: ClientPresence, |}; +/** This is what appears in the `muted_users` server event. + * See https://chat.zulip.org/api/get-events#muted_users for details. + */ +export type MutedUser = {| + id: UserId, + timestamp: number, +|}; + // // // diff --git a/src/boot/reducers.js b/src/boot/reducers.js index 116f6ec4f63..e81ce5c76ae 100644 --- a/src/boot/reducers.js +++ b/src/boot/reducers.js @@ -12,6 +12,7 @@ import flags from '../chat/flagsReducer'; import narrows from '../chat/narrowsReducer'; import messages from '../message/messagesReducer'; import mute from '../mute/muteReducer'; +import mutedUsers from '../mute/mutedUsersReducer'; import outbox from '../outbox/outboxReducer'; import { reducer as pmConversations } from '../pm-conversations/pmConversationsModel'; import presence from '../presence/presenceReducer'; @@ -89,6 +90,7 @@ export default (state: void | GlobalState, action: Action): GlobalState => { messages: applyReducer('messages', messages, state?.messages, action, state), narrows: applyReducer('narrows', narrows, state?.narrows, action, state), mute: applyReducer('mute', mute, state?.mute, action, state), + mutedUsers: applyReducer('mutedUsers', mutedUsers, state?.mutedUsers, action, state), outbox: applyReducer('outbox', outbox, state?.outbox, action, state), pmConversations: applyReducer('pmConversations', pmConversations, state?.pmConversations, action, state), presence: applyReducer('presence', presence, state?.presence, action, state), diff --git a/src/boot/store.js b/src/boot/store.js index cff88b4e997..ccbc6f0ffe0 100644 --- a/src/boot/store.js +++ b/src/boot/store.js @@ -65,8 +65,8 @@ export const storeKeys: Array<$Keys> = [ */ // prettier-ignore export const cacheKeys: Array<$Keys> = [ - 'flags', 'messages', 'mute', 'narrows', 'pmConversations', 'realm', 'streams', - 'subscriptions', 'unread', 'userGroups', 'users', + 'flags', 'messages', 'mute', 'mutedUsers', 'narrows', 'pmConversations', + 'realm', 'streams', 'subscriptions', 'unread', 'userGroups', 'users', ]; /** diff --git a/src/config.js b/src/config.js index a2b026a8187..e51bb4642c1 100644 --- a/src/config.js +++ b/src/config.js @@ -25,6 +25,7 @@ const config: Config = { 'alert_words', 'message', 'muted_topics', + 'muted_users', 'presence', 'realm', 'realm_emoji', diff --git a/src/directSelectors.js b/src/directSelectors.js index c1e39f13f35..7d5eadd5b0f 100644 --- a/src/directSelectors.js +++ b/src/directSelectors.js @@ -6,6 +6,7 @@ import type { FlagsState, MessagesState, MuteState, + MutedUsersState, NarrowsState, TopicsState, PresenceState, @@ -44,6 +45,8 @@ export const getMessages = (state: GlobalState): MessagesState => state.messages export const getMute = (state: GlobalState): MuteState => state.mute; +export const getMutedUsers = (state: GlobalState): MutedUsersState => state.mutedUsers; + export const getTyping = (state: GlobalState): TypingState => state.typing; export const getTopics = (state: GlobalState): TopicsState => state.topics; diff --git a/src/events/eventToAction.js b/src/events/eventToAction.js index 5fda35814d6..e2077994193 100644 --- a/src/events/eventToAction.js +++ b/src/events/eventToAction.js @@ -19,6 +19,7 @@ import { EVENT_USER_REMOVE, EVENT_USER_UPDATE, EVENT_MUTED_TOPICS, + EVENT_MUTED_USERS, EVENT_USER_GROUP_ADD, EVENT_USER_GROUP_REMOVE, EVENT_USER_GROUP_UPDATE, @@ -59,6 +60,7 @@ const actionTypeOfEventType = { subscription: EVENT_SUBSCRIPTION, presence: EVENT_PRESENCE, muted_topics: EVENT_MUTED_TOPICS, + muted_users: EVENT_MUTED_USERS, realm_emoji: EVENT_REALM_EMOJI_UPDATE, realm_filters: EVENT_REALM_FILTERS, submessage: EVENT_SUBMESSAGE, @@ -143,6 +145,7 @@ export default (state: GlobalState, event: $FlowFixMe): EventAction | null => { case 'subscription': case 'presence': case 'muted_topics': + case 'muted_users': case 'realm_emoji': case 'realm_filters': case 'submessage': diff --git a/src/mute/__tests__/mutedUsersReducer-test.js b/src/mute/__tests__/mutedUsersReducer-test.js new file mode 100644 index 00000000000..d9ccb35b322 --- /dev/null +++ b/src/mute/__tests__/mutedUsersReducer-test.js @@ -0,0 +1,55 @@ +/* @flow strict-local */ +import Immutable from 'immutable'; +import deepFreeze from 'deep-freeze'; + +import mutedUsersReducer from '../mutedUsersReducer'; +import { EVENT_MUTED_USERS } from '../../actionConstants'; +import * as eg from '../../__tests__/lib/exampleData'; + +describe('mutedUsersReducer', () => { + const baseState = Immutable.Map([[eg.otherUser.user_id, 1618822632]]); + + describe('REALM_INIT', () => { + test('when `muted_users` data is provided init state with it', () => { + const action = deepFreeze({ + ...eg.action.realm_init, + data: { + ...eg.action.realm_init.data, + muted_users: [{ id: eg.otherUser.user_id, timestamp: 1618822632 }], + }, + }); + expect(mutedUsersReducer(eg.baseReduxState.mutedUsers, action)).toEqual( + Immutable.Map([[eg.otherUser.user_id, 1618822632]]), + ); + }); + + test('when no `muted_users` data is given reset state', () => { + expect(mutedUsersReducer(baseState, eg.action.realm_init)).toEqual(Immutable.Map()); + }); + }); + + describe('ACCOUNT_SWITCH', () => { + test('resets state to initial state', () => { + expect(mutedUsersReducer(baseState, eg.action.account_switch)).toEqual(Immutable.Map()); + }); + }); + + describe('EVENT_MUTED_USERS', () => { + test('update `muted_users` when event comes in', () => { + const action = deepFreeze({ + type: EVENT_MUTED_USERS, + id: 1234, + muted_users: [ + { id: eg.otherUser.user_id, timestamp: 1618822632 }, + { id: eg.thirdUser.user_id, timestamp: 1618822635 }, + ], + }); + + // prettier-ignore + expect(mutedUsersReducer(baseState, action)).toEqual(Immutable.Map([ + [eg.otherUser.user_id, 1618822632], + [eg.thirdUser.user_id, 1618822635], + ])); + }); + }); +}); diff --git a/src/mute/mutedUsersReducer.js b/src/mute/mutedUsersReducer.js new file mode 100644 index 00000000000..fffbc8f83bf --- /dev/null +++ b/src/mute/mutedUsersReducer.js @@ -0,0 +1,36 @@ +/* @flow strict-local */ +import Immutable from 'immutable'; + +import type { MutedUsersState, Action, UserId } from '../types'; +import { + REALM_INIT, + LOGIN_SUCCESS, + LOGOUT, + ACCOUNT_SWITCH, + EVENT_MUTED_USERS, +} from '../actionConstants'; +import type { MutedUser } from '../api/apiTypes'; + +const initialState: MutedUsersState = Immutable.Map(); + +function mutedUsersToMap(muted_users: MutedUser[]): Immutable.Map { + return Immutable.Map(muted_users.map(muted_user => [muted_user.id, muted_user.timestamp])); +} + +export default (state: MutedUsersState = initialState, action: Action): MutedUsersState => { + switch (action.type) { + case LOGOUT: + case ACCOUNT_SWITCH: + case LOGIN_SUCCESS: + return initialState; + + case REALM_INIT: + return mutedUsersToMap(action.data.muted_users ?? []); + + case EVENT_MUTED_USERS: + return mutedUsersToMap(action.muted_users); + + default: + return state; + } +}; diff --git a/src/reduxTypes.js b/src/reduxTypes.js index f44d5d7d311..a73b106a048 100644 --- a/src/reduxTypes.js +++ b/src/reduxTypes.js @@ -166,6 +166,9 @@ export type MigrationsState = {| export type MuteState = MuteTuple[]; +/** A map from user IDs to the Unix timestamp in seconds when they were muted. */ +export type MutedUsersState = Immutable.Map; + /** * An index on `MessagesState`, listing messages in each narrow. * @@ -320,6 +323,7 @@ export type GlobalState = {| messages: MessagesState, migrations: MigrationsState, mute: MuteState, + mutedUsers: MutedUsersState, narrows: NarrowsState, outbox: OutboxState, pmConversations: PmConversationsState, diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js index d5257882efa..8712cf605f7 100644 --- a/src/webview/MessageList.js +++ b/src/webview/MessageList.js @@ -15,6 +15,7 @@ import type { GetText, Message, MuteState, + MutedUsersState, Narrow, Outbox, ImageEmojiType, @@ -38,6 +39,7 @@ import { getFetchingForNarrow, getFirstUnreadIdInNarrow, getMute, + getMutedUsers, getOwnUser, getSettings, getSubscriptions, @@ -77,6 +79,7 @@ export type BackgroundData = $ReadOnly<{| debug: Debug, flags: FlagsState, mute: MuteState, + mutedUsers: MutedUsersState, ownUser: User, subscriptions: Subscription[], theme: ThemeName, @@ -234,11 +237,11 @@ class MessageList extends Component { narrow, showMessagePlaceholders, } = this.props; - const contentHtml = contentHtmlFromPieceDescriptors( + const contentHtml = contentHtmlFromPieceDescriptors({ backgroundData, narrow, - htmlPieceDescriptorsForShownMessages, - ); + htmlPieceDescriptors: htmlPieceDescriptorsForShownMessages, + }); const { auth, theme } = backgroundData; const html: string = getHtml(contentHtml, theme, { scrollMessageId: initialScrollMessageId, @@ -353,6 +356,7 @@ export default connect((state, props: OuterProps) => { debug: getDebug(state), flags: getFlags(state), mute: getMute(state), + mutedUsers: getMutedUsers(state), ownUser: getOwnUser(state), subscriptions: getSubscriptions(state), theme: getSettings(state).theme, diff --git a/src/webview/__tests__/generateInboundEvents-test.js b/src/webview/__tests__/generateInboundEvents-test.js index 1db52510e0c..8e969b4f54f 100644 --- a/src/webview/__tests__/generateInboundEvents-test.js +++ b/src/webview/__tests__/generateInboundEvents-test.js @@ -1,5 +1,6 @@ /* @flow strict-local */ import deepFreeze from 'deep-freeze'; +import Immutable from 'immutable'; import generateInboundEvents from '../generateInboundEvents'; import { flagsStateToStringList } from '../html/messageAsHtml'; @@ -15,6 +16,7 @@ describe('generateInboundEvents', () => { debug: eg.baseReduxState.session.debug, flags: eg.baseReduxState.flags, mute: [], + mutedUsers: Immutable.Map(), ownUser: eg.selfUser, subscriptions: [], theme: 'default', diff --git a/src/webview/generateInboundEvents.js b/src/webview/generateInboundEvents.js index f04beaf96b8..c0828ac5a2a 100644 --- a/src/webview/generateInboundEvents.js +++ b/src/webview/generateInboundEvents.js @@ -47,11 +47,11 @@ export type WebViewInboundEvent = const updateContent = (prevProps: Props, nextProps: Props): WebViewInboundEventContent => { const content = htmlBody( - contentHtmlFromPieceDescriptors( - nextProps.backgroundData, - nextProps.narrow, - nextProps.htmlPieceDescriptorsForShownMessages, - ), + contentHtmlFromPieceDescriptors({ + backgroundData: nextProps.backgroundData, + narrow: nextProps.narrow, + htmlPieceDescriptors: nextProps.htmlPieceDescriptorsForShownMessages, + }), nextProps.showMessagePlaceholders, ); const transitionProps = getMessageTransitionProps(prevProps, nextProps); diff --git a/src/webview/html/contentHtmlFromPieceDescriptors.js b/src/webview/html/contentHtmlFromPieceDescriptors.js index 0d1a4ae95e4..7a2ef86c5f1 100644 --- a/src/webview/html/contentHtmlFromPieceDescriptors.js +++ b/src/webview/html/contentHtmlFromPieceDescriptors.js @@ -6,11 +6,15 @@ import messageAsHtml from './messageAsHtml'; import messageHeaderAsHtml from './messageHeaderAsHtml'; import timeRowAsHtml from './timeRowAsHtml'; -export default ( +export default ({ + backgroundData, + narrow, + htmlPieceDescriptors, +}: {| backgroundData: BackgroundData, narrow: Narrow, htmlPieceDescriptors: HtmlPieceDescriptor[], -): string => { +|}): string => { const pieces = []; htmlPieceDescriptors.forEach(section => { if (section.message !== null) { diff --git a/src/webview/html/messageAsHtml.js b/src/webview/html/messageAsHtml.js index 4df03408884..3e94e5f78c2 100644 --- a/src/webview/html/messageAsHtml.js +++ b/src/webview/html/messageAsHtml.js @@ -73,7 +73,7 @@ $!${messageReactionListAsHtml(reactions, ownUser.user_id, allImageEmojiById)} const widgetBody = (message: Message | Outbox) => template` $!${message.content} -

Interactive message

To use, open on web or desktop

diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 7a2d7ddb0c7..3ae92c6b80e 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -437,7 +437,7 @@ function visibleReadMessageIds(): {| first: number, last: number |} { return { first, last }; } -/** DEPRECATED */ +/** DEPRECATED - consider using `node.closest('.message')` instead. */ const getMessageNode = (node: ?Node): ?Node => { let curNode = node; while (curNode && curNode.parentNode && curNode.parentNode !== documentBody) { diff --git a/src/webview/static/base.css b/src/webview/static/base.css index 38426576368..f8d4147100a 100644 --- a/src/webview/static/base.css +++ b/src/webview/static/base.css @@ -570,7 +570,7 @@ hr { } /* Our own "sorry, unsupported" message for widgets. */ -.widget { +.special-message { display: flex; flex-direction: column; align-items: center;