Skip to content

Commit 7bdbe42

Browse files
committed
autocomplete: Warn on @-mention when user is not subscribed.
Show a warning when @-mentioning a user who is not subscribed to the stream the user was mentioned in, with an option to subscribe them to the stream. Set up pipeline to enable the same. Closes #3373.
1 parent 0e2c187 commit 7bdbe42

File tree

2 files changed

+105
-3
lines changed

2 files changed

+105
-3
lines changed

src/autocomplete/AutocompleteView.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ type Props = $ReadOnly<{|
2020
text: string,
2121
selection: InputSelection,
2222
onAutocomplete: (input: string) => void,
23+
processAutoComplete: (completion: string, completionType: string) => void,
2324
|}>;
2425

2526
export default class AutocompleteView extends PureComponent<Props> {
2627
handleAutocomplete = (autocomplete: string) => {
27-
const { text, onAutocomplete, selection } = this.props;
28+
const { text, onAutocomplete, selection, processAutoComplete } = this.props;
29+
const { lastWordPrefix } = getAutocompleteFilter(text, selection);
2830
const newText = getAutocompletedText(text, autocomplete, selection);
31+
processAutoComplete(autocomplete, lastWordPrefix);
2932
onAutocomplete(newText);
3033
};
3134

src/compose/ComposeBox.js

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
UserOrBot,
1414
Dispatch,
1515
Dimensions,
16+
Subscription,
1617
} from '../types';
1718
import { connect } from '../react-redux';
1819
import {
@@ -27,18 +28,27 @@ import * as api from '../api';
2728
import { FloatingActionButton, Input } from '../common';
2829
import { showErrorAlert } from '../utils/info';
2930
import { IconDone, IconSend } from '../common/Icons';
30-
import { isStreamNarrow, isStreamOrTopicNarrow, topicNarrow } from '../utils/narrow';
31+
import {
32+
isStreamNarrow,
33+
isStreamOrTopicNarrow,
34+
topicNarrow,
35+
isPrivateNarrow,
36+
} from '../utils/narrow';
3137
import ComposeMenu from './ComposeMenu';
3238
import getComposeInputPlaceholder from './getComposeInputPlaceholder';
3339
import NotSubscribed from '../message/NotSubscribed';
3440
import AnnouncementOnly from '../message/AnnouncementOnly';
41+
import MentionedUserNotSubscribed from '../message/MentionedUserNotSubscribed';
42+
import AnimatedScaleComponent from '../animation/AnimatedScaleComponent';
3543

3644
import {
3745
getAuth,
3846
getIsAdmin,
3947
getSession,
4048
getLastMessageTopic,
4149
getActiveUsersByEmail,
50+
getStreamInNarrow,
51+
getSubscriptionForId,
4252
} from '../selectors';
4353
import {
4454
getIsActiveStreamSubscribed,
@@ -60,6 +70,7 @@ type SelectorProps = {|
6070
editMessage: ?EditMessage,
6171
draft: string,
6272
lastMessageTopic: string,
73+
subscription: Subscription | void,
6374
|};
6475

6576
type Props = $ReadOnly<{|
@@ -83,6 +94,7 @@ type State = {|
8394
message: string,
8495
height: number,
8596
selection: InputSelection,
97+
unsubscribedMentions: UserOrBot[],
8698
|};
8799

88100
export const updateTextInput = (textInput: ?TextInput, text: string): void => {
@@ -122,6 +134,7 @@ class ComposeBox extends PureComponent<Props, State> {
122134
topic: this.props.lastMessageTopic,
123135
message: this.props.draft,
124136
selection: { start: 0, end: 0 },
137+
unsubscribedMentions: [],
125138
};
126139

127140
componentWillUnmount() {
@@ -184,6 +197,64 @@ class ComposeBox extends PureComponent<Props, State> {
184197
dispatch(draftUpdate(narrow, message));
185198
};
186199

200+
handleMentionSubscribedCheck = (message: string) => {
201+
const { usersByEmail, subscription, narrow } = this.props;
202+
if (isPrivateNarrow(narrow)) {
203+
return;
204+
}
205+
206+
const unformattedMessage = message.split('**')[1];
207+
208+
// We skip user groups, for which autocompletes are of the form
209+
// `*<user_group_name>*`, and therefore, message.split('**')[1]
210+
// is undefined.
211+
if (unformattedMessage === undefined) {
212+
return;
213+
}
214+
const [userFullName, userId] = unformattedMessage.split('|');
215+
const unsubscribedMentions = this.state.unsubscribedMentions.slice();
216+
let mentionedUser;
217+
// eslint-disable-next-line no-unused-vars
218+
for (const [email, user] of usersByEmail) {
219+
if (userId !== undefined) {
220+
if (user.user_id === userId) {
221+
mentionedUser = user;
222+
break;
223+
}
224+
} else if (user.full_name === userFullName) {
225+
mentionedUser = user;
226+
break;
227+
}
228+
}
229+
if (
230+
mentionedUser
231+
&& subscription
232+
&& !subscription.subscribers.includes(mentionedUser.user_id)
233+
&& !unsubscribedMentions.includes(mentionedUser)
234+
) {
235+
unsubscribedMentions.push(mentionedUser);
236+
this.setState({ unsubscribedMentions });
237+
}
238+
};
239+
240+
handleMentionWarningDismiss = (user: UserOrBot) => {
241+
this.setState(prevState => ({
242+
unsubscribedMentions: prevState.unsubscribedMentions.filter(
243+
(x: UserOrBot) => x.user_id !== user.user_id,
244+
),
245+
}));
246+
};
247+
248+
clearMentionWarnings = () => {
249+
this.setState({ unsubscribedMentions: [] });
250+
};
251+
252+
processAutocomplete = (completion: string, completionType: string) => {
253+
if (completionType === '@') {
254+
this.handleMentionSubscribedCheck(completion);
255+
}
256+
};
257+
187258
handleMessageAutocomplete = (message: string) => {
188259
this.setMessageInputValue(message);
189260
};
@@ -250,6 +321,7 @@ class ComposeBox extends PureComponent<Props, State> {
250321
dispatch(addToOutbox(this.getDestinationNarrow(), message));
251322

252323
this.setMessageInputValue('');
324+
this.clearMentionWarnings();
253325
dispatch(sendTypingStop(narrow));
254326
};
255327

@@ -332,7 +404,15 @@ class ComposeBox extends PureComponent<Props, State> {
332404
};
333405

334406
render() {
335-
const { isTopicFocused, isMenuExpanded, height, message, topic, selection } = this.state;
407+
const {
408+
isTopicFocused,
409+
isMenuExpanded,
410+
height,
411+
message,
412+
topic,
413+
selection,
414+
unsubscribedMentions,
415+
} = this.state;
336416
const {
337417
ownEmail,
338418
narrow,
@@ -344,6 +424,18 @@ class ComposeBox extends PureComponent<Props, State> {
344424
isSubscribed,
345425
} = this.props;
346426

427+
const mentionWarnings = [];
428+
for (const user of unsubscribedMentions) {
429+
mentionWarnings.push(
430+
<MentionedUserNotSubscribed
431+
narrow={narrow}
432+
user={user}
433+
onDismiss={this.handleMentionWarningDismiss}
434+
key={user.user_id}
435+
/>,
436+
);
437+
}
438+
347439
if (!isSubscribed) {
348440
return <NotSubscribed narrow={narrow} />;
349441
} else if (isAnnouncementOnly && !isAdmin) {
@@ -358,6 +450,9 @@ class ComposeBox extends PureComponent<Props, State> {
358450

359451
return (
360452
<View style={this.styles.wrapper}>
453+
<AnimatedScaleComponent visible={mentionWarnings.length !== 0}>
454+
{mentionWarnings}
455+
</AnimatedScaleComponent>
361456
<View style={[this.styles.autocompleteWrapper, { marginBottom: height }]}>
362457
<TopicAutocomplete
363458
isFocused={isTopicFocused}
@@ -370,6 +465,7 @@ class ComposeBox extends PureComponent<Props, State> {
370465
selection={selection}
371466
text={message}
372467
onAutocomplete={this.handleMessageAutocomplete}
468+
processAutoComplete={this.processAutocomplete}
373469
/>
374470
</View>
375471
<View style={[this.styles.composeBox, style]} onLayout={this.handleLayoutChange}>
@@ -435,4 +531,7 @@ export default connect<SelectorProps, _, _>((state, props) => ({
435531
editMessage: getSession(state).editMessage,
436532
draft: getDraftForNarrow(state, props.narrow),
437533
lastMessageTopic: getLastMessageTopic(state, props.narrow),
534+
subscription: isPrivateNarrow(props.narrow)
535+
? undefined
536+
: getSubscriptionForId(state, getStreamInNarrow(state, props.narrow).stream_id),
438537
}))(ComposeBox);

0 commit comments

Comments
 (0)