import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { LayoutAnimation, NativeSyntheticEvent, Platform, Pressable, StyleSheet, Text, TextInput, TextInputContentSizeChangeEventData, UIManager, View, type StyleProp, type TextInputProps, type TextStyle, type ViewStyle, } from 'react-native'; import { AppIcon } from './app-icon'; import { ChromeGlassLayer } from './ChromeGlass'; import { colors, fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); } const ACTION_BUTTON_SIZE = 48; /** Filled action circle when nested inside the pill (Instagram-style composer). */ const ACTION_BUTTON_SIZE_INSET = 36; const FONT_SIZE = 16; const LINE_HEIGHT = 25; const MAX_LINES_DEFAULT = 4; const PILL_MIN_HEIGHT = ACTION_BUTTON_SIZE; const VERTICAL_PAD = (PILL_MIN_HEIGHT - LINE_HEIGHT) / 2; export type ComposePillVariant = 'surface' | 'chrome' | 'overlay'; export type ComposePillReplyPreview = { title: string; body: string; onDismiss: () => void; }; export type ComposePillLineLayout = { lineCount: number; isMultiline: boolean; pillHeight: number; inputMaxHeight: number; }; export type ComposePillLineLayoutState = ComposePillLineLayout & { handleContentSizeChange: (event: NativeSyntheticEvent) => void; resetLineCount: () => void; setLineCount: (count: number) => void; }; type ComposePillLayoutOptions = { maxLines?: number; }; function singleLineMaxContentHeight(): number { return LINE_HEIGHT * 2; } function maxPillHeight(maxLines: number): number { return LINE_HEIGHT * maxLines + VERTICAL_PAD * 2; } export function countComposePillLines( text: string, contentHeight: number, maxLines: number = MAX_LINES_DEFAULT, ): number { if (!text) return 1; const explicitLines = text.split('\n').length; if (explicitLines > 1) { return Math.min(maxLines, explicitLines); } if (contentHeight <= singleLineMaxContentHeight()) { return 1; } const wrappedLines = Math.round(contentHeight / LINE_HEIGHT); return Math.min(maxLines, Math.max(1, wrappedLines)); } export function pillHeightForLineCount(lineCount: number, maxLines: number = MAX_LINES_DEFAULT): number { if (lineCount <= 1) return PILL_MIN_HEIGHT; const textBlockHeight = lineCount * LINE_HEIGHT; return Math.min(maxPillHeight(maxLines), textBlockHeight); } export function useComposePillLineLayout( value: string, { maxLines = MAX_LINES_DEFAULT }: ComposePillLayoutOptions = {}, ): ComposePillLineLayoutState { const valueRef = useRef(value); valueRef.current = value; const [lineCount, setLineCount] = useState(1); const updateLineLayout = useCallback( (text: string, contentHeight: number) => { const nextLineCount = text ? countComposePillLines(text, contentHeight, maxLines) : 1; setLineCount(nextLineCount); }, [maxLines], ); const handleContentSizeChange = useCallback( (event: NativeSyntheticEvent) => { const text = valueRef.current; if (!text) { setLineCount(1); return; } updateLineLayout(text, event.nativeEvent.contentSize.height); }, [updateLineLayout], ); const resetLineCount = useCallback(() => { setLineCount(1); }, []); const isMultiline = lineCount > 1; const pillHeight = pillHeightForLineCount(lineCount, maxLines); const inputMaxHeight = maxPillHeight(maxLines) - VERTICAL_PAD * 2; return { lineCount, isMultiline, pillHeight, inputMaxHeight, handleContentSizeChange, resetLineCount, setLineCount, }; } type ComposePillTextInputRef = React.Ref; export type ComposePillTextFieldProps = Omit & { value: string; onChangeText: (text: string) => void; variant?: ComposePillVariant; maxLines?: number; inputRef?: ComposePillTextInputRef; layout?: ComposePillLineLayoutState; style?: StyleProp; }; export function ComposePillTextField({ value, onChangeText, variant = 'surface', maxLines = MAX_LINES_DEFAULT, inputRef, layout, placeholder, placeholderTextColor, onFocus, onBlur, editable, style, ...rest }: ComposePillTextFieldProps) { const internalLayout = useComposePillLineLayout(value, { maxLines }); const { lineCount, isMultiline, inputMaxHeight, handleContentSizeChange, setLineCount, } = layout ?? internalLayout; const handleChange = useCallback( (text: string) => { onChangeText(text); if (!text) { setLineCount(1); } }, [onChangeText, setLineCount], ); const variantColors = composePillVariantColors(variant); return ( = maxLines} blurOnSubmit={false} returnKeyType="default" editable={editable} style={[ styles.input, isMultiline ? styles.inputMultiline : styles.inputSingleLine, { color: variantColors.text, ...(isMultiline ? { maxHeight: inputMaxHeight, textAlignVertical: 'top' as const } : { textAlignVertical: 'center' as const }), }, style, ]} {...(Platform.OS === 'android' ? { includeFontPadding: false } : {})} {...(Platform.OS === 'web' ? ({ outlineStyle: 'none', resize: 'none', overflow: 'hidden', WebkitTextFillColor: variantColors.text, caretColor: variantColors.text, } as object) : {})} {...rest} /> ); } export type ComposePillEndActionPlacement = 'outside' | 'inside'; export type ComposePillInputProps = { value: string; onChangeText: (text: string) => void; placeholder?: string; disabled?: boolean; maxLines?: number; variant?: ComposePillVariant; onFocus?: () => void; onBlur?: () => void; inputRef?: ComposePillTextInputRef; /** Inside the pill (camera / location circle, etc.). */ leading?: ReactNode; /** Before the pill (avatar in comments). */ leadingOutside?: ReactNode; trailing?: ReactNode; endAction?: ReactNode; /** * `outside` — send circle beside the pill. * `inside` — action nested in the pill. * Implied `inside` when `actionSwap` is on. */ endActionPlacement?: ComposePillEndActionPlacement; /** * System-wide Instagram composer: hide `trailing` when the field has text and * show `endAction` in its place inside the pill (with a short layout animation). */ actionSwap?: boolean; replyPreview?: ComposePillReplyPreview | null; style?: StyleProp; transparentWrap?: boolean; /** Bare row without pill chrome — text field only grows inline. */ bare?: boolean; /** * Drop composer row insets — use when the pill sits inside an already-padded * dock (profile quick message beside FABs). */ flush?: boolean; rowAlignItems?: 'center' | 'flex-end'; textInputProps?: Omit< ComposePillTextFieldProps, 'value' | 'onChangeText' | 'variant' | 'maxLines' | 'inputRef' | 'layout' | 'editable' >; }; function composePillVariantColors(variant: ComposePillVariant) { if (variant === 'overlay') { return { text: colors.white, placeholder: 'rgba(255,255,255,0.55)', /** Transparent — frost via ChromeGlassLayer (mesmo glass do drawer). */ pillBackground: 'transparent', pillBorder: colors.chromePillBorder, replyBackground: 'transparent', useChromeGlass: true, }; } if (variant === 'chrome') { return { text: m3Colors.onSurface, placeholder: m3Colors.onSurfaceVariant, pillBackground: 'transparent', pillBorder: colors.chromePillBorder, replyBackground: 'transparent', useChromeGlass: true, }; } return { text: m3Colors.onSurface, placeholder: m3Colors.onSurfaceVariant, pillBackground: m3Colors.surfaceContainerHigh, pillBorder: 'transparent', replyBackground: m3Colors.surfaceContainerHigh, useChromeGlass: false, }; } export function ComposePillReplyBar({ preview, variant = 'surface', }: { preview: ComposePillReplyPreview; variant?: ComposePillVariant; }) { const variantColors = composePillVariantColors(variant); return ( {variantColors.useChromeGlass ? : null} {preview.title} {preview.body} ); } export function ComposePillIconButton({ label, children, onPress, disabled, }: { label: string; children: ReactNode; onPress?: () => void; disabled?: boolean; }) { return ( {children} ); } export function ComposePillActionButton({ label, children, onPress, disabled, inset = false, }: { label: string; children: ReactNode; onPress?: () => void; disabled?: boolean; /** Smaller filled circle for use inside the composer pill. */ inset?: boolean; }) { const size = inset ? ACTION_BUTTON_SIZE_INSET : ACTION_BUTTON_SIZE; return ( {children} ); } export function ComposePillInput({ value, onChangeText, placeholder = 'Mensagem', disabled, maxLines = MAX_LINES_DEFAULT, variant = 'surface', onFocus, onBlur, inputRef, leading, leadingOutside, trailing, endAction, endActionPlacement = 'outside', actionSwap = false, replyPreview, style, transparentWrap = false, bare = false, flush = false, rowAlignItems = bare ? 'center' : 'flex-end', textInputProps, }: ComposePillInputProps) { const layout = useComposePillLineLayout(value, { maxLines }); const { isMultiline, pillHeight, resetLineCount } = layout; const variantColors = composePillVariantColors(variant); const hasText = Boolean(value.trim()); const endActionInside = (actionSwap || endActionPlacement === 'inside') && !bare; const visibleTrailing = actionSwap && hasText ? null : trailing; const visibleEndAction = actionSwap && !hasText ? null : endAction; const hadTextRef = useRef(hasText); useEffect(() => { if (!actionSwap) { hadTextRef.current = hasText; return; } if (hadTextRef.current === hasText) return; hadTextRef.current = hasText; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); }, [actionSwap, hasText]); const handleChange = useCallback( (text: string) => { onChangeText(text); if (!text) { resetLineCount(); } }, [onChangeText, resetLineCount], ); const textField = ( ); const trailingSlot = visibleTrailing || (endActionInside && visibleEndAction) ? ( {visibleTrailing} {endActionInside ? visibleEndAction : null} ) : null; return ( {replyPreview ? : null} {leadingOutside} {bare ? ( <> {leading} {textField} {visibleTrailing} {!endActionInside ? visibleEndAction : null} ) : ( {variantColors.useChromeGlass ? : null} {leading} {textField} {trailingSlot} )} {!endActionInside ? visibleEndAction : null} ); } const styles = StyleSheet.create({ wrap: { backgroundColor: 'transparent', }, wrapTransparent: { backgroundColor: 'transparent', }, wrapSurface: { backgroundColor: m3Colors.surface, }, replyBar: { flexDirection: 'row', alignItems: 'center', marginHorizontal: spacing.sm, marginTop: spacing.sm, marginBottom: spacing.xs, paddingVertical: spacing.sm, paddingHorizontal: spacing.md, borderRadius: m3Shape.cornerMedium, gap: spacing.sm, }, replyBarGlass: { overflow: 'hidden', }, replyBarAccent: { width: 4, alignSelf: 'stretch', borderRadius: m3Shape.cornerExtraSmall, backgroundColor: m3Colors.primary, zIndex: 1, }, replyBarContent: { flex: 1, gap: 2, zIndex: 1, }, replyBarName: { color: m3Colors.primary, fontSize: 13, fontFamily: fonts.semibold, }, replyBarPreview: { color: m3Colors.onSurfaceVariant, fontSize: 13, fontFamily: fonts.regular, }, replyBarClose: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', zIndex: 1, }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: spacing.sm, paddingHorizontal: spacing.sm, paddingTop: spacing.sm, }, composerRowFlush: { paddingHorizontal: 0, paddingTop: 0, gap: 0, }, inputPill: { flex: 1, flexDirection: 'row', paddingLeft: spacing.xs, paddingRight: spacing.xs, overflow: 'hidden', }, inputPillWithInsetAction: { paddingLeft: spacing.xs, paddingRight: spacing.xs, gap: spacing.xs, }, inputPillGlass: {}, inputPillSingle: { borderRadius: m3Shape.cornerFull, }, inputPillExpanded: { borderRadius: m3Shape.cornerLarge, }, iconButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', zIndex: 1, }, iconButtonDisabled: { opacity: 0.4, }, trailingIcons: { flexDirection: 'row', alignItems: 'center', zIndex: 1, }, trailingIconsExpanded: { paddingBottom: spacing.xs, }, input: { flex: 1, fontSize: FONT_SIZE, lineHeight: LINE_HEIGHT, fontFamily: fonts.regular, paddingHorizontal: spacing.xs, zIndex: 1, }, /** Single-line: match pill height so placeholder/caret sit optically centered with inset actions. */ inputSingleLine: { height: PILL_MIN_HEIGHT, minHeight: PILL_MIN_HEIGHT, maxHeight: PILL_MIN_HEIGHT, paddingTop: 0, paddingBottom: 0, ...Platform.select({ ios: { // Multiline TextInput on iOS ignores textAlignVertical — pad to center the glyph. paddingTop: Math.max(0, (PILL_MIN_HEIGHT - LINE_HEIGHT) / 2 - 1), paddingBottom: Math.max(0, (PILL_MIN_HEIGHT - LINE_HEIGHT) / 2 + 1), }, android: { textAlignVertical: 'center', }, default: { paddingTop: VERTICAL_PAD, paddingBottom: VERTICAL_PAD, }, }), }, inputMultiline: { paddingTop: VERTICAL_PAD, paddingBottom: VERTICAL_PAD, }, bareInput: { minHeight: 40, paddingTop: spacing.xs, paddingBottom: spacing.xs, }, actionButton: { backgroundColor: m3Colors.primary, alignItems: 'center', justifyContent: 'center', zIndex: 1, }, actionButtonDisabled: { opacity: 0.45, }, }); export const COMPOSE_PILL_ACTION_BUTTON_SIZE = ACTION_BUTTON_SIZE; export const COMPOSE_PILL_ACTION_BUTTON_SIZE_INSET = ACTION_BUTTON_SIZE_INSET;