diff options
Diffstat (limited to 'src')
-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; + } +}; |