diff --git a/AGENTS.md b/AGENTS.md index d99cd6af73b8..e5c25da7d0a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,4 @@ # Repo Notes - This repo uses React Compiler. Assume React Compiler patterns are enabled when editing React code, and avoid adding `useMemo`/`useCallback` by default unless they are clearly needed for correctness or compatibility with existing code. +- When a component reads multiple adjacent values from the same store hook, prefer a consolidated selector with `C.useShallow(...)` instead of multiple separate subscriptions. diff --git a/shared/team-building/contacts.tsx b/shared/team-building/contacts.tsx index e3d63325c69c..8eda6c68cf6f 100644 --- a/shared/team-building/contacts.tsx +++ b/shared/team-building/contacts.tsx @@ -1,19 +1,32 @@ import * as React from 'react' +import * as C from '@/constants' import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTBContext} from '@/stores/team-building' const useContactsProps = () => { - const contactsImported = useSettingsContactsState(s => s.importEnabled) - const contactsPermissionStatus = useSettingsContactsState(s => s.permissionStatus) - const isImportPromptDismissed = useSettingsContactsState(s => s.importPromptDismissed) - const numContactsImported = useSettingsContactsState(s => s.importedCount || 0) - - const importContactsLater = useSettingsContactsState(s => s.dispatch.importContactsLater) - const loadContactImportEnabled = useSettingsContactsState(s => s.dispatch.loadContactImportEnabled) - const editContactImportEnabled = useSettingsContactsState(s => s.dispatch.editContactImportEnabled) - const requestPermissions = useSettingsContactsState(s => s.dispatch.requestPermissions) + const { + contactsImported, + contactsPermissionStatus, + editContactImportEnabled, + importContactsLater, + isImportPromptDismissed, + loadContactImportEnabled, + numContactsImported, + requestPermissions, + } = useSettingsContactsState( + C.useShallow(s => ({ + contactsImported: s.importEnabled, + contactsPermissionStatus: s.permissionStatus, + editContactImportEnabled: s.dispatch.editContactImportEnabled, + importContactsLater: s.dispatch.importContactsLater, + isImportPromptDismissed: s.importPromptDismissed, + loadContactImportEnabled: s.dispatch.loadContactImportEnabled, + numContactsImported: s.importedCount || 0, + requestPermissions: s.dispatch.requestPermissions, + })) + ) const onAskForContactsLater = importContactsLater const onLoadContactsSetting = loadContactImportEnabled diff --git a/shared/team-building/filtered-service-tab-bar.tsx b/shared/team-building/filtered-service-tab-bar.tsx index cd11373dfeb7..e2e3aae2899e 100644 --- a/shared/team-building/filtered-service-tab-bar.tsx +++ b/shared/team-building/filtered-service-tab-bar.tsx @@ -3,17 +3,19 @@ import type * as T from '@/constants/types' import {ServiceTabBar} from './service-tab-bar' import * as TeamBuilding from '@/stores/team-building' +const getVisibleServices = (filterServices?: Array) => + filterServices + ? TeamBuilding.allServices.filter(serviceId => filterServices.includes(serviceId)) + : TeamBuilding.allServices + export const FilteredServiceTabBar = ( props: Omit, 'services'> & { filterServices?: Array } ) => { - const {selectedService, onChangeService} = props - const {servicesShown, minimalBorder, offset, filterServices} = props + const {selectedService, onChangeService, servicesShown, minimalBorder, offset, filterServices} = props + const services = getVisibleServices(filterServices) - const services = filterServices - ? TeamBuilding.allServices.filter(serviceId => filterServices.includes(serviceId)) - : TeamBuilding.allServices return services.length === 1 && services[0] === 'keybase' ? null : ( ): Array => +const deriveSelectedUsers = (teamSoFar: ReadonlySet): Array => [...teamSoFar].map(userInfo => { let username = '' let serviceId: T.TB.ServiceIdWithContact @@ -41,6 +41,139 @@ const deriveTeamSoFar = (teamSoFar: ReadonlySet): Array searchResults.get(searchString.trim())?.get(selectedService) + +const findUserById = (users: ReadonlyArray | undefined, userId: string) => + users?.find(user => user.id === userId) + +const shouldShowContactsBanner = ( + filterServices: ReadonlyArray | undefined +) => Kb.Styles.isMobile && (!filterServices || filterServices.includes('phone')) + +const useTeamBuildingData = ( + searchString: string, + selectedService: T.TB.ServiceIdWithContact +) => { + const {searchResults, error, rawTeamSoFar, userRecs} = TB.useTBContext( + C.useShallow(s => ({ + error: s.error, + rawTeamSoFar: s.teamSoFar, + searchResults: s.searchResults, + userRecs: s.userRecs, + })) + ) + + return { + error, + teamSoFar: deriveSelectedUsers(rawTeamSoFar), + userRecs, + userResults: getUserResults(searchResults, searchString, selectedService), + } +} + +const useTeamBuildingActions = ({ + namespace, + searchString, + selectedService, + userResults, + userRecs, + setFocusInputCounter, + setHighlightedIndex, + setSearchString, + setSelectedService, +}: { + namespace: T.TB.AllowedNamespace + searchString: string + selectedService: T.TB.ServiceIdWithContact + userResults: ReadonlyArray | undefined + userRecs: ReadonlyArray | undefined + setFocusInputCounter: React.Dispatch> + setHighlightedIndex: React.Dispatch> + setSearchString: React.Dispatch> + setSelectedService: React.Dispatch> +}) => { + const { + addUsersToTeamSoFar, + cancelTeamBuilding, + dispatchSearch, + fetchUserRecs, + finishTeamBuilding, + finishedTeamBuilding, + removeUsersFromTeamSoFar, + } = TB.useTBContext( + C.useShallow(s => ({ + addUsersToTeamSoFar: s.dispatch.addUsersToTeamSoFar, + cancelTeamBuilding: s.dispatch.cancelTeamBuilding, + dispatchSearch: s.dispatch.search, + fetchUserRecs: s.dispatch.fetchUserRecs, + finishTeamBuilding: s.dispatch.finishTeamBuilding, + finishedTeamBuilding: s.dispatch.finishedTeamBuilding, + removeUsersFromTeamSoFar: s.dispatch.removeUsersFromTeamSoFar, + })) + ) + + const search = C.useThrottledCallback( + (query: string, service: T.TB.ServiceIdWithContact, limit?: number) => { + dispatchSearch(query, service, namespace === 'chat', limit) + }, + 500 + ) + + const focusInput = () => { + setFocusInputCounter(old => old + 1) + } + + const onChangeText = (newText: string) => { + setSearchString(newText) + search(newText, selectedService) + setHighlightedIndex(0) + } + + const onAdd = (userId: string) => { + const user = findUserById(userResults, userId) ?? findUserById(userRecs, userId) + if (!user) { + logger.error(`Couldn't find Types.User to add for ${userId}`) + onChangeText('') + return + } + + onChangeText('') + addUsersToTeamSoFar([user]) + setHighlightedIndex(-1) + focusInput() + } + + const onChangeService = (service: T.TB.ServiceIdWithContact) => { + setSelectedService(service) + focusInput() + if (!T.TB.isContactServiceId(service)) { + search(searchString, service) + } + } + + return { + cancelTeamBuilding, + fetchUserRecs, + onAdd, + onChangeService, + onChangeText, + onFinishTeamBuilding: namespace === 'teams' ? finishTeamBuilding : finishedTeamBuilding, + onRemove: (userId: string) => { + removeUsersFromTeamSoFar([userId]) + }, + onSearchForMore: (len: number) => { + if (len >= 10) { + search(searchString, selectedService, len + 20) + } + }, + search, + } +} + type OwnProps = { namespace: T.TB.AllowedNamespace teamID?: string @@ -50,12 +183,7 @@ type OwnProps = { recommendedHideYourself?: boolean } -const TeamBuilding = (p: OwnProps) => { - const namespace = p.namespace - const teamID = p.teamID - const filterServices = p.filterServices - const goButtonLabel = p.goButtonLabel ?? 'Start' - +const TeamBuilding = ({namespace, teamID, filterServices, goButtonLabel = 'Start'}: OwnProps) => { const [focusInputCounter, setFocusInputCounter] = React.useState(0) const [enterInputCounter, setEnterInputCounter] = React.useState(0) const [highlightedIndex, setHighlightedIndex] = React.useState(0) @@ -70,79 +198,35 @@ const TeamBuilding = (p: OwnProps) => { setHighlightedIndex(old => (old < 1 ? 0 : old - 1)) } - const incFocusInputCounter = () => { - setFocusInputCounter(old => old + 1) - } - const onEnterKeyDown = () => { setEnterInputCounter(old => old + 1) } - const searchResults = TB.useTBContext(s => s.searchResults) - const error = TB.useTBContext(s => s.error) - const _teamSoFar = TB.useTBContext(s => s.teamSoFar) - const userRecs = TB.useTBContext(s => s.userRecs) - - const userResults: ReadonlyArray | undefined = searchResults - .get(searchString.trim()) - ?.get(selectedService) - - const teamSoFar = deriveTeamSoFar(_teamSoFar) - - const cancelTeamBuilding = TB.useTBContext(s => s.dispatch.cancelTeamBuilding) - const finishTeamBuilding = TB.useTBContext(s => s.dispatch.finishTeamBuilding) - const finishedTeamBuilding = TB.useTBContext(s => s.dispatch.finishedTeamBuilding) - const removeUsersFromTeamSoFar = TB.useTBContext(s => s.dispatch.removeUsersFromTeamSoFar) - const addUsersToTeamSoFar = TB.useTBContext(s => s.dispatch.addUsersToTeamSoFar) - const fetchUserRecs = TB.useTBContext(s => s.dispatch.fetchUserRecs) - - const _search = TB.useTBContext(s => s.dispatch.search) - const search = C.useThrottledCallback( - (query: string, service: T.TB.ServiceIdWithContact, limit?: number) => { - _search(query, service, namespace === 'chat', limit) - }, - 500 - ) + const {error, teamSoFar, userRecs, userResults} = useTeamBuildingData(searchString, selectedService) + const { + cancelTeamBuilding, + fetchUserRecs, + onAdd, + onChangeService, + onChangeText, + onFinishTeamBuilding, + onRemove, + onSearchForMore, + search, + } = useTeamBuildingActions({ + namespace, + searchString, + selectedService, + userResults, + userRecs, + setFocusInputCounter, + setHighlightedIndex, + setSearchString, + setSelectedService, + }) const onClose = cancelTeamBuilding - const onFinishTeamBuilding = namespace === 'teams' ? finishTeamBuilding : finishedTeamBuilding - const onRemove = (userId: string) => { - removeUsersFromTeamSoFar([userId]) - } - - const onChangeText = (newText: string) => { - setSearchString(newText) - search(newText, selectedService) - setHighlightedIndex(0) - } - const onClear = () => onChangeText('') - const onSearchForMore = (len: number) => { - if (len >= 10) { - search(searchString, selectedService, len + 20) - } - } - const onAdd = (userId: string) => { - const user = userResults?.filter(u => u.id === userId)[0] ?? userRecs?.filter(u => u.id === userId)[0] - - if (!user) { - logger.error(`Couldn't find Types.User to add for ${userId}`) - onChangeText('') - return - } - onChangeText('') - addUsersToTeamSoFar([user]) - setHighlightedIndex(-1) - incFocusInputCounter() - } - - const onChangeService = (service: T.TB.ServiceIdWithContact) => { - setSelectedService(service) - incFocusInputCounter() - if (!T.TB.isContactServiceId(service)) { - search(searchString, service) - } - } const waitingForCreate = C.Waiting.useAnyWaiting(C.waitingKeyChatCreating) @@ -235,10 +319,7 @@ const TeamBuilding = (p: OwnProps) => { ) const errorBanner = !!error && {error} - - // If there are no filterServices or if the filterServices has a phone - const showContactsBanner = Kb.Styles.isMobile && (!filterServices || filterServices.includes('phone')) - + const showContactsBanner = shouldShowContactsBanner(filterServices) return ( <> diff --git a/shared/team-building/list-body.tsx b/shared/team-building/list-body.tsx index e905776f41ed..f0e08edee75f 100644 --- a/shared/team-building/list-body.tsx +++ b/shared/team-building/list-body.tsx @@ -16,11 +16,31 @@ import {useRoute} from '@react-navigation/native' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useFollowerState} from '@/stores/followers' import {useCurrentUserState} from '@/stores/current-user' -// import {useAnimatedScrollHandler} from '@/common-adapters/reanimated' import {useColorScheme} from 'react-native' -const Suggestions = (props: Pick) => { - const {namespace, selectedService} = props +type SuggestionsProps = { + namespace: T.TB.AllowedNamespace + selectedService: T.TB.ServiceIdWithContact +} + +type ListBodyProps = { + namespace: T.TB.AllowedNamespace + searchString: string + selectedService: T.TB.ServiceIdWithContact + highlightedIndex: number + onAdd: (userId: string) => void + onRemove: (userId: string) => void + teamSoFar: ReadonlyArray + onSearchForMore: (len: number) => void + onChangeText: (newText: string) => void + onFinishTeamBuilding: () => void + offset: unknown + enterInputCounter: number +} + +type DerivedResults = ReturnType + +const Suggestions = ({namespace, selectedService}: SuggestionsProps) => { const isDarkMode = useColorScheme() === 'dark' return ( ) ) } -function isKeybaseUserId(userId: string) { - // Only keybase user id's do not have - return !userId.includes('@') -} +const isKeybaseUserId = (userId: string) => !userId.includes('@') -function followStateHelperWithId( - me: string, - followingState: ReadonlySet, - userId: string = '' -): T.TB.FollowingState { - if (isKeybaseUserId(userId)) { - if (userId === me) { - return 'You' - } else { - return followingState.has(userId) ? 'Following' : 'NotFollowing' - } +const getFollowingState = ( + myUsername: string, + following: ReadonlySet, + userId = '' +): T.TB.FollowingState => { + if (!isKeybaseUserId(userId)) { + return 'NoState' } - return 'NoState' + + if (userId === myUsername) { + return 'You' + } + + return following.has(userId) ? 'Following' : 'NotFollowing' } const deriveSearchResults = ( - searchResults: ReadonlyArray | undefined, + users: ReadonlyArray | undefined, teamSoFar: ReadonlySet, myUsername: string, - followingState: ReadonlySet, + following: ReadonlySet, preExistingTeamMembers: ReadonlyMap -) => - searchResults?.map(info => { +) => { + const teamMemberIds = new Set([...teamSoFar].map(user => user.id)) + + return users?.map(info => { const label = info.label || '' return { contact: !!info.contact, displayLabel: formatAnyPhoneNumbers(label), - followingState: followStateHelperWithId(myUsername, followingState, info.serviceMap.keybase), - inTeam: [...teamSoFar].some(u => u.id === info.id), + followingState: getFollowingState(myUsername, following, info.serviceMap.keybase), + inTeam: teamMemberIds.has(info.id), isPreExistingTeamMember: preExistingTeamMembers.has(info.id), isYou: info.username === myUsername, key: [info.id, info.prettyName, info.label, String(!!info.contact)].join('&'), @@ -105,214 +125,314 @@ const deriveSearchResults = ( username: info.username, } }) - -// Flatten list of recommendation sections. After recommendations are organized -// in sections, we also need a flat list of all recommendations to be able to -// know how many we have in total (including "fake" "import contacts" row), and -// which one is currently highlighted, to support keyboard events. -// -// Resulting list may have nulls in place of fake rows. -const flattenRecommendations = (recommendations: Array) => { - const result: Array = [] - for (const section of recommendations) { - result.push( - ...section.data.map(rec => ('isImportButton' in rec || 'isSearchHint' in rec ? undefined : rec)) - ) - } - return result } +const toSelectableRecommendation = (rec: Types.ResultData) => + 'isImportButton' in rec || 'isSearchHint' in rec ? undefined : rec + +const flattenRecommendations = (recommendations: ReadonlyArray) => + recommendations.reduce>((results, section) => { + results.push(...section.data.map(toSelectableRecommendation)) + return results + }, []) + const alphabet = 'abcdefghijklmnopqrstuvwxyz' const aCharCode = alphabet.charCodeAt(0) const alphaSet = new Set(alphabet) const isAlpha = (letter: string) => alphaSet.has(letter) const letterToAlphaIndex = (letter: string) => letter.charCodeAt(0) - aCharCode +const createSection = ( + label: string, + shortcut: boolean, + data: Array = [] +): Types.SearchRecSection => ({ + data, + label, + shortcut, +}) + +const getRecommendationsSectionIndex = ( + rec: Types.SearchResult, + recommendationIndex: number, + numericSectionIndex: number +) => { + if (!rec.contact) { + return recommendationIndex + } + + const displayName = rec.prettyName || rec.displayLabel + const firstLetter = displayName[0]?.toLowerCase() + if (!firstLetter) { + return undefined + } + + return isAlpha(firstLetter) + ? letterToAlphaIndex(firstLetter) + recommendationIndex + 1 + : numericSectionIndex +} + // Returns array with 28 entries // 0 - "Recommendations" section // 1-26 - a-z sections // 27 - 0-9 section const sortAndSplitRecommendations = ( - results: T.Unpacked, + results: DerivedResults, showingContactsButton: boolean ): Array | undefined => { - if (!results) return undefined - - const sections: Array = [ - ...(showingContactsButton - ? [ - { - data: [{isImportButton: true as const}], - label: '', - shortcut: false, - }, - ] - : []), + if (!results) { + return undefined + } + + const sections: Array = [] + if (showingContactsButton) { + sections.push(createSection('', false, [{isImportButton: true}])) + } + sections.push(createSection('Recommendations', false)) + + const recommendationIndex = sections.length - 1 + const numericSectionIndex = recommendationIndex + 27 - { - data: [], - label: 'Recommendations', - shortcut: false, - }, - ] - const recSectionIdx = sections.length - 1 - const numSectionIdx = recSectionIdx + 27 results.forEach(rec => { - if (!rec.contact) { - sections[recSectionIdx]?.data.push(rec) + const sectionIndex = getRecommendationsSectionIndex(rec, recommendationIndex, numericSectionIndex) + if (sectionIndex === undefined) { return } - if (rec.prettyName || rec.displayLabel) { - // Use the first letter of the name we will display, but first normalize out - // any diacritics. - const decodedLetter = /*unidecode*/ rec.prettyName || rec.displayLabel - if (decodedLetter[0]) { - const letter = decodedLetter[0].toLowerCase() - if (isAlpha(letter)) { - // offset 1 to skip recommendations - const sectionIdx = letterToAlphaIndex(letter) + recSectionIdx + 1 - if (!sections[sectionIdx]) { - sections[sectionIdx] = { - data: [], - label: letter.toUpperCase(), - shortcut: true, - } - } - sections[sectionIdx].data.push(rec) - } else { - if (!sections[numSectionIdx]) { - sections[numSectionIdx] = { - data: [], - label: numSectionLabel, - shortcut: true, - } - } - sections[numSectionIdx].data.push(rec) - } - } + + if (!sections[sectionIndex]) { + const isNumericSection = sectionIndex === numericSectionIndex + const label = isNumericSection + ? numSectionLabel + : String.fromCharCode(aCharCode + sectionIndex - recommendationIndex - 1).toUpperCase() + sections[sectionIndex] = createSection(label, true) } + sections[sectionIndex]?.data.push(rec) }) + if (results.length < 5) { - sections.push({ - data: [{isSearchHint: true as const}], - label: '', - shortcut: false, - }) + sections.push(createSection('', false, [{isSearchHint: true}])) } - return sections.filter(s => s.data.length > 0) + + return sections.filter(section => section.data.length > 0) } -const emptyMap = new Map() - -export const ListBody = ( - props: Pick< - Types.Props, - | 'namespace' - | 'searchString' - | 'selectedService' - | 'highlightedIndex' - | 'onAdd' - | 'onRemove' - | 'teamSoFar' - | 'onSearchForMore' - | 'onChangeText' - | 'onFinishTeamBuilding' - > & { - offset: unknown - enterInputCounter: number - } +const getSearchResults = ( + searchResults: T.TB.SearchResults, + searchString: string, + selectedService: T.TB.ServiceIdWithContact +) => searchResults.get(searchString.trim())?.get(selectedService) + +const getSelectableResults = ( + showRecs: boolean, + recommendations: ReadonlyArray | undefined, + searchResults: DerivedResults +) => (showRecs ? flattenRecommendations(recommendations ?? []) : searchResults) + +const getHighlightedResult = ( + highlightedIndex: number, + userResults: ReturnType ) => { - const {params} = useRoute>() - const recommendedHideYourself = params.recommendedHideYourself ?? false - const teamID = params.teamID - const {searchString, selectedService} = props - const {onAdd, onRemove, teamSoFar, onSearchForMore, onChangeText} = props - const {namespace, highlightedIndex, /*offset, */ enterInputCounter, onFinishTeamBuilding} = props + if (!userResults?.length) { + return undefined + } - const contactsImported = useSettingsContactsState(s => s.importEnabled) - const contactsPermissionStatus = useSettingsContactsState(s => s.permissionStatus) + return userResults[highlightedIndex % userResults.length] +} + +const emptyMap = new Map() +const useListBodyData = ({ + searchString, + selectedService, + teamID, +}: { + searchString: string + selectedService: T.TB.ServiceIdWithContact + teamID?: T.Teams.TeamID +}) => { + const {contactsImported, contactsPermissionStatus} = useSettingsContactsState( + C.useShallow(s => ({ + contactsImported: s.importEnabled, + contactsPermissionStatus: s.permissionStatus, + })) + ) const username = useCurrentUserState(s => s.username) const following = useFollowerState(s => s.following) - const maybeTeamDetails = useTeamsState(s => (teamID ? s.teamDetails.get(teamID) : undefined)) const preExistingTeamMembers: T.Teams.TeamDetails['members'] = maybeTeamDetails?.members ?? emptyMap - const userRecs = TB.useTBContext(s => s.userRecs) - const _teamSoFar = TB.useTBContext(s => s.teamSoFar) - const _searchResults = TB.useTBContext(s => s.searchResults) - const _recommendations = deriveSearchResults(userRecs, _teamSoFar, username, following, preExistingTeamMembers) - - const userResults: ReadonlyArray | undefined = _searchResults - .get(searchString.trim()) - ?.get(selectedService) + const {allSearchResults, teamSoFar, userRecs} = TB.useTBContext( + C.useShallow(s => ({ + allSearchResults: s.searchResults, + teamSoFar: s.teamSoFar, + userRecs: s.userRecs, + })) + ) - const searchResults = deriveSearchResults(userResults, _teamSoFar, username, following, preExistingTeamMembers) + const recommendationResults = deriveSearchResults( + userRecs, + teamSoFar, + username, + following, + preExistingTeamMembers + ) + const userResults = getSearchResults(allSearchResults, searchString, selectedService) + const searchResults = deriveSearchResults( + userResults, + teamSoFar, + username, + following, + preExistingTeamMembers + ) const showResults = !!searchString - const showRecs = !searchString && !!_recommendations && selectedService === 'keybase' - - const ResultRow = namespace === 'people' ? PeopleResult : UserResult - const showLoading = !!searchString && !searchResults - + const showRecs = !searchString && !!recommendationResults && selectedService === 'keybase' const showingContactsButton = C.isMobile && contactsPermissionStatus !== 'denied' && !contactsImported - const recommendations = showRecs ? sortAndSplitRecommendations(_recommendations, showingContactsButton) : undefined + const recommendations = showRecs + ? sortAndSplitRecommendations(recommendationResults, showingContactsButton) + : undefined - const showRecPending = !searchString && !recommendations && selectedService === 'keybase' + return { + recommendations, + searchResults, + showLoading: !!searchString && !searchResults, + showRecPending: !searchString && !recommendations && selectedService === 'keybase', + showRecs, + showResults, + } +} +const useEnterKeyHandler = ({ + enterInputCounter, + highlightedIndex, + onAdd, + onChangeText, + onFinishTeamBuilding, + onRemove, + recommendations, + searchResults, + searchString, + showRecs, + teamSoFar, +}: { + enterInputCounter: number + highlightedIndex: number + onAdd: (userId: string) => void + onChangeText: (newText: string) => void + onFinishTeamBuilding: () => void + onRemove: (userId: string) => void + recommendations: ReadonlyArray | undefined + searchResults: DerivedResults + searchString: string + showRecs: boolean + teamSoFar: ReadonlyArray +}) => { const lastEnterInputCounterRef = React.useRef(enterInputCounter) + React.useEffect(() => { - if (lastEnterInputCounterRef.current !== enterInputCounter) { - lastEnterInputCounterRef.current = enterInputCounter - const userResultsToShow = showRecs ? flattenRecommendations(recommendations ?? []) : searchResults - const selectedResult = - !!userResultsToShow && userResultsToShow[highlightedIndex % userResultsToShow.length] - if (selectedResult) { - // We don't handle cases where they hit enter on someone that is already a - // team member - if (selectedResult.isPreExistingTeamMember) { - return - } - if (teamSoFar.filter(u => u.userId === selectedResult.userId).length) { - onRemove(selectedResult.userId) - onChangeText('') - } else { - onAdd(selectedResult.userId) - } - } else if (!searchString && !!teamSoFar.length) { - // They hit enter with an empty search string and a teamSoFar - // We'll Finish the team building - onFinishTeamBuilding() + if (lastEnterInputCounterRef.current === enterInputCounter) { + return + } + + lastEnterInputCounterRef.current = enterInputCounter + const selectableResults = getSelectableResults(showRecs, recommendations, searchResults) + const selectedResult = getHighlightedResult(highlightedIndex, selectableResults) + + if (selectedResult) { + if (selectedResult.isPreExistingTeamMember) { + return + } + + if (teamSoFar.some(user => user.userId === selectedResult.userId)) { + onRemove(selectedResult.userId) + onChangeText('') + } else { + onAdd(selectedResult.userId) } + return + } + + if (!searchString && teamSoFar.length) { + onFinishTeamBuilding() } }, [ enterInputCounter, - showRecs, + highlightedIndex, + onAdd, + onChangeText, + onFinishTeamBuilding, + onRemove, recommendations, searchResults, - highlightedIndex, + searchString, + showRecs, teamSoFar, - onRemove, - onChangeText, + ]) +} + +const LoadingState = ({showLoading}: {showLoading: boolean}) => ( + + {showLoading && } + +) + +const NoResults = () => ( + + Sorry, no results were found. + +) + +export const ListBody = ({ + namespace, + searchString, + selectedService, + highlightedIndex, + onAdd, + onRemove, + teamSoFar, + onSearchForMore, + onChangeText, + onFinishTeamBuilding, + enterInputCounter, +}: ListBodyProps) => { + const {params} = useRoute>() + const recommendedHideYourself = params.recommendedHideYourself ?? false + const teamID = params.teamID + const ResultRow = namespace === 'people' ? PeopleResult : UserResult + + const {recommendations, searchResults, showLoading, showRecPending, showRecs, showResults} = useListBodyData( + { + searchString, + selectedService, + teamID, + } + ) + + useEnterKeyHandler({ + enterInputCounter, + highlightedIndex, onAdd, - searchString, + onChangeText, onFinishTeamBuilding, - ]) + onRemove, + recommendations, + searchResults, + searchString, + showRecs, + teamSoFar, + }) if (showRecPending || showLoading) { - return ( - - {showLoading && } - - ) + return } + if (!showRecs && !showResults) { return } @@ -332,53 +452,45 @@ export const ListBody = ( ) } - const _onSearchForMore = () => { + const onEndReached = throttle(() => { onSearchForMore(searchResults?.length ?? 0) - } - - const _onEndReached = throttle(_onSearchForMore, 500) + }, 500) - return ( - <> - {searchResults?.length ? ( - - ( - - )} + return searchResults?.length ? ( + + ( + - - ) : ( - - Sorry, no results were found. - - )} - + )} + /> + + ) : ( + ) } diff --git a/shared/team-building/recs-and-recos.tsx b/shared/team-building/recs-and-recos.tsx index 014eecbda86d..e98ea6118730 100644 --- a/shared/team-building/recs-and-recos.tsx +++ b/shared/team-building/recs-and-recos.tsx @@ -1,5 +1,6 @@ import * as Kb from '@/common-adapters' import * as React from 'react' +import type * as T from '@/constants/types' import AlphabetIndex from './alphabet-index' import PeopleResult from './search-result/people-result' import UserResult from './search-result/user-result' @@ -7,6 +8,24 @@ import type * as Types from './types' import {ContactsImportButton} from './contacts' type RefType = React.RefObject | null> +type TeamSoFar = ReadonlyArray<{userId: string}> + +type TeamAlphabetIndexProps = { + recommendations?: Array + teamSoFar: TeamSoFar + sectionListRef: RefType +} + +type RecsAndRecosProps = { + highlightedIndex: number + recommendations?: Array + namespace: T.TB.AllowedNamespace + selectedService: T.TB.ServiceIdWithContact + onAdd: (userId: string) => void + onRemove: (userId: string) => void + teamSoFar: TeamSoFar + recommendedHideYourself: boolean +} export const numSectionLabel = '0-9' @@ -18,12 +37,7 @@ const SearchHintText = () => ( ) -const TeamAlphabetIndex = ( - props: Pick & { - sectionListRef: RefType - } -) => { - const {recommendations, teamSoFar, sectionListRef} = props +const TeamAlphabetIndex = ({recommendations, teamSoFar, sectionListRef}: TeamAlphabetIndexProps) => { let showNumSection = false let labels: Array = [] if (recommendations && recommendations.length > 0) { @@ -31,7 +45,7 @@ const TeamAlphabetIndex = ( labels = recommendations.filter(r => r.shortcut && r.label !== numSectionLabel).map(r => r.label) } - const _onScrollToSection = (label: string) => { + const onScrollToSection = (label: string) => { if (sectionListRef.current) { const sectionIndex = (recommendations && @@ -54,18 +68,18 @@ const TeamAlphabetIndex = ( } return ( <> - + ) } -const _listIndexToSectionAndLocalIndex = ( +const listIndexToSectionAndLocalIndex = ( highlightedIndex?: number, sections?: Types.SearchRecSection[] ): {index: number; section: Types.SearchRecSection} | undefined => { @@ -81,24 +95,13 @@ const _listIndexToSectionAndLocalIndex = ( } return } -export const RecsAndRecos = ( - props: Pick< - Types.Props, - | 'highlightedIndex' - | 'recommendations' - | 'namespace' - | 'selectedService' - | 'onAdd' - | 'onRemove' - | 'teamSoFar' - > & {recommendedHideYourself: boolean} -) => { +export const RecsAndRecos = (props: RecsAndRecosProps) => { const {highlightedIndex, recommendations, recommendedHideYourself, namespace} = props const {selectedService, onAdd, onRemove, teamSoFar} = props const sectionListRef = React.useRef>(null) const ResultRow = namespace === 'people' ? PeopleResult : UserResult - const highlightDetails = _listIndexToSectionAndLocalIndex(highlightedIndex, recommendations) + const highlightDetails = listIndexToSectionAndLocalIndex(highlightedIndex, recommendations) React.useEffect(() => { highlightedIndex >= 0 && diff --git a/shared/team-building/search-result/common-result.tsx b/shared/team-building/search-result/common-result.tsx index fa3585c1a016..3577c8b55804 100644 --- a/shared/team-building/search-result/common-result.tsx +++ b/shared/team-building/search-result/common-result.tsx @@ -33,6 +33,17 @@ export type CommonResultProps = ResultProps & { rowStyle?: Kb.Styles.StylesCrossPlatform } +type BottomRowProps = { + isKeybaseResult: boolean + username: string + isPreExistingTeamMember: boolean + keybaseUsername?: string + followingState: T.TB.FollowingState + displayLabel: string + prettyName: string + services: {[K in T.TB.ServiceIdWithContact]?: string} +} + /* * Case 1: the service is 'keybase' (isKeybaseResult = true) * @@ -48,6 +59,26 @@ export type CommonResultProps = ResultProps & { * {prettyName} if the user added it. Can fallback to username if no prettyName is set * {service icons} if the user has proofs */ +const getRowAction = (props: ResultProps) => { + if (props.isPreExistingTeamMember) { + return undefined + } + return props.inTeam ? () => props.onRemove(props.userId) : () => props.onAdd(props.userId) +} + +const FallbackResultInfo = ({displayLabel, prettyName}: Pick) => ( + <> + + {prettyName} + + {!!displayLabel && displayLabel !== prettyName && ( + + {displayLabel} + + )} + +) + const CommonResult = (props: CommonResultProps) => { /* * Regardless of the service that is being searched, if we find that a @@ -57,11 +88,10 @@ const CommonResult = (props: CommonResultProps) => { const isKeybaseResult = props.resultForService === 'keybase' const keybaseUsername: string | undefined = props.services['keybase'] const serviceUsername = props.services[props.resultForService] - const onAdd = !props.isPreExistingTeamMember ? () => props.onAdd(props.userId) : undefined - const onRemove = !props.isPreExistingTeamMember ? () => props.onRemove(props.userId) : undefined + const onClick = getRowAction(props) return ( - + { followingState={props.followingState} isKeybaseResult={isKeybaseResult} keybaseUsername={keybaseUsername} - username={serviceUsername || ''} + username={serviceUsername} /> {props.bottomRow ?? ( { keybaseUsername={keybaseUsername} prettyName={props.prettyName} services={props.services} - username={serviceUsername || ''} + username={serviceUsername} /> )} ) : ( - <> - - {props.prettyName} - - {!!props.displayLabel && props.displayLabel !== props.prettyName && ( - - {props.displayLabel} - - )} - + )} const textWithConditionalSeparator = (text: string, conditional: boolean) => `${text}${conditional ? ` ${dotSeparator}` : ''}` +const shouldOmitFirstIconMargin = ({ + displayLabel, + isKeybaseResult, + keybaseUsername, + prettyName, +}: Pick) => + !isKeybaseResult + ? !keybaseUsername && !prettyName && !displayLabel + : prettyName + ? prettyName === keybaseUsername + : !displayLabel + const Avatar = ({ resultForService, keybaseUsername, @@ -163,26 +196,17 @@ const Avatar = ({ } // If service icons are the only item present in the bottom row, then don't apply margin-left to the first icon -const ServicesIcons = (props: { +type ServicesIconsProps = { services: {[K in T.TB.ServiceIdWithContact]?: string} prettyName: string displayLabel: string isKeybaseResult: boolean keybaseUsername?: string -}) => { +} + +const ServicesIcons = (props: ServicesIconsProps) => { const serviceIds = serviceMapToArray(props.services) - // When the result is from a non-keybase service, we could have: - // 1. keybase username - // 2. pretty name or display label. prettyName can fallback to username if no prettyName is set. - // - // When the result is from the keybase service, we could have: - // 1. prettyName that matches the username - in which case it will be hidden - // 1. No prettyName and also no displayLabel - const firstIconNoMargin = !props.isKeybaseResult - ? !props.keybaseUsername && !props.prettyName && !props.displayLabel - : props.prettyName - ? props.prettyName === props.keybaseUsername - : !props.displayLabel + const firstIconNoMargin = shouldOmitFirstIconMargin(props) return ( {serviceIds.map((serviceName, index) => { @@ -244,37 +268,40 @@ const MobileScrollView = ({children}: {children: React.ReactNode}) => <>{children} ) -const BottomRow = (props: { - isKeybaseResult: boolean - username: string - isPreExistingTeamMember: boolean - keybaseUsername?: string +const KeybaseUsernameLabel = ({ + followingState, + keybaseUsername, +}: { followingState: T.TB.FollowingState - displayLabel: string - prettyName: string - services: {[K in T.TB.ServiceIdWithContact]?: string} -}) => { + keybaseUsername: string +}) => ( + <> + + {keybaseUsername} + +   + {dotSeparator} +   + +) + +const BottomRow = (props: BottomRowProps) => { const serviceUserIsAlsoKeybaseUser = !props.isKeybaseResult && props.keybaseUsername const showServicesIcons = props.isKeybaseResult || !!props.keybaseUsername - const keybaseUsernameComponent = serviceUserIsAlsoKeybaseUser ? ( - <> - - {props.keybaseUsername} - -   - {dotSeparator} -   - - ) : null return ( - {keybaseUsernameComponent} + {serviceUserIsAlsoKeybaseUser && props.keybaseUsername ? ( + + ) : null} {props.isPreExistingTeamMember ? ( {isPreExistingTeamMemberText(props.prettyName, props.username)} @@ -313,16 +340,18 @@ const Username = (props: { isKeybaseResult: boolean keybaseUsername?: string username: string -}) => ( - - {props.username} - -) +}) => { + const showFollowingState = props.isKeybaseResult && props.keybaseUsername + + return ( + + {props.username} + + ) +} export const userResultHeight = Kb.Styles.isMobile ? Kb.Styles.globalMargins.xlarge : 48 const styles = Kb.Styles.styleSheetCreate(() => ({ @@ -362,8 +391,8 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ }, })) -const followingStateToStyle = (followingState: T.TB.FollowingState) => { - return { +const followingStateToStyle = (followingState: T.TB.FollowingState) => + ({ Following: { color: Kb.Styles.globalColors.greenDark, }, @@ -376,7 +405,6 @@ const followingStateToStyle = (followingState: T.TB.FollowingState) => { You: { color: Kb.Styles.globalColors.black, }, - }[followingState] -} + })[followingState] export default CommonResult diff --git a/shared/team-building/search-result/people-result.tsx b/shared/team-building/search-result/people-result.tsx index 030bed5bc778..7b175bf26be1 100644 --- a/shared/team-building/search-result/people-result.tsx +++ b/shared/team-building/search-result/people-result.tsx @@ -52,19 +52,13 @@ const PeopleResult = function PeopleResult(props: ResultProps) { } const resultIsMe = keybaseUsername === myUsername - const dropdown = keybaseUsername ? ( + const dropdown = ( - ) : ( - ) @@ -97,44 +91,49 @@ type DropdownProps = { onUnfollow?: () => void } +const buildMenuItems = ({ + blocked, + onAddToTeam, + onBrowsePublicFolder, + onManageBlocking, + onOpenPrivateFolder, +}: DropdownProps): Kb.MenuItems => + [ + onAddToTeam && {icon: 'iconfont-add', onClick: onAddToTeam, title: 'Add to team...'}, + onOpenPrivateFolder && { + icon: 'iconfont-folder-open', + onClick: onOpenPrivateFolder, + title: 'Open private folder', + }, + onBrowsePublicFolder && { + icon: 'iconfont-folder-public', + onClick: onBrowsePublicFolder, + title: 'Browse public folder', + }, + onManageBlocking && { + danger: true, + icon: 'iconfont-add', + onClick: onManageBlocking, + title: blocked ? 'Manage blocking' : 'Block', + }, + ].filter(Boolean) as Kb.MenuItems + const DropdownButton = (p: DropdownProps) => { - const {onAddToTeam, onOpenPrivateFolder, onBrowsePublicFolder, onManageBlocking, blocked} = p - const items: Kb.MenuItems = [ - onAddToTeam && {icon: 'iconfont-add', onClick: onAddToTeam, title: 'Add to team...'}, - onOpenPrivateFolder && { - icon: 'iconfont-folder-open', - onClick: onOpenPrivateFolder, - title: 'Open private folder', - }, - onBrowsePublicFolder && { - icon: 'iconfont-folder-public', - onClick: onBrowsePublicFolder, - title: 'Browse public folder', - }, - onManageBlocking && { - danger: true, - icon: 'iconfont-add', - onClick: onManageBlocking, - title: blocked ? 'Manage blocking' : 'Block', - }, - ].reduce((arr, i) => { - i && arr.push(i as Kb.MenuItem) - return arr - }, []) + const items = buildMenuItems(p) const makePopup = (p: Kb.Popup2Parms) => { - const {attachTo, hidePopup} = p - return ( - - ) - } + const {attachTo, hidePopup} = p + return ( + + ) + } const {showPopup, popup, popupAnchor} = Kb.usePopup2(makePopup) return ( diff --git a/shared/team-building/service-tab-bar.desktop.tsx b/shared/team-building/service-tab-bar.desktop.tsx index 35455930bfca..977b96d9925a 100644 --- a/shared/team-building/service-tab-bar.desktop.tsx +++ b/shared/team-building/service-tab-bar.desktop.tsx @@ -6,6 +6,26 @@ import type * as T from '@/constants/types' import type {Props, IconProps} from './service-tab-bar' import {useColorScheme} from 'react-native' +const getDesktopServicesLayout = ( + services: ReadonlyArray, + selectedService: T.TB.ServiceIdWithContact, + servicesShown: number, + lastSelectedUnlockedService?: T.TB.ServiceIdWithContact +) => { + const lockedServices = services.slice(0, servicesShown) + const selectedServiceIsLocked = services.indexOf(selectedService) < servicesShown + const frontServices = selectedServiceIsLocked + ? lastSelectedUnlockedService === undefined + ? services.slice(0, servicesShown + 1) + : lockedServices.concat(lastSelectedUnlockedService) + : lockedServices.concat(selectedService) + + return { + frontServices, + moreServices: difference(services, frontServices), + } +} + const ServiceIcon = (props: IconProps) => { const [hover, setHover] = React.useState(false) const isDarkMode = useColorScheme() === 'dark' @@ -72,21 +92,21 @@ const MoreNetworksButton = (props: { }) => { const {services, onChangeService} = props const makePopup = (p: Kb.Popup2Parms) => { - const {attachTo, hidePopup} = p - return ( - ({ - onClick: () => onChangeService(service), - title: service, - view: , - }))} - onHidden={hidePopup} - visible={true} - /> - ) - } + const {attachTo, hidePopup} = p + return ( + ({ + onClick: () => onChangeService(service), + title: service, + view: , + }))} + onHidden={hidePopup} + visible={true} + /> + ) + } const {showPopup, popup, popupAnchor} = Kb.usePopup2(makePopup) @@ -135,24 +155,19 @@ export const ServiceTabBar = (props: Props) => { >() const {services, onChangeService: propsOnChangeService, servicesShown: nLocked = 3} = props const onChangeService = (service: T.TB.ServiceIdWithContact) => { - if (services.indexOf(service) >= nLocked && service !== lastSelectedUnlockedService) { - setLastSelectedUnlockedService(service) - } - propsOnChangeService(service) - } - const lockedServices = services.slice(0, nLocked) - let frontServices = new Array() - if (services.indexOf(props.selectedService) < nLocked) { - // Selected service is locked - if (lastSelectedUnlockedService === undefined) { - frontServices = services.slice(0, nLocked + 1) - } else { - frontServices = lockedServices.concat([lastSelectedUnlockedService]) + if (services.indexOf(service) >= nLocked && service !== lastSelectedUnlockedService) { + setLastSelectedUnlockedService(service) } - } else { - frontServices = lockedServices.concat([props.selectedService]) + propsOnChangeService(service) } - const moreServices = difference(services, frontServices) + + const {frontServices, moreServices} = getDesktopServicesLayout( + services, + props.selectedService, + nLocked, + lastSelectedUnlockedService + ) + return ( {frontServices.map(service => ( diff --git a/shared/team-building/service-tab-bar.native.tsx b/shared/team-building/service-tab-bar.native.tsx index e6b9d09c6336..def651c62d23 100644 --- a/shared/team-building/service-tab-bar.native.tsx +++ b/shared/team-building/service-tab-bar.native.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import {serviceIdToIconFont, serviceIdToAccentColor, serviceIdToLongLabel, serviceIdToBadge} from './shared' -import type * as T from '@/constants/types' import {ScrollView} from 'react-native' import type {Props, IconProps} from './service-tab-bar' import {useColorScheme} from 'react-native' @@ -41,7 +40,6 @@ const AnimatedScrollView = createAnimatedComponent(ScrollView) // On tablet add an additional "service" item that is only a bottom border that extends to the end of the ScrollView const TabletBottomBorderExtension = function TabletBottomBorderExtension(props: { offset?: SharedValue - servicesCount: number }) { 'use no memo' const {offset} = props @@ -159,9 +157,6 @@ export const ServiceTabBar = (props: Props) => { 'use no memo' const {onChangeService, offset, services, selectedService} = props const bounceX = useSharedValue(40) - const onClick = (service: T.TB.ServiceIdWithContact) => { - onChangeService(service) - } React.useEffect(() => { bounceX.set(0) @@ -208,13 +203,11 @@ export const ServiceTabBar = (props: Props) => { offset={offset} service={service} label={serviceIdToLongLabel(service)} - onClick={onClick} + onClick={onChangeService} isActive={selectedService === service} /> ))} - {Kb.Styles.isTablet ? ( - - ) : null} + {Kb.Styles.isTablet ? : null} ) } diff --git a/shared/team-building/shared.tsx b/shared/team-building/shared.tsx index 9303fdcff0ef..dac68d852aa8 100644 --- a/shared/team-building/shared.tsx +++ b/shared/team-building/shared.tsx @@ -68,25 +68,23 @@ const services: { }, } +const accentColors: {[K in T.TB.ServiceIdWithContact]: string} = { + email: '#3663ea', + facebook: '#3B5998', + github: '#333', + hackernews: '#FF6600', + keybase: '#3663ea', + phone: '#3663ea', + reddit: '#ff4500', + twitter: '#1DA1F2', +} + +const darkModeAccentColors: Partial = { + github: '#E7E8E8', +} + export const serviceIdToAccentColor = (service: T.TB.ServiceIdWithContact, isDarkMode: boolean): string => { - switch (service) { - case 'email': - return isDarkMode ? '#3663ea' : '#3663ea' - case 'facebook': - return isDarkMode ? '#3B5998' : '#3B5998' - case 'github': - return isDarkMode ? '#E7E8E8' : '#333' - case 'hackernews': - return isDarkMode ? '#FF6600' : '#FF6600' - case 'keybase': - return isDarkMode ? '#3663ea' : '#3663ea' - case 'phone': - return isDarkMode ? '#3663ea' : '#3663ea' - case 'reddit': - return isDarkMode ? '#ff4500' : '#ff4500' - case 'twitter': - return isDarkMode ? '#1DA1F2' : '#1DA1F2' - } + return (isDarkMode && darkModeAccentColors[service]) || accentColors[service] } export const serviceIdToIconFont = (service: T.TB.ServiceIdWithContact): IconType => services[service].icon export const serviceIdToAvatarIcon = (service: T.TB.ServiceIdWithContact): IconType => diff --git a/shared/team-building/types.tsx b/shared/team-building/types.tsx index 9eb722d6df50..6479e5608c73 100644 --- a/shared/team-building/types.tsx +++ b/shared/team-building/types.tsx @@ -33,32 +33,3 @@ export type SearchRecSection = { shortcut: boolean data: Array } - -export type Props = { - error?: string - filterServices?: Array - focusInputCounter: number - goButtonLabel?: T.TB.GoButtonLabel - highlightedIndex: number - namespace: T.TB.AllowedNamespace - onAdd: (userId: string) => void - onChangeService: (newService: T.TB.ServiceIdWithContact) => void - onChangeText: (newText: string) => void - onClear: () => void - onClose: () => void - onDownArrowKeyDown: () => void - onEnterKeyDown: () => void - onFinishTeamBuilding: () => void - onRemove: (userId: string) => void - onSearchForMore: (len: number) => void - onUpArrowKeyDown: () => void - recommendations?: Array - search: (query: string, service: T.TB.ServiceIdWithContact) => void - searchResults: Array | undefined - searchString: string - selectedService: T.TB.ServiceIdWithContact - showServiceResultCount: boolean - teamBuildingSearchResults: T.TB.SearchResults - teamID: T.Teams.TeamID | undefined - teamSoFar: Array -}