diff --git a/src/api/index.js b/src/api/index.js index 1f6219f802f..a65263be063 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -42,6 +42,7 @@ import subscriptionRemove from './subscriptions/subscriptionRemove'; import toggleMuteStream from './subscriptions/toggleMuteStream'; import togglePinStream from './subscriptions/togglePinStream'; import toggleStreamNotifications from './subscriptions/toggleStreamNotifications'; +import getSubscriptionToStream from './subscriptions/getSubscriptionToStream'; import unmuteTopic from './subscriptions/unmuteTopic'; import tryGetFileTemporaryUrl from './tryGetFileTemporaryUrl'; import createUserGroup from './user_groups/createUserGroup'; @@ -97,6 +98,7 @@ export { muteTopic, subscriptionAdd, subscriptionRemove, + getSubscriptionToStream, toggleMuteStream, togglePinStream, toggleStreamNotifications, diff --git a/src/api/subscriptions/getSubscriptionToStream.js b/src/api/subscriptions/getSubscriptionToStream.js new file mode 100644 index 00000000000..1c521d58f7b --- /dev/null +++ b/src/api/subscriptions/getSubscriptionToStream.js @@ -0,0 +1,21 @@ +/* @flow strict-local */ +import type { Auth, ApiResponseSuccess } from '../transportTypes'; +import { apiGet } from '../apiFetch'; + +type ApiResponseSubscriptionStatus = {| + ...ApiResponseSuccess, + is_subscribed: boolean, +|}; + +/** + * Get whether a user is subscribed to a particular stream. + * + * See https://zulip.com/api/get-subscription-status for + * documentation of this endpoint. + */ +export default ( + auth: Auth, + userId: number, + streamId: number, +): Promise => + apiGet(auth, `users/${userId}/subscriptions/${streamId}`); diff --git a/src/autocomplete/AutocompleteView.js b/src/autocomplete/AutocompleteView.js index b7a1f3ba157..9e4bda4adc7 100644 --- a/src/autocomplete/AutocompleteView.js +++ b/src/autocomplete/AutocompleteView.js @@ -19,14 +19,24 @@ type Props = $ReadOnly<{| isFocused: boolean, text: string, selection: InputSelection, - onAutocomplete: (input: string) => void, + + /** + * The callback that is called when the user taps on any of the suggested items. + * + * @param input The text entered by the user, modified to include the autocompletion. + * @param completion The suggestion selected by the user. Includes markdown formatting, + but not the prefix. Eg. **FullName**, **StreamName**. + * @param lastWordPrefix The type of the autocompletion - valid values are keys of 'prefixToComponent'. + */ + onAutocomplete: (input: string, completion: string, lastWordPrefix: string) => void, |}>; export default class AutocompleteView extends PureComponent { handleAutocomplete = (autocomplete: string) => { const { text, onAutocomplete, selection } = this.props; + const { lastWordPrefix } = getAutocompleteFilter(text, selection); const newText = getAutocompletedText(text, autocomplete, selection); - onAutocomplete(newText); + onAutocomplete(newText, autocomplete, lastWordPrefix); }; render() { diff --git a/src/common/ZulipButton.js b/src/common/ZulipButton.js index 45a76313c35..4a653a74e22 100644 --- a/src/common/ZulipButton.js +++ b/src/common/ZulipButton.js @@ -1,7 +1,7 @@ /* @flow strict-local */ import React, { PureComponent } from 'react'; import { StyleSheet, Text, View, ActivityIndicator } from 'react-native'; -import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet'; +import type { TextStyleProp, ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet'; import TranslatedText from './TranslatedText'; import type { LocalizableText } from '../types'; @@ -62,6 +62,7 @@ const styles = StyleSheet.create({ type Props = $ReadOnly<{| style?: ViewStyleProp, + textStyle?: TextStyleProp, progress: boolean, disabled: boolean, Icon?: SpecificIconType, @@ -78,6 +79,7 @@ type Props = $ReadOnly<{| * have their `secondary` property set to `true`. * * @prop [style] - Style object applied to the outermost component. + * @prop [textStyle] - Style applied to the button text. * @prop [progress] - Shows a progress indicator in place of the button text. * @prop [disabled] - If set the button is not pressable and visually looks disabled. * @prop [Icon] - Icon component to display in front of the button text @@ -110,6 +112,7 @@ export default class ZulipButton extends PureComponent { const textStyle = [ styles.text, disabled ? styles.disabledText : secondary ? styles.secondaryText : styles.primaryText, + this.props.textStyle, ]; const iconStyle = [styles.icon, secondary ? styles.secondaryIcon : styles.primaryIcon]; diff --git a/src/compose/ComposeBox.js b/src/compose/ComposeBox.js index 07c91982fa9..8980c152603 100644 --- a/src/compose/ComposeBox.js +++ b/src/compose/ComposeBox.js @@ -16,6 +16,8 @@ import type { Dimensions, CaughtUp, GetText, + Subscription, + Stream, } from '../types'; import { connect } from '../react-redux'; import { withGetText } from '../boot/TranslationProvider'; @@ -35,6 +37,7 @@ import ComposeMenu from './ComposeMenu'; import getComposeInputPlaceholder from './getComposeInputPlaceholder'; import NotSubscribed from '../message/NotSubscribed'; import AnnouncementOnly from '../message/AnnouncementOnly'; +import MentionWarnings from './MentionWarnings'; import { getAuth, @@ -43,6 +46,7 @@ import { getLastMessageTopic, getActiveUsersByEmail, getCaughtUpForNarrow, + getStreamInNarrow, } from '../selectors'; import { getIsActiveStreamSubscribed, @@ -64,6 +68,7 @@ type SelectorProps = {| draft: string, lastMessageTopic: string, caughtUp: CaughtUp, + stream: Subscription | {| ...Stream, in_home_view: boolean |}, |}; type Props = $ReadOnly<{| @@ -116,7 +121,7 @@ class ComposeBox extends PureComponent { messageInput: ?TextInput = null; topicInput: ?TextInput = null; - + mentionWarnings: React$ElementRef = React.createRef(); inputBlurTimeoutId: ?TimeoutID = null; state = { @@ -190,8 +195,19 @@ class ComposeBox extends PureComponent { dispatch(draftUpdate(narrow, message)); }; - handleMessageAutocomplete = (message: string) => { - this.setMessageInputValue(message); + // See JSDoc on 'onAutocomplete' in 'AutocompleteView.js'. + handleMessageAutocomplete = ( + completedText: string, + completion: string, + lastWordPrefix: string, + ) => { + this.setMessageInputValue(completedText); + + if (lastWordPrefix === '@') { + if (this.mentionWarnings.current) { + this.mentionWarnings.current.getWrappedInstance().handleMentionSubscribedCheck(completion); + } + } }; handleMessageSelectionChange = (event: { +nativeEvent: { +selection: InputSelection } }) => { @@ -261,6 +277,11 @@ class ComposeBox extends PureComponent { dispatch(addToOutbox(this.getDestinationNarrow(), message)); this.setMessageInputValue(''); + + if (this.mentionWarnings.current) { + this.mentionWarnings.current.getWrappedInstance().clearMentionWarnings(); + } + dispatch(sendTypingStop(narrow)); }; @@ -354,6 +375,7 @@ class ComposeBox extends PureComponent { isAdmin, isAnnouncementOnly, isSubscribed, + stream, } = this.props; if (!isSubscribed) { @@ -370,6 +392,7 @@ class ComposeBox extends PureComponent { return ( + ((state, props) => ({ draft: getDraftForNarrow(state, props.narrow), lastMessageTopic: getLastMessageTopic(state, props.narrow), caughtUp: getCaughtUpForNarrow(state, props.narrow), + stream: getStreamInNarrow(state, props.narrow), }))(withGetText(ComposeBox)); diff --git a/src/compose/MentionWarnings.js b/src/compose/MentionWarnings.js new file mode 100644 index 00000000000..1a99ece0511 --- /dev/null +++ b/src/compose/MentionWarnings.js @@ -0,0 +1,186 @@ +/* @flow strict-local */ + +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; + +import type { Auth, Stream, Dispatch, Narrow, UserOrBot, Subscription, GetText } from '../types'; +import { TranslationContext } from '../boot/TranslationProvider'; +import { getActiveUsersById, getAuth } from '../selectors'; +import { isPrivateNarrow } from '../utils/narrow'; +import * as api from '../api'; +import { showToast } from '../utils/info'; + +import AnimatedScaleComponent from '../animation/AnimatedScaleComponent'; +import MentionedUserNotSubscribed from '../message/MentionedUserNotSubscribed'; + +type State = {| + unsubscribedMentions: Array, +|}; + +type SelectorProps = {| + auth: Auth, + usersById: Map, +|}; + +type Props = $ReadOnly<{| + narrow: Narrow, + stream: Subscription | {| ...Stream, in_home_view: boolean |}, + + dispatch: Dispatch, + ...SelectorProps, +|}>; + +class MentionWarnings extends PureComponent { + static contextType = TranslationContext; + context: GetText; + + state = { + unsubscribedMentions: [], + }; + + /** + * Tries to parse a user object from an @-mention. + * + * @param completion The autocomplete option chosend by the user. + See JSDoc for AutoCompleteView for details. + */ + getUserFromMention = (completion: string): UserOrBot | void => { + const { usersById } = this.props; + + const unformattedMessage = completion.split('**')[1]; + const [userFullName, userIdRaw] = unformattedMessage.split('|'); + + // We skip user groups, for which autocompletes are of the form + // `**`, and therefore, message.split('**')[1] + // is undefined. + if (unformattedMessage === undefined) { + return undefined; + } + + if (userIdRaw !== undefined) { + const userId = Number.parseInt(userIdRaw, 10); + return usersById.get(userId); + } + + for (const user of usersById.values()) { + if (user.full_name === userFullName) { + return user; + } + } + + return undefined; + }; + + showSubscriptionStatusLoadError = (mentionedUser: UserOrBot) => { + const _ = this.context; + + const alertTitle = _.intl.formatMessage( + { + id: "Couldn't load information about {fullName}", + defaultMessage: "Couldn't load information about {fullName}", + }, + { fullName: mentionedUser.full_name }, + ); + showToast(alertTitle); + }; + + /** + * Check whether the message text entered by the user contains + * an @-mention to a user unsubscribed to the current stream, and if + * so, shows a warning. + * + * This function is expected to be called by `ComposeBox` using a ref + * to this component. + * + * @param completion The autocomplete option chosend by the user. + See JSDoc for AutoCompleteView for details. + */ + handleMentionSubscribedCheck = async (completion: string) => { + const { narrow, auth, stream } = this.props; + const { unsubscribedMentions } = this.state; + + if (isPrivateNarrow(narrow)) { + return; + } + const mentionedUser = this.getUserFromMention(completion); + if (mentionedUser === undefined || unsubscribedMentions.includes(mentionedUser.user_id)) { + return; + } + + let isSubscribed: boolean; + try { + isSubscribed = (await api.getSubscriptionToStream( + auth, + mentionedUser.user_id, + stream.stream_id, + )).is_subscribed; + } catch (err) { + this.showSubscriptionStatusLoadError(mentionedUser); + return; + } + + if (!isSubscribed) { + this.setState(prevState => ({ + unsubscribedMentions: [...prevState.unsubscribedMentions, mentionedUser.user_id], + })); + } + }; + + handleMentionWarningDismiss = (user: UserOrBot) => { + this.setState(prevState => ({ + unsubscribedMentions: prevState.unsubscribedMentions.filter( + (x: number) => x !== user.user_id, + ), + })); + }; + + clearMentionWarnings = () => { + this.setState({ + unsubscribedMentions: [], + }); + }; + + render() { + const { unsubscribedMentions } = this.state; + const { stream, narrow, usersById } = this.props; + + if (isPrivateNarrow(narrow)) { + return null; + } + + const mentionWarnings = []; + for (const userId of unsubscribedMentions) { + const user = usersById.get(userId); + + if (user === undefined) { + continue; + } + + mentionWarnings.push( + , + ); + } + + return ( + + {mentionWarnings} + + ); + } +} + +// $FlowFixMe. TODO: Use a type checked connect call. +export default connect( + state => ({ + auth: getAuth(state), + usersById: getActiveUsersById(state), + }), + null, + null, + { withRef: true }, +)(MentionWarnings); diff --git a/src/message/MentionedUserNotSubscribed.js b/src/message/MentionedUserNotSubscribed.js new file mode 100644 index 00000000000..2a77ca3922f --- /dev/null +++ b/src/message/MentionedUserNotSubscribed.js @@ -0,0 +1,88 @@ +/* @flow strict-local */ + +import React, { PureComponent } from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; + +import type { Auth, Stream, Dispatch, UserOrBot, Subscription } from '../types'; +import { connect } from '../react-redux'; +import * as api from '../api'; +import { ZulipButton, Label } from '../common'; +import { getAuth } from '../selectors'; + +type SelectorProps = {| + auth: Auth, +|}; + +type Props = $ReadOnly<{| + user: UserOrBot, + stream: Subscription | {| ...Stream, in_home_view: boolean |}, + onDismiss: (user: UserOrBot) => void, + + dispatch: Dispatch, + ...SelectorProps, +|}>; + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + backgroundColor: 'hsl(40, 100%, 60%)', // Material warning-color + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: 'orange', + }, + text: { + flex: 1, + color: 'black', + }, + button: { + backgroundColor: 'orange', + padding: 6, + }, + buttonText: { + color: 'black', + }, +}); + +class MentionedUserNotSubscribed extends PureComponent { + subscribeToStream = () => { + const { auth, stream, user } = this.props; + api.subscriptionAdd(auth, [{ name: stream.name }], [user.email]); + this.handleDismiss(); + }; + + handleDismiss = () => { + const { user, onDismiss } = this.props; + onDismiss(user); + }; + + render() { + const { user } = this.props; + + return ( + + + + + ); + } +} + +export default connect((state, props) => ({ + auth: getAuth(state), +}))(MentionedUserNotSubscribed); diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 3385814a6e1..f365e35ca3d 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -106,6 +106,7 @@ "Enable notifications": "Enable notifications", "Jot down something": "Jot down something", "Message {recipient}": "Message {recipient}", + "{username} will not be notified unless you subscribe them to this stream.": "{username} will not be notified unless you subscribe them to this stream.", "Send private message": "Send private message", "View private messages": "View private messages", "(This user has been deactivated)": "(This user has been deactivated)", @@ -215,6 +216,7 @@ "Sending Message...": "Sending Message...", "Failed to send message": "Failed to send message", "Message sent": "Message sent", + "Couldn't load information about {fullName}": "Couldn't load information about {fullName}", "What's your status?": "What's your status?", "📅 In a meeting": "📅 In a meeting", "🚌 Commuting": "🚌 Commuting",