diff options
| -rw-r--r-- | src/components/comments/AddComment.tsx | 4 | ||||
| -rw-r--r-- | src/components/comments/CommentsContainer.tsx | 11 | ||||
| -rw-r--r-- | src/components/comments/MentionInputControlled.tsx | 195 | ||||
| -rw-r--r-- | src/components/common/TaggTypeahead.tsx | 19 | ||||
| -rw-r--r-- | src/constants/api.ts | 1 | ||||
| -rw-r--r-- | src/screens/onboarding/BasicInfoOnboarding.tsx | 153 | ||||
| -rw-r--r-- | src/screens/profile/MomentCommentsScreen.tsx | 5 | ||||
| -rw-r--r-- | src/services/UserProfileService.ts | 27 | 
8 files changed, 350 insertions, 65 deletions
| diff --git a/src/components/comments/AddComment.tsx b/src/components/comments/AddComment.tsx index 9cf10b5e..befaa8fe 100644 --- a/src/components/comments/AddComment.tsx +++ b/src/components/comments/AddComment.tsx @@ -7,7 +7,6 @@ import {    TextInput,    View,  } from 'react-native'; -import {MentionInput} from 'react-native-controlled-mentions';  import {TouchableOpacity} from 'react-native-gesture-handler';  import {useDispatch, useSelector} from 'react-redux';  import UpArrowIcon from '../../assets/icons/up_arrow.svg'; @@ -20,6 +19,7 @@ import {CommentThreadType, CommentType} from '../../types';  import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils';  import {mentionPartTypes} from '../../utils/comments';  import {Avatar} from '../common'; +import {MentionInputControlled} from './MentionInputControlled';  export interface AddCommentProps {    momentId: string; @@ -112,7 +112,7 @@ const AddComment: React.FC<AddCommentProps> = ({momentId, placeholderText}) => {          ]}>          <View style={styles.textContainer}>            <Avatar style={styles.avatar} uri={avatar} /> -          <MentionInput +          <MentionInputControlled              containerStyle={styles.text}              placeholder={placeholderText}              value={inReplyToMention + comment} diff --git a/src/components/comments/CommentsContainer.tsx b/src/components/comments/CommentsContainer.tsx index bd8d5c49..595ec743 100644 --- a/src/components/comments/CommentsContainer.tsx +++ b/src/components/comments/CommentsContainer.tsx @@ -18,6 +18,7 @@ export type CommentsContainerProps = {    shouldUpdate: boolean;    setShouldUpdate: (update: boolean) => void;    isThread: boolean; +  setCommentsLengthParent: (length: number) => void;  };  /** @@ -31,6 +32,7 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({    shouldUpdate,    setShouldUpdate,    commentId, +  setCommentsLengthParent,  }) => {    const {setCommentsLength, commentTapped} = useContext(CommentContext);    const {username: loggedInUsername} = useSelector( @@ -41,6 +43,14 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({    const ref = useRef<FlatList<CommentType>>(null);    const ITEM_HEIGHT = SCREEN_HEIGHT / 7.0; +  const countComments = (comments: CommentType[]) => { +    let count = 0; +    for (let i = 0; i < comments.length; i++) { +      count += 1 + comments[i].replies_count; +    } +    return count; +  } +    useEffect(() => {      const loadComments = async () => {        await getComments(objectId, isThread).then((comments) => { @@ -51,6 +61,7 @@ const CommentsContainer: React.FC<CommentsContainerProps> = ({            }            setShouldUpdate(false);          } +        setCommentsLengthParent(countComments(comments));        });      };      let subscribedToLoadComments = true; diff --git a/src/components/comments/MentionInputControlled.tsx b/src/components/comments/MentionInputControlled.tsx new file mode 100644 index 00000000..6abcb566 --- /dev/null +++ b/src/components/comments/MentionInputControlled.tsx @@ -0,0 +1,195 @@ +import React, {FC, MutableRefObject, useMemo, useRef, useState} from 'react'; +import { +  NativeSyntheticEvent, +  Text, +  TextInput, +  TextInputSelectionChangeEventData, +  View, +} from 'react-native'; + +import { +  MentionInputProps, +  MentionPartType, +  Suggestion, +} from 'react-native-controlled-mentions/dist/types'; +import { +  defaultMentionTextStyle, +  generateValueFromPartsAndChangedText, +  generateValueWithAddedSuggestion, +  getMentionPartSuggestionKeywords, +  isMentionPartType, +  parseValue, +} from 'react-native-controlled-mentions/dist/utils'; + +const MentionInputControlled: FC<MentionInputProps> = ({ +  value, +  onChange, + +  partTypes = [], + +  inputRef: propInputRef, + +  containerStyle, + +  onSelectionChange, + +  ...textInputProps +}) => { +  const textInput = useRef<TextInput | null>(null); + +  const [selection, setSelection] = useState({start: 0, end: 0}); + +  const [keyboardText, setKeyboardText] = useState<string>(''); + +  const validRegex = () => { +    if (partTypes.length === 0) { +      return /.*\@[^ ]*$/; +    } else { +      return new RegExp(`.*\@${keywordByTrigger[partTypes[0].trigger]}.*$`); +    } +  }; + +  const {plainText, parts} = useMemo(() => parseValue(value, partTypes), [ +    value, +    partTypes, +  ]); + +  const handleSelectionChange = ( +    event: NativeSyntheticEvent<TextInputSelectionChangeEventData>, +  ) => { +    setSelection(event.nativeEvent.selection); + +    onSelectionChange && onSelectionChange(event); +  }; + +  /** +   * Callback that trigger on TextInput text change +   * +   * @param changedText +   */ +  const onChangeInput = (changedText: string) => { +    setKeyboardText(changedText); +    onChange( +      generateValueFromPartsAndChangedText(parts, plainText, changedText), +    ); +  }; + +  /** +   * We memoize the keyword to know should we show mention suggestions or not +   */ +  const keywordByTrigger = useMemo(() => { +    return getMentionPartSuggestionKeywords( +      parts, +      plainText, +      selection, +      partTypes, +    ); +  }, [parts, plainText, selection, partTypes]); + +  /** +   * Callback on mention suggestion press. We should: +   * - Get updated value +   * - Trigger onChange callback with new value +   */ +  const onSuggestionPress = (mentionType: MentionPartType) => ( +    suggestion: Suggestion, +  ) => { +    const newValue = generateValueWithAddedSuggestion( +      parts, +      mentionType, +      plainText, +      selection, +      suggestion, +    ); + +    if (!newValue) { +      return; +    } + +    onChange(newValue); + +    /** +     * Move cursor to the end of just added mention starting from trigger string and including: +     * - Length of trigger string +     * - Length of mention name +     * - Length of space after mention (1) +     * +     * Not working now due to the RN bug +     */ +    // const newCursorPosition = currentPart.position.start + triggerPartIndex + trigger.length + +    // suggestion.name.length + 1; + +    // textInput.current?.setNativeProps({selection: {start: newCursorPosition, end: newCursorPosition}}); +  }; + +  const handleTextInputRef = (ref: TextInput) => { +    textInput.current = ref as TextInput; + +    if (propInputRef) { +      if (typeof propInputRef === 'function') { +        propInputRef(ref); +      } else { +        (propInputRef as MutableRefObject<TextInput>).current = ref as TextInput; +      } +    } +  }; + +  const renderMentionSuggestions = (mentionType: MentionPartType) => ( +    <React.Fragment key={mentionType.trigger}> +      {mentionType.renderSuggestions && +        mentionType.renderSuggestions({ +          keyword: keywordByTrigger[mentionType.trigger], +          onSuggestionPress: onSuggestionPress(mentionType), +        })} +    </React.Fragment> +  ); + +  const validateInput = (testString: string) => { +    return validRegex().test(testString); +  }; + +  return ( +    <View style={containerStyle}> +      {validateInput(keyboardText) +        ? (partTypes.filter( +            (one) => +              isMentionPartType(one) && +              one.renderSuggestions != null && +              !one.isBottomMentionSuggestionsRender, +          ) as MentionPartType[]).map(renderMentionSuggestions) +        : null} + +      <TextInput +        multiline +        {...textInputProps} +        ref={handleTextInputRef} +        onChangeText={onChangeInput} +        onSelectionChange={handleSelectionChange}> +        <Text> +          {parts.map(({text, partType, data}, index) => +            partType ? ( +              <Text +                key={`${index}-${data?.trigger ?? 'pattern'}`} +                style={partType.textStyle ?? defaultMentionTextStyle}> +                {text} +              </Text> +            ) : ( +              <Text key={index}>{text}</Text> +            ), +          )} +        </Text> +      </TextInput> + +      {validateInput(keyboardText) +        ? (partTypes.filter( +            (one) => +              isMentionPartType(one) && +              one.renderSuggestions != null && +              one.isBottomMentionSuggestionsRender, +          ) as MentionPartType[]).map(renderMentionSuggestions) +        : null} +    </View> +  ); +}; + +export {MentionInputControlled}; diff --git a/src/components/common/TaggTypeahead.tsx b/src/components/common/TaggTypeahead.tsx index 7cd99278..747e0bb5 100644 --- a/src/components/common/TaggTypeahead.tsx +++ b/src/components/common/TaggTypeahead.tsx @@ -1,26 +1,32 @@  import React, {Fragment, useEffect, useState} from 'react';  import {ScrollView, StyleSheet} from 'react-native';  import {MentionSuggestionsProps} from 'react-native-controlled-mentions'; +import {useSelector} from 'react-redux';  import {SEARCH_ENDPOINT_MESSAGES} from '../../constants';  import {loadSearchResults} from '../../services'; +import {RootState} from '../../store/rootReducer';  import {ProfilePreviewType} from '../../types'; -import {SCREEN_WIDTH} from '../../utils'; +import {SCREEN_WIDTH, shuffle} from '../../utils';  import TaggUserRowCell from './TaggUserRowCell';  const TaggTypeahead: React.FC<MentionSuggestionsProps> = ({    keyword,    onSuggestionPress,  }) => { +  const {friends} = useSelector((state: RootState) => state.friends);    const [results, setResults] = useState<ProfilePreviewType[]>([]);    const [height, setHeight] = useState(0);    useEffect(() => { -    getQuerySuggested(); +    if (keyword === '') { +      setResults(shuffle(friends)); +    } else { +      getQuerySuggested(); +    }    }, [keyword]);    const getQuerySuggested = async () => { -    if (!keyword || keyword.length < 3) { -      setResults([]); +    if (keyword === undefined || keyword === '@') {        return;      }      const searchResults = await loadSearchResults( @@ -41,15 +47,16 @@ const TaggTypeahead: React.FC<MentionSuggestionsProps> = ({        showsVerticalScrollIndicator={false}        onLayout={(event) => {          setHeight(event.nativeEvent.layout.height); -      }}> +      }} +      keyboardShouldPersistTaps={'always'}>        {results.map((user) => (          <TaggUserRowCell            onPress={() => { +            setResults([]);              onSuggestionPress({                id: user.id,                name: user.username,              }); -            setResults([]);            }}            user={user}          /> diff --git a/src/constants/api.ts b/src/constants/api.ts index 6a924f1d..9d3f70c9 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -12,6 +12,7 @@ const API_URL: string = BASE_URL + 'api/';  export const LOGIN_ENDPOINT: string = API_URL + 'login/';  export const VERSION_ENDPOINT: string = API_URL + 'version/v2/';  export const REGISTER_ENDPOINT: string = API_URL + 'register/'; +export const REGISTER_VALIDATE_ENDPOINT: string = API_URL + 'register/validate/';  export const EDIT_PROFILE_ENDPOINT: string = API_URL + 'edit-profile/';  export const SEND_OTP_ENDPOINT: string = API_URL + 'send-otp/';  export const VERIFY_OTP_ENDPOINT: string = API_URL + 'verify-otp/'; diff --git a/src/screens/onboarding/BasicInfoOnboarding.tsx b/src/screens/onboarding/BasicInfoOnboarding.tsx index 3fa33f63..3058a04e 100644 --- a/src/screens/onboarding/BasicInfoOnboarding.tsx +++ b/src/screens/onboarding/BasicInfoOnboarding.tsx @@ -38,7 +38,11 @@ import {    ERROR_T_AND_C_NOT_ACCEPTED,  } from '../../constants/strings';  import {OnboardingStackParams} from '../../routes'; -import {sendOtpStatusCode, sendRegister} from '../../services'; +import { +  sendOtpStatusCode, +  sendRegister, +  verifyExistingInformation, +} from '../../services';  import {BackgroundGradientType} from '../../types';  import {HeaderHeight, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; @@ -63,12 +67,20 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {    const [currentStep, setCurrentStep] = useState(0);    const [tcAccepted, setTCAccepted] = useState(false);    const [passVisibility, setPassVisibility] = useState(false); +  const [invalidWithError, setInvalidWithError] = useState( +    'Please enter a valid ', +  );    const [autoCapitalize, setAutoCap] = useState<      'none' | 'sentences' | 'words' | 'characters' | undefined    >('none');    const [fadeValue, setFadeValue] = useState<Animated.Value<number>>(      new Animated.Value(0),    ); + +  useEffect(() => { +    setValid(false); +  }, [invalidWithError]); +    const fadeButtonValue = useValue<number>(0);    const [form, setForm] = useState({      fname: '', @@ -209,30 +221,37 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {    const formSteps: {      placeholder: string;      onChangeText: (text: string) => void; +    value: string;    }[] = [      {        placeholder: 'First Name',        onChangeText: (text) => handleNameUpdate(text, 0), +      value: form.fname,      },      {        placeholder: 'Last Name',        onChangeText: (text) => handleNameUpdate(text, 1), +      value: form.lname,      },      {        placeholder: 'Phone',        onChangeText: handlePhoneUpdate, +      value: form.phone,      },      {        placeholder: 'School Email',        onChangeText: handleEmailUpdate, +      value: form.email,      },      {        placeholder: 'Username',        onChangeText: (text) => handleNameUpdate(text, 2), +      value: form.username,      },      {        placeholder: 'Password',        onChangeText: handlePasswordUpdate, +      value: form.password,      },    ];    const resetForm = (formStep: String) => { @@ -277,9 +296,33 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {      }    };    const step = formSteps[currentStep]; -  const advance = () => { +  useEffect(() => { +    setInvalidWithError( +      'Please enter a valid ' + step.placeholder.toLowerCase(), +    ); +  }, [currentStep]); +  const advance = async () => {      setAttemptedSubmit(true);      if (valid) { +      if (step.placeholder === 'School Email') { +        const verifiedInfo = await verifyExistingInformation( +          form.email, +          undefined, +        ); +        if (!verifiedInfo) { +          setInvalidWithError('Email is taken'); +          return; +        } +      } else if (step.placeholder === 'Username') { +        const verifiedInfo = await verifyExistingInformation( +          undefined, +          form.username, +        ); +        if (!verifiedInfo) { +          setInvalidWithError('Username is taken'); +          return; +        } +      }        setCurrentStep(currentStep + 1);        setAttemptedSubmit(false);        setValid(false); @@ -421,6 +464,7 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {                      returnKeyType="done"                      selectionColor="white"                      onChangeText={step.onChangeText} +                    value={step.value}                      onSubmitEditing={advance}                      autoFocus={true}                      blurOnSubmit={false} @@ -428,7 +472,7 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {                        warning: styles.passWarning,                      }}                      valid={valid} -                    invalidWarning={`Please enter a valid ${step.placeholder.toLowerCase()}`} +                    invalidWarning={invalidWithError}                      attemptedSubmit={attemptedSubmit}                    />                    <Animated.View style={{opacity: fadeButtonValue}}> @@ -443,58 +487,61 @@ const BasicInfoOnboarding: React.FC<BasicInfoOnboardingProps> = ({route}) => {                  </Animated.View>                </>              ) : ( -              <Animated.View -                style={[styles.formContainer, {opacity: fadeValue}]}> -                <TaggInput -                  accessibilityHint="Enter a password." -                  accessibilityLabel="Password input field." -                  placeholder="Password" -                  autoCompleteType="password" -                  textContentType="oneTimeCode" -                  returnKeyType="done" -                  selectionColor="white" -                  onChangeText={handlePasswordUpdate} -                  onSubmitEditing={advanceRegistration} -                  blurOnSubmit={false} -                  autoFocus={true} -                  secureTextEntry={!passVisibility} -                  valid={valid} -                  externalStyles={{ -                    warning: styles.passWarning, -                  }} -                  invalidWarning={ -                    'Password must be at least 8 characters & contain at least one of a-z, A-Z, 0-9, and a special character.' -                  } -                  attemptedSubmit={attemptedSubmit} -                  style={ -                    attemptedSubmit && !valid -                      ? [styles.input, styles.invalidColor] -                      : styles.input -                  } -                /> -                <TouchableOpacity -                  accessibilityLabel="Show password button" -                  accessibilityHint="Select this if you want to display your tagg password" -                  style={styles.showPassContainer} -                  onPress={() => setPassVisibility(!passVisibility)}> -                  <Text style={styles.showPass}>Show Password</Text> -                </TouchableOpacity> -                <LoadingIndicator /> -                <TermsConditions -                  style={styles.tc} -                  accepted={tcAccepted} -                  onChange={setTCAccepted} -                /> -                <Animated.View style={{opacity: fadeButtonValue}}> -                  <TaggSquareButton -                    onPress={advanceRegistration} -                    title={'Next'} -                    buttonStyle={'normal'} -                    buttonColor={'white'} -                    labelColor={'blue'} +              <> +                <Text style={styles.formHeader}>SIGN UP</Text> +                <Animated.View +                  style={[styles.formContainer, {opacity: fadeValue}]}> +                  <TaggInput +                    accessibilityHint="Enter a password." +                    accessibilityLabel="Password input field." +                    placeholder="Password" +                    autoCompleteType="password" +                    textContentType="oneTimeCode" +                    returnKeyType="done" +                    selectionColor="white" +                    onChangeText={handlePasswordUpdate} +                    onSubmitEditing={advanceRegistration} +                    blurOnSubmit={false} +                    autoFocus={true} +                    secureTextEntry={!passVisibility} +                    valid={valid} +                    externalStyles={{ +                      warning: styles.passWarning, +                    }} +                    invalidWarning={ +                      'Password must be at least 8 characters & contain at least one of a-z, A-Z, 0-9, and a special character.' +                    } +                    attemptedSubmit={attemptedSubmit} +                    style={ +                      attemptedSubmit && !valid +                        ? [styles.input, styles.invalidColor] +                        : styles.input +                    }                    /> +                  <TouchableOpacity +                    accessibilityLabel="Show password button" +                    accessibilityHint="Select this if you want to display your tagg password" +                    style={styles.showPassContainer} +                    onPress={() => setPassVisibility(!passVisibility)}> +                    <Text style={styles.showPass}>Show Password</Text> +                  </TouchableOpacity> +                  <LoadingIndicator /> +                  <TermsConditions +                    style={styles.tc} +                    accepted={tcAccepted} +                    onChange={setTCAccepted} +                  /> +                  <Animated.View style={{opacity: fadeButtonValue}}> +                    <TaggSquareButton +                      onPress={advanceRegistration} +                      title={'Next'} +                      buttonStyle={'normal'} +                      buttonColor={'white'} +                      labelColor={'blue'} +                    /> +                  </Animated.View>                  </Animated.View> -              </Animated.View> +              </>              )}            </>          )} diff --git a/src/screens/profile/MomentCommentsScreen.tsx b/src/screens/profile/MomentCommentsScreen.tsx index ffe21f4c..4b332b56 100644 --- a/src/screens/profile/MomentCommentsScreen.tsx +++ b/src/screens/profile/MomentCommentsScreen.tsx @@ -32,8 +32,6 @@ type MomentCommentContextType = {    ) => void;    shouldUpdateAllComments: boolean;    setShouldUpdateAllComments: (available: boolean) => void; -  commentsLength: number; -  setCommentsLength: (length: number) => void;  };  export const CommentContext = React.createContext( @@ -68,8 +66,6 @@ const MomentCommentsScreen: React.FC<MomentCommentsScreenProps> = ({route}) => {          setCommentTapped,          shouldUpdateAllComments,          setShouldUpdateAllComments, -        commentsLength, -        setCommentsLength,        }}>        <View style={styles.background}>          <SafeAreaView> @@ -81,6 +77,7 @@ const MomentCommentsScreen: React.FC<MomentCommentsScreenProps> = ({route}) => {                shouldUpdate={shouldUpdateAllComments}                setShouldUpdate={setShouldUpdateAllComments}                isThread={false} +              setCommentsLengthParent={setCommentsLength}              />              <AddComment                placeholderText={ diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index c11d874f..8b7b78e1 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -11,6 +11,7 @@ import {    PROFILE_INFO_ENDPOINT,    PROFILE_PHOTO_ENDPOINT,    REGISTER_ENDPOINT, +  REGISTER_VALIDATE_ENDPOINT,    SEND_OTP_ENDPOINT,    TAGG_CUSTOMER_SUPPORT,    USER_PROFILE_ENDPOINT, @@ -432,3 +433,29 @@ export const visitedUserProfile = async (userId: string) => {      return undefined;    }  }; + +export const verifyExistingInformation = async ( +  email: string | undefined, +  username: string | undefined, +) => { +  try { +    const form = new FormData(); +    if (email) { +      form.append('email', email); +    }  +    if (username) { +      form.append('username', username); +    } +    const response = await fetch(REGISTER_VALIDATE_ENDPOINT, { +      method: 'POST', +      headers: { +        'Content-Type': 'multipart/form-data', +      }, +      body: form, +    }); +    return response.status===200; +  } catch (error) { +    console.log(error); +    return false; +  } +}; | 
