| 1 | import type { ComponentProps, ReactNode } from "react"; |
| 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | import { |
| 4 | Platform, |
| 5 | Pressable, |
| 6 | ScrollView, |
| 7 | View, |
| 8 | type LayoutChangeEvent, |
| 9 | type NativeScrollEvent, |
| 10 | type NativeSyntheticEvent, |
| 11 | type PressableProps, |
| 12 | type StyleProp, |
| 13 | type ViewStyle, |
| 14 | } from "react-native"; |
| 15 | |
| 16 | import { cn } from "../lib/cn"; |
| 17 | import { AppText as Text } from "./AppText"; |
| 18 | import { SymbolView } from "./AppSymbol"; |
| 19 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 20 | |
| 21 | const COMPOSER_TOOLBAR_GAP = 8; |
| 22 | const COMPOSER_TOOLBAR_FADE_WIDTH = 18; |
| 23 | const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; |
| 24 | |
| 25 | /** |
| 26 | * Quiet inline composer control used inside cards and their context rows. |
| 27 | * Unlike ComposerToolbarButton, this does not draw another pill inside the |
| 28 | * composer surface, so model and workspace controls read as part of the card. |
| 29 | */ |
| 30 | export function ComposerInlineControl(props: { |
| 31 | readonly accessibilityHint?: string; |
| 32 | readonly accessibilityLabel?: string; |
| 33 | readonly disabled?: boolean; |
| 34 | readonly emphasized?: boolean; |
| 35 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| 36 | readonly renderIcon?: (size: number) => ReactNode; |
| 37 | readonly label: string; |
| 38 | readonly maxWidth?: ViewStyle["maxWidth"]; |
| 39 | readonly onPress?: () => void; |
| 40 | readonly selected?: boolean; |
| 41 | readonly static?: boolean; |
| 42 | readonly chevronDirection?: "down" | "right"; |
| 43 | readonly showChevron?: boolean; |
| 44 | }) { |
| 45 | const { scale, smallIconSize } = useAndroidControlSizing(); |
| 46 | return ( |
| 47 | <Pressable |
| 48 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| 49 | accessibilityHint={props.accessibilityHint} |
| 50 | accessibilityRole={props.static ? undefined : "button"} |
| 51 | accessibilityState={ |
| 52 | props.static ? undefined : { disabled: props.disabled, selected: props.selected } |
| 53 | } |
| 54 | className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle" |
| 55 | disabled={props.disabled || props.static} |
| 56 | onPress={props.onPress} |
| 57 | style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }} |
| 58 | > |
| 59 | {props.renderIcon ? ( |
| 60 | <View |
| 61 | className="size-4 shrink-0 items-center justify-center" |
| 62 | style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined} |
| 63 | > |
| 64 | {props.renderIcon(smallIconSize)} |
| 65 | </View> |
| 66 | ) : props.icon ? ( |
| 67 | <SymbolView |
| 68 | name={props.icon} |
| 69 | size={smallIconSize} |
| 70 | tintColorClassName={ |
| 71 | props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted" |
| 72 | } |
| 73 | type="monochrome" |
| 74 | /> |
| 75 | ) : null} |
| 76 | <Text |
| 77 | className={cn( |
| 78 | "shrink text-sm font-t3-medium", |
| 79 | props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted", |
| 80 | )} |
| 81 | numberOfLines={1} |
| 82 | > |
| 83 | {props.label} |
| 84 | </Text> |
| 85 | {props.showChevron === false ? null : ( |
| 86 | <SymbolView |
| 87 | name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"} |
| 88 | size={Math.round(10 * scale)} |
| 89 | tintColorClassName={ |
| 90 | props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted" |
| 91 | } |
| 92 | type="monochrome" |
| 93 | /> |
| 94 | )} |
| 95 | </Pressable> |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | export function ComposerToolbarRow(props: { |
| 100 | readonly children: ReactNode; |
| 101 | readonly paddingBottom?: number; |
| 102 | readonly paddingHorizontal?: number; |
| 103 | readonly paddingTop?: number; |
| 104 | readonly style?: StyleProp<ViewStyle>; |
| 105 | }) { |
| 106 | return ( |
| 107 | <View |
| 108 | className="flex-row items-center gap-1.5" |
| 109 | style={[ |
| 110 | { |
| 111 | paddingBottom: props.paddingBottom ?? 8, |
| 112 | paddingHorizontal: props.paddingHorizontal ?? 6, |
| 113 | paddingTop: props.paddingTop ?? 8, |
| 114 | }, |
| 115 | props.style, |
| 116 | ]} |
| 117 | > |
| 118 | {props.children} |
| 119 | </View> |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | export function ComposerToolbarScroller(props: { |
| 124 | readonly children: ReactNode; |
| 125 | readonly align?: "start" | "end"; |
| 126 | /** Only for non-Uniwind surfaces such as the native terminal palette. */ |
| 127 | readonly fadeOpaque?: string; |
| 128 | /** Only for non-Uniwind surfaces such as the native terminal palette. */ |
| 129 | readonly fadeTransparent?: string; |
| 130 | /** Semantic Uniwind surface behind the toolbar. Defaults to card. */ |
| 131 | readonly fadeSurface?: "card" | "sheet"; |
| 132 | readonly contentPaddingRight?: number; |
| 133 | }) { |
| 134 | const [metrics, setMetrics] = useState({ |
| 135 | contentWidth: 0, |
| 136 | offsetX: 0, |
| 137 | viewportWidth: 0, |
| 138 | }); |
| 139 | |
| 140 | const scrollEdges = useMemo(() => { |
| 141 | const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); |
| 142 | return { |
| 143 | showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, |
| 144 | showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, |
| 145 | }; |
| 146 | }, [metrics]); |
| 147 | |
| 148 | const handleLayout = useCallback((event: LayoutChangeEvent) => { |
| 149 | const viewportWidth = event.nativeEvent.layout.width; |
| 150 | setMetrics((current) => |
| 151 | current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, |
| 152 | ); |
| 153 | }, []); |
| 154 | |
| 155 | const handleContentSizeChange = useCallback((contentWidth: number) => { |
| 156 | setMetrics((current) => |
| 157 | current.contentWidth === contentWidth ? current : { ...current, contentWidth }, |
| 158 | ); |
| 159 | }, []); |
| 160 | |
| 161 | const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 162 | const offsetX = event.nativeEvent.contentOffset.x; |
| 163 | setMetrics((current) => |
| 164 | Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, |
| 165 | ); |
| 166 | }, []); |
| 167 | |
| 168 | return ( |
| 169 | <View className="relative min-w-0 flex-1"> |
| 170 | <ScrollView |
| 171 | horizontal |
| 172 | keyboardShouldPersistTaps="always" |
| 173 | onContentSizeChange={handleContentSizeChange} |
| 174 | onLayout={handleLayout} |
| 175 | onScroll={handleScroll} |
| 176 | scrollEventThrottle={16} |
| 177 | showsHorizontalScrollIndicator={false} |
| 178 | contentContainerStyle={{ |
| 179 | alignItems: "center", |
| 180 | flexGrow: props.align === "end" ? 1 : undefined, |
| 181 | justifyContent: props.align === "end" ? "flex-end" : undefined, |
| 182 | gap: COMPOSER_TOOLBAR_GAP, |
| 183 | paddingLeft: 0, |
| 184 | paddingRight: props.contentPaddingRight ?? 1, |
| 185 | }} |
| 186 | > |
| 187 | {props.children} |
| 188 | </ScrollView> |
| 189 | {scrollEdges.showLeftFade ? ( |
| 190 | <View |
| 191 | className={cn( |
| 192 | "absolute inset-y-0 left-0", |
| 193 | props.fadeOpaque === undefined && "bg-linear-to-r to-transparent", |
| 194 | props.fadeOpaque === undefined && |
| 195 | (props.fadeSurface === "sheet" ? "from-sheet" : "from-card"), |
| 196 | )} |
| 197 | pointerEvents="none" |
| 198 | style={{ |
| 199 | width: COMPOSER_TOOLBAR_FADE_WIDTH, |
| 200 | experimental_backgroundImage: |
| 201 | props.fadeOpaque === undefined |
| 202 | ? undefined |
| 203 | : `linear-gradient(to right, ${props.fadeOpaque} 0%, ${props.fadeTransparent ?? "transparent"} 100%)`, |
| 204 | }} |
| 205 | /> |
| 206 | ) : null} |
| 207 | {scrollEdges.showRightFade ? ( |
| 208 | <View |
| 209 | className={cn( |
| 210 | "absolute inset-y-0 right-0", |
| 211 | props.fadeOpaque === undefined && "bg-linear-to-r from-transparent", |
| 212 | props.fadeOpaque === undefined && |
| 213 | (props.fadeSurface === "sheet" ? "to-sheet" : "to-card"), |
| 214 | )} |
| 215 | pointerEvents="none" |
| 216 | style={{ |
| 217 | width: COMPOSER_TOOLBAR_FADE_WIDTH, |
| 218 | experimental_backgroundImage: |
| 219 | props.fadeOpaque === undefined |
| 220 | ? undefined |
| 221 | : `linear-gradient(to right, ${props.fadeTransparent ?? "transparent"} 0%, ${props.fadeOpaque} 100%)`, |
| 222 | }} |
| 223 | /> |
| 224 | ) : null} |
| 225 | </View> |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | export function ComposerActionButton(props: { |
| 230 | readonly accessibilityLabel: string; |
| 231 | readonly disabled?: boolean; |
| 232 | readonly icon: ComponentProps<typeof SymbolView>["name"]; |
| 233 | readonly onPress: () => void; |
| 234 | readonly variant?: "primary" | "danger"; |
| 235 | // Forwarded so a ControlPillMenu can drive this button as its long-press |
| 236 | // anchor: Android injects onLongPress, iOS injects onTouchStart and onPress. |
| 237 | readonly onLongPress?: PressableProps["onLongPress"]; |
| 238 | readonly onTouchStart?: PressableProps["onTouchStart"]; |
| 239 | }) { |
| 240 | const { scale, smallIconSize } = useAndroidControlSizing(); |
| 241 | const circleSize = Math.round(30 * scale); |
| 242 | return ( |
| 243 | <Pressable |
| 244 | accessibilityLabel={props.accessibilityLabel} |
| 245 | accessibilityRole="button" |
| 246 | accessibilityState={{ disabled: props.disabled }} |
| 247 | className="size-[44px] shrink-0 items-center justify-center active:opacity-70" |
| 248 | disabled={props.disabled} |
| 249 | onPress={props.onPress} |
| 250 | onLongPress={props.onLongPress} |
| 251 | onTouchStart={props.onTouchStart} |
| 252 | > |
| 253 | <View |
| 254 | style={{ width: circleSize, height: circleSize }} |
| 255 | className={cn( |
| 256 | "items-center justify-center rounded-full", |
| 257 | props.variant === "danger" |
| 258 | ? "bg-danger" |
| 259 | : props.disabled |
| 260 | ? "bg-primary/15" |
| 261 | : "bg-primary", |
| 262 | )} |
| 263 | > |
| 264 | <SymbolView |
| 265 | name={props.icon} |
| 266 | size={smallIconSize} |
| 267 | weight="semibold" |
| 268 | tintColorClassName={ |
| 269 | props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground" |
| 270 | } |
| 271 | type="monochrome" |
| 272 | /> |
| 273 | </View> |
| 274 | </Pressable> |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | export function ComposerToolbarButton(props: { |
| 279 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| 280 | readonly iconNode?: ReactNode; |
| 281 | readonly label?: string; |
| 282 | readonly accessibilityLabel?: string; |
| 283 | readonly active?: boolean; |
| 284 | readonly disabled?: boolean; |
| 285 | readonly maxWidth?: number; |
| 286 | readonly minWidth?: number; |
| 287 | readonly onPress?: () => void; |
| 288 | readonly showChevron?: boolean; |
| 289 | readonly textTransform?: "none" | "uppercase"; |
| 290 | readonly variant?: "default" | "primary" | "danger"; |
| 291 | readonly className?: string; |
| 292 | readonly style?: StyleProp<ViewStyle>; |
| 293 | }) { |
| 294 | const variant = props.variant ?? "default"; |
| 295 | const isCircle = !props.label && props.showChevron === false; |
| 296 | const iconTintClassName = |
| 297 | variant === "primary" |
| 298 | ? props.disabled |
| 299 | ? "accent-icon-subtle" |
| 300 | : "accent-primary-foreground" |
| 301 | : variant === "danger" |
| 302 | ? "accent-danger-foreground" |
| 303 | : "accent-icon"; |
| 304 | |
| 305 | return ( |
| 306 | <Pressable |
| 307 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| 308 | accessibilityRole="button" |
| 309 | disabled={props.disabled} |
| 310 | onPress={props.onPress} |
| 311 | className={cn( |
| 312 | // Default width cap lives in the class chain (not the inline style) |
| 313 | // so callers can lift it with max-w-full — flex-filling pills in the |
| 314 | // thread composer stretch to the row's edge. The numeric maxWidth |
| 315 | // prop still wins via the inline style below. |
| 316 | "h-11 max-w-[172px] flex-row items-center justify-center rounded-full border active:opacity-70", |
| 317 | variant === "primary" && "shadow-lg shadow-adaptive-black-a10-a25 disabled:shadow-none", |
| 318 | isCircle ? "w-11" : "gap-2 px-3.5", |
| 319 | variant === "primary" |
| 320 | ? props.disabled |
| 321 | ? "bg-subtle-strong" |
| 322 | : "bg-primary" |
| 323 | : variant === "danger" |
| 324 | ? "bg-danger" |
| 325 | : props.active |
| 326 | ? "bg-subtle-strong" |
| 327 | : "bg-subtle", |
| 328 | variant === "default" |
| 329 | ? props.active |
| 330 | ? "border-border" |
| 331 | : "border-border-subtle" |
| 332 | : variant === "danger" |
| 333 | ? "border-danger-border" |
| 334 | : props.disabled |
| 335 | ? "border-border-subtle" |
| 336 | : "border-primary-foreground/20", |
| 337 | props.className, |
| 338 | )} |
| 339 | style={({ pressed }) => [ |
| 340 | { |
| 341 | maxWidth: props.maxWidth, |
| 342 | minWidth: props.minWidth, |
| 343 | opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, |
| 344 | }, |
| 345 | props.style, |
| 346 | ]} |
| 347 | > |
| 348 | {props.iconNode ? ( |
| 349 | <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View> |
| 350 | ) : props.icon ? ( |
| 351 | <SymbolView |
| 352 | name={props.icon} |
| 353 | size={16} |
| 354 | tintColorClassName={iconTintClassName} |
| 355 | type="monochrome" |
| 356 | /> |
| 357 | ) : null} |
| 358 | {props.label ? ( |
| 359 | <Text |
| 360 | className={cn( |
| 361 | "shrink text-center text-sm font-t3-bold", |
| 362 | variant === "primary" |
| 363 | ? props.disabled |
| 364 | ? "text-foreground-muted" |
| 365 | : "text-primary-foreground" |
| 366 | : "text-foreground", |
| 367 | )} |
| 368 | ellipsizeMode="tail" |
| 369 | numberOfLines={1} |
| 370 | style={{ textTransform: props.textTransform ?? "none" }} |
| 371 | > |
| 372 | {props.label} |
| 373 | </Text> |
| 374 | ) : null} |
| 375 | {props.showChevron === false ? null : ( |
| 376 | <SymbolView |
| 377 | name="chevron.down" |
| 378 | size={11} |
| 379 | tintColorClassName={iconTintClassName} |
| 380 | type="monochrome" |
| 381 | /> |
| 382 | )} |
| 383 | </Pressable> |
| 384 | ); |
| 385 | } |