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
12 changes: 8 additions & 4 deletions src/autocomplete/TopicAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const styles = createStyleSheet({
});

type Props = $ReadOnly<{|
narrow: Narrow,
/** If null, this component won't do anything. */
narrow: Narrow | null,

isFocused: boolean,
text: string,
onAutocomplete: (name: string) => void,
Expand All @@ -28,7 +30,7 @@ type Props = $ReadOnly<{|
export default function TopicAutocomplete(props: Props): Node {
const { narrow, isFocused, text, onAutocomplete } = props;
const dispatch = useDispatch();
const topics = useSelector(state => getTopicsForNarrow(state, narrow));
const topics = useSelector(state => (narrow ? getTopicsForNarrow(state, narrow) : []));

useEffect(() => {
// The following should be sufficient to ensure we're up-to-date
Expand All @@ -43,10 +45,12 @@ export default function TopicAutocomplete(props: Props): Node {
//
// The latter is already taken care of (see handling of
// EVENT_NEW_MESSAGE in topicsReducer). So, do the former here.
dispatch(fetchTopicsForStream(narrow));
if (narrow) {
dispatch(fetchTopicsForStream(narrow));
}
}, [dispatch, narrow]);

if (!isFocused) {
if (!isFocused || !narrow) {
return null;
}

Expand Down
13 changes: 7 additions & 6 deletions src/drafts/__tests__/draftsSelectors-test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import deepFreeze from 'deep-freeze';
/* @flow strict-local */

import { getDraftForNarrow } from '../draftsSelectors';
import { topicNarrow, keyFromNarrow } from '../../utils/narrow';
import * as eg from '../../__tests__/lib/exampleData';

describe('getDraftForNarrow', () => {
test('return content of draft if exists', () => {
const narrow = topicNarrow('stream', 'topic');
const state = deepFreeze({
const narrow = topicNarrow(eg.stream.name, 'topic');
const state = eg.reduxState({
drafts: {
[keyFromNarrow(narrow)]: 'content',
},
Expand All @@ -18,14 +19,14 @@ describe('getDraftForNarrow', () => {
});

test('return empty string if not exists', () => {
const narrow = topicNarrow('stream', 'topic');
const state = deepFreeze({
const narrow = topicNarrow(eg.stream.name, 'topic');
const state = eg.reduxState({
drafts: {
[keyFromNarrow(narrow)]: 'content',
},
});

const draft = getDraftForNarrow(state, topicNarrow('stream', 'topic1'));
const draft = getDraftForNarrow(state, topicNarrow(eg.stream.name, 'topic1'));

expect(draft).toEqual('');
});
Expand Down
6 changes: 3 additions & 3 deletions src/message/__tests__/fetchActions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import * as logging from '../../utils/logging';

const mockStore = configureStore([thunk]);

const narrow = streamNarrow('some stream');
const narrow = streamNarrow(eg.stream.name);
const streamNarrowStr = keyFromNarrow(narrow);

global.FormData = class FormData {};
Expand Down Expand Up @@ -54,7 +54,7 @@ describe('fetchActions', () => {
narrows: Immutable.Map({
[streamNarrowStr]: [],
}),
streams: [eg.makeStream({ name: 'some stream' })],
streams: [eg.stream],
});

const result = isFetchNeededAtAnchor(state, narrow, FIRST_UNREAD_ANCHOR);
Expand All @@ -77,7 +77,7 @@ describe('fetchActions', () => {
[streamNarrowStr]: [1],
}),
messages: eg.makeMessagesState([message1, message2]),
streams: [eg.makeStream({ name: 'some stream' })],
streams: [eg.stream],
});

const result = isFetchNeededAtAnchor(state, narrow, FIRST_UNREAD_ANCHOR);
Expand Down
4 changes: 2 additions & 2 deletions src/message/__tests__/messageActions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { Action } from '../../actionTypes';

const mockStore = configureStore([thunk]);

const streamNarrowObj = streamNarrow('some stream');
const streamNarrowObj = streamNarrow(eg.stream.name);

describe('messageActions', () => {
describe('doNarrow', () => {
Expand All @@ -25,7 +25,7 @@ describe('messageActions', () => {
eg.reduxState({
accounts: [eg.selfAccount],
session: { ...eg.baseReduxState.session, isHydrated: true },
streams: [eg.makeStream({ name: 'some stream' })],
streams: [eg.stream],
}),
);

Expand Down
10 changes: 8 additions & 2 deletions src/message/messagesActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getMessageIdFromLink, getNarrowFromLink } from '../utils/internalLinks'
import { openLinkWithUserPreference } from '../utils/openLink';
import { navigateToChat } from '../nav/navActions';
import { FIRST_UNREAD_ANCHOR } from '../anchor';
import { getStreamsById } from '../subscriptions/subscriptionSelectors';
import { getStreamsById, getStreamsByName } from '../subscriptions/subscriptionSelectors';
import * as api from '../api';
import { isUrlOnRealm } from '../utils/url';
import { getOwnUserId } from '../users/userSelectors';
Expand All @@ -30,8 +30,14 @@ export const messageLinkPress = (href: string): ThunkAction<Promise<void>> => as
const state = getState();
const auth = getAuth(state);
const streamsById = getStreamsById(state);
const streamsByName = getStreamsByName(state);
const ownUserId = getOwnUserId(state);
const narrow = getNarrowFromLink(href, auth.realm, streamsById, ownUserId);
const narrow = getNarrowFromLink(href, auth.realm, streamsById, streamsByName, ownUserId);
// TODO: In some cases getNarrowFromLink successfully parses the link, but
// finds it points somewhere we can't see: in particular, to a stream
// that's hidden from our user (perhaps doesn't exist.) For those,
// perhaps give an error instead of falling back to opening in browser,
// which should be futile.
if (narrow) {
const anchor = getMessageIdFromLink(href, auth.realm);
dispatch(doNarrow(narrow, anchor));
Expand Down
80 changes: 58 additions & 22 deletions src/sharing/ShareToStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ import type { ComponentType } from 'react';
import type { ValidationError } from './ShareWrapper';
import type { SharingNavigationProp } from './SharingScreen';
import type { RouteProp } from '../react-navigation';
import type { Dispatch, Subscription, Auth, GetText } from '../types';
import type { Dispatch, Auth, GetText, Stream } from '../types';
import type { SharedData } from './types';
import { TranslationContext } from '../boot/TranslationProvider';
import { connect } from '../react-redux';
import { Input } from '../common';
import { getSubscriptionsById } from '../subscriptions/subscriptionSelectors';
import StreamAutocomplete from '../autocomplete/StreamAutocomplete';
import TopicAutocomplete from '../autocomplete/TopicAutocomplete';
import AnimatedScaleComponent from '../animation/AnimatedScaleComponent';
import { streamNarrow } from '../utils/narrow';
import { getAuth, getRealm } from '../selectors';
import { getAuth, getRealm, getStreamsByName } from '../selectors';
import { fetchTopicsForStream } from '../topics/topicActions';
import ShareWrapper from './ShareWrapper';

Expand All @@ -26,7 +25,7 @@ type OuterProps = $ReadOnly<{|
|}>;

type SelectorProps = $ReadOnly<{|
subscriptions: Map<number, Subscription>,
streamsByName: Map<string, Stream>,
auth: Auth,
mandatoryTopics: boolean,
|}>;
Expand All @@ -39,7 +38,12 @@ type Props = $ReadOnly<{|
|}>;

type State = $ReadOnly<{|
stream: string,
/** The text the user has typed into the "stream name" field. */
streamName: string,

/** An actual stream ID corresponding to streamName, or null if none does. */
streamId: number | null,

topic: string,
isStreamFocused: boolean,
isTopicFocused: boolean,
Expand All @@ -50,7 +54,8 @@ class ShareToStreamInner extends React.Component<Props, State> {
context: GetText;

state = {
stream: '',
streamName: '',
streamId: null,
topic: '',
isStreamFocused: false,
isTopicFocused: false,
Expand All @@ -69,16 +74,27 @@ class ShareToStreamInner extends React.Component<Props, State> {
};

focusTopic = () => {
const { stream } = this.state;
const { streamName, streamId } = this.state;
const { dispatch } = this.props;
const narrow = streamNarrow(stream);

dispatch(fetchTopicsForStream(narrow));
if (streamId !== null) {
// We have an actual stream selected. Fetch its recent topic names.
// TODO: why do we do this? `TopicAutocomplete` does the same thing,
// with an effect that reruns when the stream changes.
const narrow = streamNarrow(streamName);
dispatch(fetchTopicsForStream(narrow));
}
// If what's entered in the stream-name field isn't an actual stream,
// then there's no point fetching topics.
// … Maybe we shouldn't be allowing this interaction in that case in
// the first place?

this.setState({ isTopicFocused: true });
};

handleStreamChange = stream => {
this.setState({ stream });
handleStreamChange = streamName => {
const stream = this.props.streamsByName.get(streamName);
this.setState({ streamName, streamId: stream ? stream.stream_id : null });
};

handleTopicChange = topic => {
Expand All @@ -87,8 +103,15 @@ class ShareToStreamInner extends React.Component<Props, State> {

handleStreamAutoComplete = (rawStream: string) => {
// TODO: What is this for? (write down our assumptions)
const stream = rawStream.split('**')[1];
this.setState({ stream, isStreamFocused: false });
const streamName = rawStream.split('**')[1];
const stream = this.props.streamsByName.get(streamName);
this.setState({
streamName,
// TODO the "else" case ought to be impossible: the user chose a
// stream in autocomplete, and we couldn't find it by name.
streamId: stream ? stream.stream_id : null,
isStreamFocused: false,
});
};

handleTopicAutoComplete = (topic: string) => {
Expand All @@ -97,12 +120,16 @@ class ShareToStreamInner extends React.Component<Props, State> {

getValidationErrors: string => $ReadOnlyArray<ValidationError> = message => {
const { mandatoryTopics } = this.props;
const { stream, topic } = this.state;
const { streamName, streamId, topic } = this.state;
const { sharedData } = this.props.route.params;

const result = [];

if (stream.trim() === '') {
if (streamId === null) {
result.push('stream-invalid');
}

if (streamName.trim() === '') {
result.push('stream-empty');
}

Expand All @@ -119,9 +146,18 @@ class ShareToStreamInner extends React.Component<Props, State> {

render() {
const { sharedData } = this.props.route.params;
const { stream, topic, isStreamFocused, isTopicFocused } = this.state;
const narrow = streamNarrow(stream);
const sendTo = { stream, topic, type: 'stream' };
const { streamName, streamId, topic, isStreamFocused, isTopicFocused } = this.state;
const narrow = streamId !== null ? streamNarrow(streamName) : null;
const sendTo = {
streamName,
/* $FlowFixMe[incompatible-cast]: ShareWrapper will only look at this
* if getValidationErrors returns empty, so only if streamId is
* indeed not null. Should make that logic less indirected and more
* transparent. */
streamId: (streamId: number),
topic,
type: 'stream',
};

return (
<ShareWrapper
Expand All @@ -130,11 +166,11 @@ class ShareToStreamInner extends React.Component<Props, State> {
sendTo={sendTo}
>
<AnimatedScaleComponent visible={isStreamFocused}>
<StreamAutocomplete filter={stream} onAutocomplete={this.handleStreamAutoComplete} />
<StreamAutocomplete filter={streamName} onAutocomplete={this.handleStreamAutoComplete} />
</AnimatedScaleComponent>
<Input
placeholder="Stream"
value={stream}
value={streamName}
onChangeText={this.handleStreamChange}
onFocus={this.focusStream}
onChange={this.focusStream}
Expand All @@ -155,7 +191,7 @@ class ShareToStreamInner extends React.Component<Props, State> {
onFocus={this.focusTopic}
onBlur={this.blurTopic}
onChangeText={this.handleTopicChange}
editable={stream !== ''}
editable={streamName !== ''}
autoCapitalize="none"
/>
</ShareWrapper>
Expand All @@ -164,7 +200,7 @@ class ShareToStreamInner extends React.Component<Props, State> {
}

const ShareToStream: ComponentType<OuterProps> = connect(state => ({
subscriptions: getSubscriptionsById(state),
streamsByName: getStreamsByName(state),
auth: getAuth(state),
mandatoryTopics: getRealm(state).mandatoryTopics,
}))(ShareToStreamInner);
Expand Down
26 changes: 17 additions & 9 deletions src/sharing/ShareWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { IconAttachment, IconCancel } from '../common/Icons';

type SendTo =
| {| type: 'pm', selectedRecipients: $ReadOnlyArray<UserId> |}
| {| type: 'stream', stream: string, topic: string |};
// TODO(#3918): Drop streamName. Used below for sending, and for narrow.
| {| type: 'stream', streamName: string, streamId: number, topic: string |};

const styles = createStyleSheet({
wrapper: {
Expand Down Expand Up @@ -60,6 +61,7 @@ const styles = createStyleSheet({
export type ValidationError =
| 'mandatory-topic-empty'
| 'stream-empty'
| 'stream-invalid'
| 'recipients-empty'
| 'message-empty';

Expand Down Expand Up @@ -132,6 +134,8 @@ class ShareWrapperInner extends React.Component<Props, State> {
switch (error) {
case 'stream-empty':
return _('Please specify a stream.');
case 'stream-invalid':
return _('Please specify a valid stream.');
case 'mandatory-topic-empty':
return _('Please specify a topic.');
case 'recipients-empty':
Expand Down Expand Up @@ -169,27 +173,31 @@ class ShareWrapperInner extends React.Component<Props, State> {
content: messageToSend,
type: 'stream',
subject: sendTo.topic || apiConstants.NO_TOPIC_TOPIC,
to: sendTo.stream,
// TODO(server-2.0): switch to numeric stream ID, not name
to: sendTo.streamName,
};

try {
await api.sendMessage(auth, messageData);
} catch (err) {
showToast(_('Failed to send message'));
logging.error(err);
this.onShareCancelled();
this.onShareCancelled(sendTo);
return;
}
showToast(_('Message sent'));
this.onShareSuccess();
this.onShareSuccess(sendTo);
};

onShareCancelled = () => {
onShareCancelled = sendTo => {
// If in the future this callback uses the `sendTo` data, it should get
// it from its parameter (just like `onShareSuccess` does) and not from
// the props. That's because the props may have changed since the
// actual send request we just made.
Comment on lines +195 to +196
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Jan 4, 2022

Choose a reason for hiding this comment

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

I guess the proposed change doesn't add a ton of complexity, but also: in the first place, should the UI allow changing the message's destination while a send request is in progress? I think disallowing that is better than allowing it.

I can see wanting to change it after a request has started—but only if the request has failed or we've timed out on it, we've notified the user, and we want to let the user initiate a retry.

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, disallowing that in the UI would be quite reasonable.

Even then, I think it'd be cleaner for these callbacks to get sendTo from their caller (as they do with this change), rather than from the component's props (as they do before). That way the fact that this code all agrees on what conversation it's referring to is something that's straightforward to see locally -- the information gets read once and then passed around -- rather than have to wonder if there might be a way for it to get mutated between the different times we read it off the props.

NavigationService.dispatch(navigateBack());
};

onShareSuccess = () => {
const { sendTo } = this.props;
onShareSuccess = sendTo => {
switch (sendTo.type) {
case 'pm': {
const { selectedRecipients } = sendTo;
Expand All @@ -201,8 +209,8 @@ class ShareWrapperInner extends React.Component<Props, State> {
}

case 'stream': {
const { stream } = sendTo;
const narrow = streamNarrow(stream);
const { streamName } = sendTo;
const narrow = streamNarrow(streamName);
NavigationService.dispatch(replaceWithChat(narrow));
break;
}
Expand Down
Loading