import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native' import React, { useCallback, useReducer } from 'react' import { Platform, StyleSheet, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { ConversationAvatar } from '../../components/display/conversation_avatar' import { Tabs } from '../../components/display/tabs' import { Text } from '../../components/display/text' import { coerceColorKey } from '../../components/display/utils/avatar_gradient_colors' import FormSheet, { getFormSheetScreenOptions } from '../../components/primitive/form_sheet' import { useTheme } from '../../hooks' import { useConversation } from '../../hooks/use_conversation' import { useConversationAvatarUpdate, type AvatarUpdatePayload, } from '../../hooks/use_conversation_avatar_update' import { useFontScale } from '../../hooks/use_font_scale' import { usePreviewAvatarDiameter } from '../../hooks/use_preview_avatar_diameter' import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils' import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker' import { avatarPickerReducer, initAvatarPickerState, initAvatarPickerStateFromPayload, initEmptyAvatarPickerState, type AvatarPickerAction, type AvatarPickerState, type AvatarTab, } from './avatar_picker_state' import { AvatarPreview } from './avatar_preview' import { ColorPicker } from './color_picker' import { EmojiTab } from './emoji_tab' import { IconGrid } from './icon_grid' import { UploadTab } from './upload_tab' // On iPad a native formSheet is a fixed-size centered sheet whose content gets // laid out against the window instead of the sheet, clipping the bottom of the // picker (CHAT-246) — present as a regular modal there instead. const isPad = Platform.OS === 'ios' && Platform.isPad const presentation = Platform.select({ android: 'modal' as const, ios: isPad ? ('modal' as const) : ('formSheet' as const), }) export const AvatarPickerScreenOptions = getFormSheetScreenOptions({ headerTitle: 'Update avatar', presentation, sheetAllowedDetents: [1], }) export const AvatarPickerCreateScreenOptions = getFormSheetScreenOptions({ headerTitle: 'Choose avatar', presentation, sheetAllowedDetents: [1], }) export type AvatarPickerScreenProps = StaticScreenProps<{ conversation_id?: number source_params?: Record avatar_selection?: AvatarUpdatePayload }> const TABS: AvatarTab[] = ['icon', 'emoji', 'upload'] const TAB_LABELS: Record = { icon: 'Icon', emoji: 'Emoji', upload: 'Upload', } export function AvatarPickerScreen({ route }: AvatarPickerScreenProps) { const { conversation_id, source_params, avatar_selection } = route.params if (conversation_id) { return } return } function EditModeContent({ conversationId }: { conversationId: number }) { const navigation = useNavigation() const { data: conversation } = useConversation({ conversation_id: conversationId }) const mutation = useConversationAvatarUpdate({ conversationId }) const previewDiameter = usePreviewAvatarDiameter() const [state, dispatch] = useReducer(avatarPickerReducer, conversation, initAvatarPickerState) const hasAvatar = state.selectedType !== null const handleDone = useCallback(() => { if (!state.isDirty) { navigation.goBack() return } const payload = buildPayload(state) if (payload) { mutation.mutate(payload) } navigation.goBack() }, [state, mutation, navigation]) const handleRemove = useCallback(() => { mutation.mutate({ kind: 'clear' }, { onSuccess: () => dispatch({ type: 'CLEAR' }) }) }, [mutation, dispatch]) return ( {hasAvatar && ( Remove )} Done } fallbackPreview={ } /> ) } interface CreateModeContentProps { sourceParams?: Record existingSelection?: AvatarUpdatePayload } function CreateModeContent({ sourceParams, existingSelection }: CreateModeContentProps) { const navigation = useNavigation() const [state, dispatch] = useReducer(avatarPickerReducer, existingSelection, selection => selection ? initAvatarPickerStateFromPayload(selection) : initEmptyAvatarPickerState() ) const handleDone = useCallback(() => { if (!state.isDirty) { navigation.goBack() return } const payload = buildPayload(state) if (payload) { navigation.dispatch( StackActions.popTo('ConversationNew', { ...sourceParams, avatar_selection: payload }) ) } else { navigation.goBack() } }, [state, navigation, sourceParams]) return ( Done } fallbackPreview={} /> ) } interface AvatarPickerFormSheetProps { state: AvatarPickerState dispatch: React.Dispatch headerTitle: string headerActions: React.ReactNode fallbackPreview: React.ReactNode } function AvatarPickerFormSheet({ state, dispatch, headerTitle, headerActions, fallbackPreview, }: AvatarPickerFormSheetProps) { const styles = useStyles() return ( {headerTitle} {headerActions} dispatch({ type: 'SELECT_TAB', payload: tab })} renderItem={({ item }) => ( {TAB_LABELS[item]} )} style={styles.tabs} /> {state.activeTab === 'icon' && ( dispatch({ type: 'SELECT_ICON', payload: iconKey })} /> )} {state.activeTab === 'emoji' && ( dispatch({ type: 'SELECT_EMOJI', payload: emoji })} /> )} {state.activeTab === 'upload' && ( dispatch({ type: 'SET_IMAGE', payload: asset })} /> )} {state.activeTab !== 'upload' && ( dispatch({ type: 'SELECT_COLOR', payload: color })} /> )} ) } function EmptyAvatarPlaceholder() { const styles = useStyles() const diameter = usePreviewAvatarDiameter() return ( ) } type ValidAvatarPickerState = AvatarPickerState & ( | { selectedType: 'icon' | 'emoji'; selectedKey: string } | { selectedType: 'image'; imageAsset: ImagePickerAsset } ) function hasValidSelection(state: AvatarPickerState): state is ValidAvatarPickerState { switch (state.selectedType) { case 'icon': case 'emoji': return state.selectedKey !== null case 'image': return state.imageAsset !== null case null: return false } } function buildPayload(state: AvatarPickerState): AvatarUpdatePayload | null { if (!hasValidSelection(state)) return null const color = coerceColorKey(state.selectedColor) switch (state.selectedType) { case 'icon': return { kind: 'icon', key: state.selectedKey, color } case 'emoji': return { kind: 'emoji', key: state.selectedKey, color } case 'image': return { kind: 'image', imageAsset: state.imageAsset, color } } } const useStyles = () => { const { colors } = useTheme() const fontScale = useFontScale({ maxFontSizeMultiplier: MAX_FONT_SIZE_MULTIPLIER_LANDMARK }) const { top } = useSafeAreaInsets() return StyleSheet.create({ formSheetRoot: { flex: 1, }, formSheetContent: { flex: 1, }, header: { paddingTop: Platform.select({ ios: 20, android: 20 + top }), }, tabs: { minHeight: 52 * fontScale, borderBottomWidth: 1, borderBottomColor: colors.borderColorDefaultBase, backgroundColor: colors.fillColorNeutral100Inverted, zIndex: 1, }, tabLabel: { fontSize: 14, color: colors.textColorDefaultSecondary, paddingVertical: 14 * fontScale, minWidth: 56 * fontScale, textAlign: 'center', }, tabLabelActive: { color: colors.textColorDefaultPrimary, }, body: { flex: 1, overflow: 'hidden', }, colorPickerWrapper: { borderTopWidth: 1, borderTopColor: colors.borderColorDefaultBase, }, emptyAvatarPlaceholder: { borderWidth: 2, borderStyle: 'dashed', borderColor: colors.borderColorDefaultBase, }, }) }