- 
          
- 
                Notifications
    You must be signed in to change notification settings 
- Fork 676
Warn on @-mentioning someone who won't see it because not subscribed #4101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5844339
              de6c66c
              b023922
              a428d55
              20bd98a
              0f15268
              1ea9e9e
              387ae00
              29168ac
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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<ApiResponseSubscriptionStatus> => | ||
| apiGet(auth, `users/${userId}/subscriptions/${streamId}`); | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
|  | @@ -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, | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be helpful to have some JSDoc about the interface of the AutocompleteView component, in particular, describing what information is represented by  | ||
| |}>; | ||
|  | ||
| export default class AutocompleteView extends PureComponent<Props> { | ||
| 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() { | ||
|  | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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<number>, | ||
| |}; | ||
|  | ||
| type SelectorProps = {| | ||
| auth: Auth, | ||
| usersById: Map<number, UserOrBot>, | ||
| |}; | ||
|  | ||
| type Props = $ReadOnly<{| | ||
| narrow: Narrow, | ||
| stream: Subscription | {| ...Stream, in_home_view: boolean |}, | ||
|  | ||
| dispatch: Dispatch, | ||
| ...SelectorProps, | ||
| |}>; | ||
|  | ||
| class MentionWarnings extends PureComponent<Props, State> { | ||
| 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 | ||
| // `*<user_group_name>*`, 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( | ||
| <MentionedUserNotSubscribed | ||
| stream={stream} | ||
| user={user} | ||
| onDismiss={this.handleMentionWarningDismiss} | ||
| key={user.user_id} | ||
| />, | ||
| ); | ||
| } | ||
|  | ||
| return ( | ||
| <AnimatedScaleComponent visible={mentionWarnings.length !== 0}> | ||
| {mentionWarnings} | ||
| </AnimatedScaleComponent> | ||
| ); | ||
| } | ||
| } | ||
|  | ||
| // $FlowFixMe. TODO: Use a type checked connect call. | ||
| export default connect( | ||
| state => ({ | ||
| auth: getAuth(state), | ||
| usersById: getActiveUsersById(state), | ||
| }), | ||
| null, | ||
| null, | ||
| { withRef: true }, | ||
| )(MentionWarnings); | 
Uh oh!
There was an error while loading. Please reload this page.