import { PlatformPressable } from '@react-navigation/elements' import { StackActions, useNavigation } from '@react-navigation/native' import { RefObject, useRef, useState } from 'react' import { FlatList, Pressable, StyleSheet, TextInput, View } from 'react-native' import { Avatar, Badge, Banner, Icon, KeyboardView, Spinner, Text } from '../../components' import { ActionButton } from '../../components/display/action_button' import { AutoFocusAwareTextInput } from '../../components/display/text_input' import { ComponentErrorBoundary } from '../../components/page/component_error_boundary' import { useTheme } from '../../hooks' import { useDirectMessageAffiliations } from '../../hooks/direct_messages/use_direct_message_affiliations' import { useDirectMessageCandidates } from '../../hooks/direct_messages/use_direct_message_candidates' import { useDirectMessageConversationValidate } from '../../hooks/direct_messages/use_direct_message_conversation_validate' import { useFindOrCreateDirectMessageConversation } from '../../hooks/direct_messages/use_find_or_create_direct_message_conversation' import { useDebouncedSearchTerm } from '../../hooks/use_debounced_search_term' import { availableFeatures, useFeatures } from '../../hooks/use_features' import { DirectMessageCandidateResource } from '../../types/resources/direct_message_candidate' import { ResultsHeading } from './components/results_heading' import { ConversationSelectRecipientsScreenProps } from './types/screen_props' const MAX_RECIPIENTS = 9 export function ConversationSelectDirectMessageRecipientsScreen({ route, }: ConversationSelectRecipientsScreenProps) { const { preselected_people = [] } = route.params ?? {} const styles = useStyles() const navigation = useNavigation() const { featureEnabled } = useFeatures() const [searchTerm, setSearchTerm] = useState('') const debouncedTerm = useDebouncedSearchTerm(searchTerm) const [selectedPeople, setSelectedPeople] = useState>( () => new Map( preselected_people.map(person => [ person.id, { ...person, type: 'DirectMessageCandidate' as const, groups: [], teams: [], under_18: false, directMessagesDisabled: false, }, ]) ) ) const searchInputRef = useRef(null) const selectPerson = (person: DirectMessageCandidateResource) => { if (selectedPeople.size >= MAX_RECIPIENTS) return setSelectedPeople(prev => new Map(prev).set(person.id, person)) setSearchTerm('') // Controlled TextInput can retain stale native text on iOS when the value // is cleared while it stays focused (keyboardShouldPersistTaps keeps it // focused through the tap) — clear() forces the native view to sync. Gated // so EAP reporters can toggle the fix on their devices to confirm it. if (featureEnabled(availableFeatures.dm_search_clear_fix)) { searchInputRef.current?.clear() } } const removePerson = (id: string) => { setSelectedPeople(prev => { const next = new Map(prev) next.delete(id) return next }) } const recipientIds = [...selectedPeople.keys()] const { warnings, validationPending } = useDirectMessageConversationValidate({ recipientIds }) const handleSuccess = (conversationId: number) => { navigation .getParent() ?.dispatch(StackActions.replace('Conversation', { conversation_id: conversationId })) } const { findOrCreateConversation, directMessageExists, isFinding, isError } = useFindOrCreateDirectMessageConversation({ recipientIds, onSuccess: handleSuccess }) const buttonText = (() => { if (selectedPeople.size === 0) return 'Start conversation' if (selectedPeople.size === 1) { const firstName = selectedPeople.values().next().value!.name.split(' ')[0] return directMessageExists ? `Open message with ${firstName}` : `Message ${firstName}` } return directMessageExists ? `Open direct message (${selectedPeople.size})` : `Start direct message (${selectedPeople.size})` })() return ( {isError && ( )} setSelectedPeople(new Map())} searchInputRef={searchInputRef} /> } > 0 || isFinding } loading={validationPending || isFinding} onPress={() => findOrCreateConversation(recipientIds)} warnings={warnings} /> ) } function ToField({ selectedPeople, searchTerm, onSearchChange, onRemove, onClear, searchInputRef, }: { selectedPeople: Map searchTerm: string onSearchChange: (term: string) => void onRemove: (id: string) => void onClear: () => void searchInputRef: RefObject }) { const styles = useStyles() const { colors } = useTheme() const hasSelections = selectedPeople.size > 0 return ( To {hasSelections && ( Clear )} {[...selectedPeople.values()].map(person => ( ))} ) } function PersonChip({ person, onRemove, }: { person: DirectMessageCandidateResource onRemove: (id: string) => void }) { const styles = useStyles() return ( {person.name} onRemove(person.id)} hitSlop={8} accessibilityLabel={`Remove ${person.name}`} > ) } function CandidateResults({ term, selectedPeople, onSelect, }: { term: string selectedPeople: Map onSelect: (candidate: DirectMessageCandidateResource) => void }) { const styles = useStyles() const { data, isFetching } = useDirectMessageCandidates(term) const { groups, teams, resultsDescription } = useDirectMessageAffiliations() if (term.length < 1) { if (selectedPeople.size > 0) return null return ( Search for people to direct message Start a conversation with people in your groups and teams. ) } const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS if (isAtLimit) { return ( You can add up to {MAX_RECIPIENTS} people to a direct message. ) } const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id)) const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0) if (isFetching && unselectedCandidates.length === 0) { return ( ) } if (!isFetching && unselectedCandidates.length === 0) { return ( No matches for "{term}" Try a different name, or check that you share a group or team with them. ) } return ( ) } function candidateBadges(candidate: DirectMessageCandidateResource) { const badges = [] if (candidate.directMessagesDisabled) badges.push({ title: 'DMs off' }) if (candidate.under_18) badges.push({ title: 'Under 18' }) return badges } function candidateAffiliationNames( candidate: DirectMessageCandidateResource, groups: Map, teams: Map ) { return [ ...candidate.groups.flatMap(id => { const name = groups.get(id) return name ? [name] : [] }), ...candidate.teams.flatMap(id => { const name = teams.get(id) return name ? [name] : [] }), ] } function CandidateList({ candidates, hasTruncated, description, groups, teams, onSelect, }: { candidates: DirectMessageCandidateResource[] hasTruncated: boolean description: string | null groups: Map teams: Map onSelect: (candidate: DirectMessageCandidateResource) => void }) { const styles = useStyles() const available = candidates.filter(c => !c.directMessagesDisabled) const dmDisabled = candidates.filter(c => c.directMessagesDisabled) return ( data={available} keyboardShouldPersistTaps="handled" keyExtractor={item => item.id} renderItem={({ item }) => ( onSelect(item)} /> )} ListHeaderComponent={} ListFooterComponent={ <> {dmDisabled.length > 0 && ( <> Unavailable for direct messages {dmDisabled.map(candidate => ( ))} )} {hasTruncated && ( Keep typing to narrow your results. )} } /> ) } function CandidateItem({ candidate, badges, affiliationNames, onSelect, disabled = false, }: { candidate: DirectMessageCandidateResource badges: { title: string }[] affiliationNames: string[] onSelect?: () => void disabled?: boolean }) { const styles = useStyles() return ( {candidate.name} {affiliationNames.length > 0 && ( {affiliationNames.join(', ')} )} {badges.length > 0 && ( {badges.map(({ title }) => ( ))} )} ) } const useStyles = () => { const { colors } = useTheme() return StyleSheet.create({ candidateResultsContainer: { flex: 1, }, toField: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8, }, toFieldHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, }, toLabel: { fontWeight: '600', color: colors.textColorDefaultSecondary, }, clearButton: { color: colors.interaction, }, toInputArea: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, borderWidth: 1, borderColor: colors.borderColorDefaultBase, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, }, toTextInput: { flex: 1, minWidth: 80, color: colors.textColorDefaultPrimary, fontSize: 16, paddingVertical: 2, }, chip: { flexDirection: 'row', alignItems: 'center', gap: 4, borderWidth: 1, borderColor: colors.borderColorDefaultBase, borderRadius: 100, paddingLeft: 4, paddingRight: 6, paddingVertical: 3, }, chipName: { fontSize: 14, }, chipRemoveIcon: { color: colors.iconColorDefaultSecondary, }, errorBanner: { margin: 16, }, emptyState: { alignItems: 'center', paddingTop: 32, paddingHorizontal: 32, gap: 8, }, emptyStateIcon: { color: colors.iconColorDefaultSecondary, marginBottom: 4, }, emptyStateTitle: { fontWeight: '600', textAlign: 'center', }, emptyStateSubtitle: { textAlign: 'center', fontSize: 14, }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderColor: colors.borderColorDefaultDim, }, rowInfo: { flex: 1, gap: 2, }, rowName: { fontSize: 15, fontWeight: '500', }, rowAffiliations: { fontSize: 12, color: colors.textColorDefaultSecondary, }, badgeRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4, }, candidateDisabled: { opacity: 0.5, }, unavailableHeader: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8, }, unavailableLabel: { fontSize: 13, }, truncatedFooter: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, }, truncatedText: { fontSize: 13, textAlign: 'center', }, }) }