diff --git a/src/chat/ChatScreen.js b/src/chat/ChatScreen.js index 3942caf4756..ac2ef085e67 100644 --- a/src/chat/ChatScreen.js +++ b/src/chat/ChatScreen.js @@ -1,5 +1,5 @@ /* @flow strict-local */ -import React from 'react'; +import React, { useCallback, useContext } from 'react'; import type { Node } from 'react'; import { useIsFocused } from '@react-navigation/native'; @@ -17,11 +17,17 @@ import InvalidNarrow from './InvalidNarrow'; import { fetchMessagesInNarrow } from '../message/fetchActions'; import ComposeBox from '../compose/ComposeBox'; import UnreadNotice from './UnreadNotice'; -import { canSendToNarrow } from '../utils/narrow'; +import { canSendToNarrow, caseNarrowDefault, keyFromNarrow } from '../utils/narrow'; import { getLoading, getSession } from '../directSelectors'; import { getFetchingForNarrow } from './fetchingSelectors'; import { getShownMessagesForNarrow, isNarrowValid as getIsNarrowValid } from './narrowsSelectors'; import { getFirstUnreadIdInNarrow } from '../message/messageSelectors'; +import { getDraftForNarrow } from '../drafts/draftsSelectors'; +import { addToOutbox } from '../actions'; +import { getAuth, getCaughtUpForNarrow } from '../selectors'; +import { showErrorAlert } from '../utils/info'; +import { TranslationContext } from '../boot/TranslationProvider'; +import * as api from '../api'; type Props = $ReadOnly<{| navigation: AppNavigationProp<'chat'>, @@ -103,10 +109,13 @@ export default function ChatScreen(props: Props): Node { const { backgroundColor } = React.useContext(ThemeContext); const { narrow, editMessage } = route.params; - const setEditMessage = (value: EditMessage | null) => - navigation.setParams({ editMessage: value }); + const setEditMessage = useCallback( + (value: EditMessage | null) => navigation.setParams({ editMessage: value }), + [navigation], + ); const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow)); + const draft = useSelector(state => getDraftForNarrow(state, narrow)); const { fetchError, @@ -120,6 +129,43 @@ export default function ChatScreen(props: Props): Node { const sayNoMessages = haveNoMessages && !isFetching; const showComposeBox = canSendToNarrow(narrow) && !showMessagePlaceholders; + const auth = useSelector(getAuth); + const dispatch = useDispatch(); + const caughtUp = useSelector(state => getCaughtUpForNarrow(state, narrow)); + const _ = useContext(TranslationContext); + + const sendCallback = useCallback( + (message: string, destinationNarrow: Narrow) => { + if (editMessage) { + const content = editMessage.content !== message ? message : undefined; + const subject = caseNarrowDefault( + destinationNarrow, + { topic: (stream, topic) => (topic !== editMessage.topic ? topic : undefined) }, + () => undefined, + ); + + if ( + (content !== undefined && content !== '') + || (subject !== undefined && subject !== '') + ) { + api.updateMessage(auth, { content, subject }, editMessage.id).catch(error => { + showErrorAlert(_('Failed to edit message'), error.message); + }); + } + + setEditMessage(null); + } else { + if (!caughtUp.newer) { + showErrorAlert(_('Failed to send message')); + return; + } + + dispatch(addToOutbox(destinationNarrow, message)); + } + }, + [_, auth, caughtUp.newer, dispatch, editMessage, setEditMessage], + ); + return ( @@ -147,8 +193,12 @@ export default function ChatScreen(props: Props): Node { {showComposeBox && ( setEditMessage(null)} + isEditing={editMessage !== null} + initialTopic={editMessage ? editMessage.topic : undefined} + initialMessage={editMessage ? editMessage.content : draft} + onSend={sendCallback} + autoFocusMessage={editMessage !== null} + key={keyFromNarrow(narrow) + (editMessage?.id.toString() ?? 'noedit')} /> )} diff --git a/src/compose/ComposeBox.js b/src/compose/ComposeBox.js index 7daf3f88f92..765be789784 100644 --- a/src/compose/ComposeBox.js +++ b/src/compose/ComposeBox.js @@ -15,11 +15,9 @@ import { ThemeContext } from '../styles'; import type { Auth, Narrow, - EditMessage, InputSelection, UserOrBot, Dispatch, - CaughtUp, GetText, Subscription, Stream, @@ -28,16 +26,17 @@ import type { } from '../types'; import { connect } from '../react-redux'; import { withGetText } from '../boot/TranslationProvider'; -import { addToOutbox, draftUpdate, sendTypingStart, sendTypingStop } from '../actions'; -import * as api from '../api'; +import { draftUpdate, sendTypingStart, sendTypingStop } from '../actions'; import { FloatingActionButton, Input } from '../common'; -import { showErrorAlert, showToast } from '../utils/info'; +import { showToast } from '../utils/info'; import { IconDone, IconSend } from '../common/Icons'; import { isStreamNarrow, isStreamOrTopicNarrow, + isTopicNarrow, streamNameOfNarrow, topicNarrow, + topicOfNarrow, } from '../utils/narrow'; import ComposeMenu from './ComposeMenu'; import getComposeInputPlaceholder from './getComposeInputPlaceholder'; @@ -45,22 +44,15 @@ import NotSubscribed from '../message/NotSubscribed'; import AnnouncementOnly from '../message/AnnouncementOnly'; import MentionWarnings from './MentionWarnings'; -import { - getAuth, - getIsAdmin, - getLastMessageTopic, - getCaughtUpForNarrow, - getStreamInNarrow, - getVideoChatProvider, -} from '../selectors'; +import { getAuth, getIsAdmin, getStreamInNarrow, getVideoChatProvider } from '../selectors'; import { getIsActiveStreamSubscribed, getIsActiveStreamAnnouncementOnly, } from '../subscriptions/subscriptionSelectors'; -import { getDraftForNarrow } from '../drafts/draftsSelectors'; import TopicAutocomplete from '../autocomplete/TopicAutocomplete'; import AutocompleteView from '../autocomplete/AutocompleteView'; import { getAllUsersById, getOwnUserId } from '../users/userSelectors'; +import * as api from '../api'; type SelectorProps = {| auth: Auth, @@ -69,21 +61,35 @@ type SelectorProps = {| isAdmin: boolean, isAnnouncementOnly: boolean, isSubscribed: boolean, - draft: string, - lastMessageTopic: string, - caughtUp: CaughtUp, videoChatProvider: VideoChatProvider | null, stream: Subscription | {| ...Stream, in_home_view: boolean |}, |}; type OuterProps = $ReadOnly<{| narrow: Narrow, - editMessage: EditMessage | null, - completeEditMessage: () => void, + + onSend: (string, Narrow) => void, + + isEditing: boolean, + + /** The contents of the message that the ComposeBox should contain when it's first rendered */ + initialMessage?: string, + /** The topic of the message that the ComposeBox should contain when it's first rendered */ + initialTopic?: string, + + /** Whether the topic input box should auto-foucs when the component renders. + * + * Passed through to the TextInput's autofocus prop. */ + autoFocusTopic?: boolean, + /** Whether the message input box should auto-foucs when the component renders. + * + * Passed through to the TextInput's autofocus prop. */ + autoFocusMessage?: boolean, |}>; type Props = $ReadOnly<{| ...OuterProps, + ...SelectorProps, // From 'withGetText' _: GetText, @@ -155,8 +161,10 @@ class ComposeBoxInner extends PureComponent { isFocused: false, isMenuExpanded: false, height: 20, - topic: this.props.lastMessageTopic, - message: this.props.draft, + topic: + this.props.initialTopic + ?? (isTopicNarrow(this.props.narrow) ? topicOfNarrow(this.props.narrow) : ''), + message: this.props.initialMessage ?? '', selection: { start: 0, end: 0 }, numUploading: 0, }; @@ -174,8 +182,8 @@ class ComposeBoxInner extends PureComponent { }; getCanSelectTopic = () => { - const { editMessage, narrow } = this.props; - if (editMessage) { + const { isEditing, narrow } = this.props; + if (isEditing) { return isStreamOrTopicNarrow(narrow); } if (!isStreamNarrow(narrow)) { @@ -291,9 +299,11 @@ class ComposeBoxInner extends PureComponent { handleMessageChange = (message: string) => { this.setState({ message, isMenuExpanded: false }); - const { dispatch, narrow } = this.props; + const { dispatch, isEditing, narrow } = this.props; dispatch(sendTypingStart(narrow)); - dispatch(draftUpdate(narrow, message)); + if (!isEditing) { + dispatch(draftUpdate(narrow, message)); + } }; // See JSDoc on 'onAutocomplete' in 'AutocompleteView.js'. @@ -320,13 +330,11 @@ class ComposeBoxInner extends PureComponent { }; handleMessageFocus = () => { - this.setState((state, { lastMessageTopic }) => ({ - ...state, - topic: state.topic || lastMessageTopic, + this.setState({ isMessageFocused: true, isFocused: true, isMenuExpanded: false, - })); + }); }; handleMessageBlur = () => { @@ -362,8 +370,8 @@ class ComposeBoxInner extends PureComponent { }; getDestinationNarrow = (): Narrow => { - const { narrow } = this.props; - if (isStreamNarrow(narrow)) { + const { narrow, isEditing } = this.props; + if (isStreamNarrow(narrow) || (isTopicNarrow(narrow) && isEditing)) { const streamName = streamNameOfNarrow(narrow); const topic = this.state.topic.trim(); return topicNarrow(streamName, topic || '(no topic)'); @@ -372,15 +380,11 @@ class ComposeBoxInner extends PureComponent { }; handleSend = () => { - const { dispatch, narrow, caughtUp, _ } = this.props; + const { dispatch } = this.props; const { message } = this.state; + const narrow = this.getDestinationNarrow(); - if (!caughtUp.newer) { - showErrorAlert(_('Failed to send message')); - return; - } - - dispatch(addToOutbox(this.getDestinationNarrow(), message)); + this.props.onSend(message, narrow); this.setMessageInputValue(''); @@ -391,41 +395,6 @@ class ComposeBoxInner extends PureComponent { dispatch(sendTypingStop(narrow)); }; - handleEdit = () => { - const { auth, editMessage, completeEditMessage, _ } = this.props; - if (!editMessage) { - throw new Error('expected editMessage'); - } - const { message, topic } = this.state; - const content = editMessage.content !== message ? message : undefined; - const subject = topic !== editMessage.topic ? topic : undefined; - if ((content !== undefined && content !== '') || (subject !== undefined && subject !== '')) { - api.updateMessage(auth, { content, subject }, editMessage.id).catch(error => { - showErrorAlert(_('Failed to edit message'), error.message); - }); - } - completeEditMessage(); - if (this.messageInputRef.current !== null) { - // `.current` is not type-checked; see definition. - this.messageInputRef.current.blur(); - } - }; - - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if (nextProps.editMessage !== this.props.editMessage) { - const topic = nextProps.editMessage - ? nextProps.editMessage.topic - : nextProps.lastMessageTopic; - const message = nextProps.editMessage ? nextProps.editMessage.content : ''; - this.setMessageInputValue(message); - this.setTopicInputValue(topic); - if (this.messageInputRef.current !== null) { - // `.current` is not type-checked; see definition. - this.messageInputRef.current.focus(); - } - } - } - inputMarginPadding = { paddingHorizontal: 8, paddingVertical: Platform.select({ @@ -477,7 +446,7 @@ class ComposeBoxInner extends PureComponent { ownUserId, narrow, allUsersById, - editMessage, + isEditing, insets, isAdmin, isAnnouncementOnly, @@ -533,6 +502,7 @@ class ComposeBoxInner extends PureComponent { underlineColorAndroid="transparent" placeholder="Topic" defaultValue={topic} + autoFocus={this.props.autoFocusTopic} selectTextOnFocus textInputRef={this.topicInputRef} onChangeText={this.handleTopicChange} @@ -550,6 +520,7 @@ class ComposeBoxInner extends PureComponent { underlineColorAndroid="transparent" placeholder={placeholder} defaultValue={message} + autoFocus={this.props.autoFocusMessage} textInputRef={this.messageInputRef} onBlur={this.handleMessageBlur} onChangeText={this.handleMessageChange} @@ -561,10 +532,10 @@ class ComposeBoxInner extends PureComponent { 0} - onPress={editMessage === null ? this.handleSend : this.handleEdit} + onPress={this.handleSend} /> @@ -580,9 +551,6 @@ const ComposeBox: ComponentType = compose( isAdmin: getIsAdmin(state), isAnnouncementOnly: getIsActiveStreamAnnouncementOnly(state, props.narrow), isSubscribed: getIsActiveStreamSubscribed(state, props.narrow), - draft: getDraftForNarrow(state, props.narrow), - lastMessageTopic: getLastMessageTopic(state, props.narrow), - caughtUp: getCaughtUpForNarrow(state, props.narrow), stream: getStreamInNarrow(state, props.narrow), videoChatProvider: getVideoChatProvider(state), })), diff --git a/src/topics/__tests__/topicsSelectors-test.js b/src/topics/__tests__/topicsSelectors-test.js index 8d0358bd335..8dbd721137f 100644 --- a/src/topics/__tests__/topicsSelectors-test.js +++ b/src/topics/__tests__/topicsSelectors-test.js @@ -1,8 +1,6 @@ /* @flow strict-local */ -import Immutable from 'immutable'; - -import { getTopicsForNarrow, getLastMessageTopic, getTopicsForStream } from '../topicSelectors'; -import { HOME_NARROW, streamNarrow, keyFromNarrow } from '../../utils/narrow'; +import { getTopicsForNarrow, getTopicsForStream } from '../topicSelectors'; +import { HOME_NARROW, streamNarrow } from '../../utils/narrow'; import { reducer as unreadReducer } from '../../unread/unreadModel'; import * as eg from '../../__tests__/lib/exampleData'; @@ -31,38 +29,6 @@ describe('getTopicsForNarrow', () => { }); }); -describe('getLastMessageTopic', () => { - test('when no messages in narrow return an empty string', () => { - const state = eg.reduxState({ - narrows: Immutable.Map({}), - users: [eg.selfUser], - realm: eg.realmState({ user_id: eg.selfUser.user_id, email: eg.selfUser.email }), - }); - - const topic = getLastMessageTopic(state, HOME_NARROW); - - expect(topic).toEqual(''); - }); - - test('when one or more messages return the topic of the last one', () => { - const narrow = streamNarrow('hello'); - const message1 = eg.streamMessage({ id: 1 }); - const message2 = eg.streamMessage({ id: 2, subject: 'some topic' }); - const state = eg.reduxState({ - narrows: Immutable.Map({ - [keyFromNarrow(narrow)]: [1, 2], - }), - messages: eg.makeMessagesState([message1, message2]), - users: [eg.selfUser], - realm: eg.realmState({ user_id: eg.selfUser.user_id, email: eg.selfUser.email }), - }); - - const topic = getLastMessageTopic(state, narrow); - - expect(topic).toEqual(message2.subject); - }); -}); - describe('getTopicsForStream', () => { test('when no topics loaded for given stream return undefined', () => { const state = eg.reduxState({ diff --git a/src/topics/topicSelectors.js b/src/topics/topicSelectors.js index 2360a6abde0..b090ca8a248 100644 --- a/src/topics/topicSelectors.js +++ b/src/topics/topicSelectors.js @@ -1,17 +1,9 @@ /* @flow strict-local */ import { createSelector } from 'reselect'; -import type { - Narrow, - GlobalState, - Selector, - StreamsState, - TopicExtended, - TopicsState, -} from '../types'; +import type { Narrow, Selector, StreamsState, TopicExtended, TopicsState } from '../types'; import { getMute, getStreams, getTopics } from '../directSelectors'; import { getUnread, getUnreadCountForTopic } from '../unread/unreadModel'; -import { getShownMessagesForNarrow } from '../chat/narrowsSelectors'; import { getStreamsById } from '../subscriptions/subscriptionSelectors'; import { NULL_ARRAY } from '../nullObjects'; import { isStreamNarrow, streamNameOfNarrow } from '../utils/narrow'; @@ -57,8 +49,3 @@ export const getTopicsForStream: Selector = createSe }); }, ); - -export const getLastMessageTopic = (state: GlobalState, narrow: Narrow): string => { - const messages = getShownMessagesForNarrow(state, narrow); - return messages.length === 0 ? '' : messages[messages.length - 1].subject; -};