diff --git a/api/mutations/community/updateAdministratorEmail.js b/api/mutations/community/updateAdministratorEmail.js index 3758b60d93..6a3808a3b4 100644 --- a/api/mutations/community/updateAdministratorEmail.js +++ b/api/mutations/community/updateAdministratorEmail.js @@ -20,7 +20,7 @@ export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { const { id: communityId, email } = args.input; const { loaders, user } = ctx; - if (!isEmail(email)) { + if (!email || !isEmail(email)) { return new UserError('Please enter a working email address'); } diff --git a/api/mutations/message/addMessage.js b/api/mutations/message/addMessage.js index 0ba4306cd6..cfbb1d4fcb 100644 --- a/api/mutations/message/addMessage.js +++ b/api/mutations/message/addMessage.js @@ -203,7 +203,7 @@ export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { // at this point we are only dealing with thread messages const thread = await loaders.thread.load(message.threadId); - if (thread.isDeleted) { + if (!thread || thread.deletedAt) { trackQueue.add({ userId: user.id, event: eventFailed, diff --git a/athena/queues/private-channel-request-approved.js b/athena/queues/private-channel-request-approved.js index e9867428b3..6af7361976 100644 --- a/athena/queues/private-channel-request-approved.js +++ b/athena/queues/private-channel-request-approved.js @@ -45,7 +45,9 @@ export default async (job: Job) => { const recipients = await getUsers([userId]); // only get owners with emails - const filteredRecipients = recipients.filter(user => isEmail(user.email)); + const filteredRecipients = recipients.filter( + user => user && isEmail(user.email) + ); // for each owner, create a notification for the app const usersNotificationPromises = filteredRecipients.map(recipient => diff --git a/athena/queues/private-channel-request-sent.js b/athena/queues/private-channel-request-sent.js index bdf51bb4f7..cf15f948d8 100644 --- a/athena/queues/private-channel-request-sent.js +++ b/athena/queues/private-channel-request-sent.js @@ -75,7 +75,7 @@ export default async (job: Job) => { // only get owners + moderators with emails const filteredRecipients = recipientsWithUserData.filter( - owner => owner.email && isEmail(owner.email) + owner => owner && owner.email && isEmail(owner.email) ); // for each owner, create a notification for the app diff --git a/athena/queues/private-community-request-approved.js b/athena/queues/private-community-request-approved.js index f2f05da072..217dc2013b 100644 --- a/athena/queues/private-community-request-approved.js +++ b/athena/queues/private-community-request-approved.js @@ -42,7 +42,9 @@ export default async (job: Job) => { const community = await getCommunityById(communityId); const recipients = await getUsers([userId]); - const filteredRecipients = recipients.filter(user => isEmail(user.email)); + const filteredRecipients = recipients.filter( + user => user && isEmail(user.email) + ); const usersNotificationPromises = filteredRecipients.map(recipient => storeUsersNotifications(updatedNotification.id, recipient.id) ); diff --git a/athena/queues/private-community-request-sent.js b/athena/queues/private-community-request-sent.js index 65b78cdea5..52ee897e71 100644 --- a/athena/queues/private-community-request-sent.js +++ b/athena/queues/private-community-request-sent.js @@ -59,7 +59,7 @@ export default async (job: Job) => { // only get owners + moderators with emails const filteredRecipients = recipientsWithUserData.filter( - owner => owner.email && isEmail(owner.email) + owner => owner && owner.email && isEmail(owner.email) ); // for each owner, create a notification for the app diff --git a/athena/queues/send-slack-invitations.js b/athena/queues/send-slack-invitations.js index 9e51b826bf..2aea444f83 100644 --- a/athena/queues/send-slack-invitations.js +++ b/athena/queues/send-slack-invitations.js @@ -62,7 +62,13 @@ const processJob = async (job: Job) => { // filter out deleted members .filter(member => !member.deleted) // only save members with valid email - .filter(member => member.profile.email && isEmail(member.profile.email)) + .filter( + member => + member && + member.profile && + member.profile.email && + isEmail(member.profile.email) + ) // format output data .map(member => ({ firstName: member.profile.first_name, diff --git a/hermes/send-email.js b/hermes/send-email.js index bfa701d3e8..5eca3a86dd 100644 --- a/hermes/send-email.js +++ b/hermes/send-email.js @@ -1,4 +1,5 @@ // @flow +import isEmail from 'validator/lib/isEmail'; import postmark from 'postmark'; const debug = require('debug')('hermes:send-email'); const stringify = require('json-stringify-pretty-compact'); @@ -46,6 +47,35 @@ const sendEmail = (options: Options) => { }); } + if (!To) { + if (userId) { + trackQueue.add({ + userId: userId, + event: events.EMAIL_BOUNCED, + properties: { tag: Tag, error: 'To field was not provided' }, + }); + } + + return; + } + + if (!isEmail(To)) { + if (userId) { + trackQueue.add({ + userId: userId, + event: events.EMAIL_BOUNCED, + // we can safely log the To field because it's not a valid email, thus not PII + properties: { + tag: Tag, + to: To, + error: 'To field was not a valid email address', + }, + }); + } + + return; + } + // $FlowFixMe return new Promise((res, rej) => { client.sendEmailWithTemplate( @@ -65,7 +95,7 @@ const sendEmail = (options: Options) => { trackQueue.add({ userId: userId, event: events.EMAIL_BOUNCED, - properties: { tag: Tag }, + properties: { tag: Tag, error: err.message }, }); } @@ -74,6 +104,17 @@ const sendEmail = (options: Options) => { .catch(e => rej(e)); } + if (err.code === 422) { + if (userId) { + trackQueue.add({ + userId: userId, + event: events.EMAIL_BOUNCED, + // we can safely log the To field as error 422 means the To field is malformed anyways and is not a valid email address + properties: { tag: Tag, error: err.message, to: To }, + }); + } + } + console.error('Error sending email:'); console.error(err); return rej(err); diff --git a/hyperion/renderer/html-template.js b/hyperion/renderer/html-template.js index 15def51814..fb51bc5346 100644 --- a/hyperion/renderer/html-template.js +++ b/hyperion/renderer/html-template.js @@ -93,7 +93,7 @@ export const getFooter = ({ - + ${bundles.map(src => createScriptTag({ src }))} ${createScriptTag({ src: `/static/js/${mainBundle}` })} diff --git a/src/components/chatInput/components/mediaUploader.js b/src/components/chatInput/components/mediaUploader.js index 9e6044f984..bbbd5dd81b 100644 --- a/src/components/chatInput/components/mediaUploader.js +++ b/src/components/chatInput/components/mediaUploader.js @@ -87,7 +87,7 @@ class MediaUploader extends React.Component { onPaste = (event: any) => { // Ensure that the image is only pasted if user focuses input - if (!this.props.inputFocused) { + if (!event || !this.props.inputFocused) { return; } const items = (event.clipboardData || event.originalEvent.clipboardData) diff --git a/src/components/composer/index.js b/src/components/composer/index.js index 7372b42173..6265f9139e 100644 --- a/src/components/composer/index.js +++ b/src/components/composer/index.js @@ -284,7 +284,7 @@ class ComposerWithData extends Component { const title = e.target.value; this.persistTitleToLocalStorageWithDebounce(title); if (/\n$/g.test(title)) { - this.bodyEditor.focus(); + this.bodyEditor.focus && this.bodyEditor.focus(); return; } this.setState({ diff --git a/src/components/threadComposer/components/composer.js b/src/components/threadComposer/components/composer.js index cf45e3c9f0..98c65117c3 100644 --- a/src/components/threadComposer/components/composer.js +++ b/src/components/threadComposer/components/composer.js @@ -351,7 +351,7 @@ class ThreadComposerWithData extends React.Component { changeTitle = e => { const title = e.target.value; if (/\n$/g.test(title)) { - this.bodyEditor.focus(); + this.bodyEditor.focus && this.bodyEditor.focus(); return; } persistTitle(title); diff --git a/src/views/community/components/channelList.js b/src/views/community/components/channelList.js index e551da2c5b..af4a6e878a 100644 --- a/src/views/community/components/channelList.js +++ b/src/views/community/components/channelList.js @@ -32,7 +32,9 @@ type Props = { }; class ChannelList extends React.Component { - sortChannels = (array: Array): Array => { + sortChannels = (array: Array): Array => { + if (!array || array.length === 0) return []; + const generalChannel = array.find(channel => channel.slug === 'general'); const withoutGeneral = array.filter(channel => channel.slug !== 'general'); const sortedWithoutGeneral = withoutGeneral.sort((a, b) => { diff --git a/src/views/explore/components/search.js b/src/views/explore/components/search.js index f5fb5dc204..2fa490f0ec 100644 --- a/src/views/explore/components/search.js +++ b/src/views/explore/components/search.js @@ -5,12 +5,13 @@ import { withRouter } from 'react-router'; import { connect } from 'react-redux'; import compose from 'recompose/compose'; import Link from 'src/components/link'; -import { Button } from '../../../components/buttons'; -import { throttle } from '../../../helpers/utils'; +import { Button } from 'src/components/buttons'; +import { throttle } from 'src/helpers/utils'; import { searchCommunitiesQuery } from 'shared/graphql/queries/search/searchCommunities'; import type { SearchCommunitiesType } from 'shared/graphql/queries/search/searchCommunities'; -import { Spinner } from '../../../components/globals'; -import { addToastWithTimeout } from '../../../actions/toasts'; +import { Spinner } from 'src/components/globals'; +import { addToastWithTimeout } from 'src/actions/toasts'; +import OutsideClickHandler from 'src/components/outsideClickHandler'; import { SearchWrapper, SearchInput, @@ -126,9 +127,7 @@ class Search extends React.Component { // if person presses escape if (e.keyCode === 27) { this.setState({ - searchResults: [], - searchIsLoading: false, - searchString: '', + isFocused: false, }); // $FlowFixMe @@ -152,8 +151,11 @@ class Search extends React.Component { if (indexOfFocusedSearchResult === searchResults.length - 1) return; if (searchResults.length <= 1) return; + const resultToFocus = searchResults[indexOfFocusedSearchResult + 1]; + if (!resultToFocus) return; + return this.setState({ - focusedSearchResult: searchResults[indexOfFocusedSearchResult + 1].id, + focusedSearchResult: resultToFocus.id, }); } @@ -162,8 +164,11 @@ class Search extends React.Component { if (indexOfFocusedSearchResult === 0) return; if (searchResults.length <= 1) return; + const resultToFocus = searchResults[indexOfFocusedSearchResult - 1]; + if (!resultToFocus) return; + return this.setState({ - focusedSearchResult: searchResults[indexOfFocusedSearchResult - 1].id, + focusedSearchResult: resultToFocus.id, }); } }; @@ -196,7 +201,7 @@ class Search extends React.Component { } onFocus = (e: any) => { - const val = e.target.val; + const val = e.target.value; if (!val || val.length === 0) return; const string = val.toLowerCase().trim(); @@ -209,6 +214,12 @@ class Search extends React.Component { }); }; + hideSearchResults = () => { + return this.setState({ + isFocused: false, + }); + }; + render() { const { searchString, @@ -241,50 +252,55 @@ class Search extends React.Component { {// user has typed in a search string - searchString && ( - - {searchResults.length > 0 && - searchResults.map(community => { - return ( - - - + isFocused && + searchString && ( + + + {searchResults.length > 0 && + searchResults.map(community => { + return ( + + + + + + + {community.name} + + {community.metaData && ( + + {community.metaData.members} members + + )} + + + + + ); + })} + + {searchResults.length === 0 && + isFocused && ( + - - {community.name} - {community.metaData && ( - - {community.metaData.members} members - - )} - + +

No communities found matching “{searchString}”

+ + + +
-
-
- ); - })} - - {searchResults.length === 0 && - isFocused && ( - - - -

No communities found matching “{searchString}”

- - - -
-
-
- )} -
- )} + + )} + + + )} ); } diff --git a/src/views/newUserOnboarding/components/communitySearch/index.js b/src/views/newUserOnboarding/components/communitySearch/index.js index 3dfa5b7e02..80efd165d5 100644 --- a/src/views/newUserOnboarding/components/communitySearch/index.js +++ b/src/views/newUserOnboarding/components/communitySearch/index.js @@ -5,11 +5,12 @@ import { withRouter } from 'react-router'; import compose from 'recompose/compose'; import Link from 'src/components/link'; import { connect } from 'react-redux'; -import { Button, OutlineButton } from '../../../../components/buttons'; -import ToggleCommunityMembership from '../../../../components/toggleCommunityMembership'; -import { throttle } from '../../../../helpers/utils'; +import { Button, OutlineButton } from 'src/components/buttons'; +import ToggleCommunityMembership from 'src/components/toggleCommunityMembership'; +import { throttle } from 'src/helpers/utils'; import { searchCommunitiesQuery } from 'shared/graphql/queries/search/searchCommunities'; -import { Spinner } from '../../../../components/globals'; +import { Spinner } from 'src/components/globals'; +import OutsideClickHandler from 'src/components/outsideClickHandler'; import type { Dispatch } from 'redux'; import { SearchWrapper, @@ -173,9 +174,7 @@ class Search extends React.Component { // if person presses escape if (e.keyCode === 27) { this.setState({ - searchResults: [], - searchIsLoading: false, - searchString: '', + isFocused: false, }); // $FlowFixMe @@ -188,8 +187,11 @@ class Search extends React.Component { if (indexOfFocusedSearchResult === searchResults.length - 1) return; if (searchResults.length === 1) return; + const resultToFocus = searchResults[indexOfFocusedSearchResult + 1]; + if (!resultToFocus) return; + return this.setState({ - focusedSearchResult: searchResults[indexOfFocusedSearchResult + 1].id, + focusedSearchResult: resultToFocus.id, }); } @@ -198,8 +200,11 @@ class Search extends React.Component { if (indexOfFocusedSearchResult === 0) return; if (searchResults.length === 1) return; + const resultToFocus = searchResults[indexOfFocusedSearchResult - 1]; + if (!resultToFocus) return; + return this.setState({ - focusedSearchResult: searchResults[indexOfFocusedSearchResult - 1].id, + focusedSearchResult: resultToFocus.id, }); } }; @@ -226,7 +231,7 @@ class Search extends React.Component { } onFocus = (e: any) => { - const val = e.target.val; + const val = e.target.value; if (!val || val.length === 0) return; const string = val.toLowerCase().trim(); @@ -239,6 +244,12 @@ class Search extends React.Component { }); }; + hideSearchResults = () => { + return this.setState({ + isFocused: false, + }); + }; + render() { const { searchString, @@ -266,89 +277,92 @@ class Search extends React.Component { placeholder="Search for communities or topics..." onChange={this.handleChange} onFocus={this.onFocus} - autoFocus={window.innerWidth < 768} + autoFocus /> {// user has typed in a search string - searchString && ( - - {searchResults.length > 0 && - searchResults.map(community => { - return ( - - - - - {community.name} - {community.metaData && ( - - {community.metaData.members} members - - )} - - {community.description} - - - -
- {community.communityPermissions.isMember ? ( - + + {searchResults.length > 0 && + searchResults.map(community => { + return ( + + ( - - Joined! - - )} + src={community.profilePhoto} /> - ) : ( - ( - + + + {community.name} + {community.metaData && ( + + {community.metaData.members} members + )} - /> - )} -
-
- ); - })} - - {searchResults.length === 0 && - isFocused && ( - - -

No communities found matching “{searchString}”

- - - -
-
- )} -
- )} + + {community.description} + + + +
+ {community.communityPermissions.isMember ? ( + ( + + Joined! + + )} + /> + ) : ( + ( + + )} + /> + )} +
+ + ); + })} + + {searchResults.length === 0 && + isFocused && ( + + +

No communities found matching “{searchString}”

+ + + +
+
+ )} + + + )} ); } diff --git a/src/views/thread/components/threadDetail.js b/src/views/thread/components/threadDetail.js index 83093afb9e..2f17a68e7e 100644 --- a/src/views/thread/components/threadDetail.js +++ b/src/views/thread/components/threadDetail.js @@ -314,7 +314,7 @@ class ThreadDetailPure extends React.Component { changeTitle = e => { const title = e.target.value; if (/\n$/g.test(title)) { - this.bodyEditor.focus(); + this.bodyEditor.focus && this.bodyEditor.focus(); return; } this.setState({