diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000..afc17c9fba --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,52 @@ +import {useCallback} from 'react' +import {Sift, type UseSiftReturn} from '@bsky.app/sift' + +import {atoms as a, useTheme} from '#/alf' +import {type AutocompleteItem} from '#/components/Autocomplete/types' +import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' +import {Portal} from '#/components/Portal' +import {IS_WEB} from '#/env' + +export function Autocomplete({ + sift, + data, + render, + onSelect, + onDismiss, +}: { + sift: UseSiftReturn + data: AutocompleteItem[] + render: Parameters>[0]['render'] + onSelect: (item: AutocompleteItem) => void + onDismiss: () => void +}) { + const t = useTheme() + + const updatePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updatePosition) + useOnKeyboard('keyboardDidHide', updatePosition) + + return ( + + + + ) +} diff --git a/src/components/Autocomplete/AutocompleteItemProfile.tsx b/src/components/Autocomplete/AutocompleteItemProfile.tsx new file mode 100644 index 0000000000..69c956e50e --- /dev/null +++ b/src/components/Autocomplete/AutocompleteItemProfile.tsx @@ -0,0 +1,38 @@ +import {SiftItem} from '@bsky.app/sift' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, useTheme} from '#/alf' +import * as ProfileCard from '#/components/ProfileCard' +import {type AutocompleteItemProps} from './types' + +export function AutocompleteItemProfile({ + active, + props, + item, +}: AutocompleteItemProps) { + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (item.type !== 'profile' || !moderationOpts) return null + + return ( + [ + a.px_md, + a.py_sm, + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], + ]}> + + + + + + ) +} diff --git a/src/components/Autocomplete/index.tsx b/src/components/Autocomplete/index.tsx new file mode 100644 index 0000000000..9c5af55d19 --- /dev/null +++ b/src/components/Autocomplete/index.tsx @@ -0,0 +1,4 @@ +export * from './Autocomplete' +export * from './AutocompleteItemProfile' +export * from './useAutocomplete' +export * from './util' diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts new file mode 100644 index 0000000000..a02d4d9326 --- /dev/null +++ b/src/components/Autocomplete/types.ts @@ -0,0 +1,35 @@ +import {type Sift} from '@bsky.app/sift' + +import type * as bsky from '#/types/bsky' + +export type AutocompleteProfile = { + key: string + type: 'profile' + value: string + profile: bsky.profile.AnyProfileView +} + +export type AutocompleteTag = { + key: string + type: 'tag' + value: string + tag: string +} + +export type AutocompleteEmoji = { + key: string + type: 'emoji' + value: string + emoji: string +} + +export type AutocompleteItem = + | AutocompleteProfile + | AutocompleteTag + | AutocompleteEmoji + +export type AutocompleteItemType = AutocompleteItem['type'] + +export type AutocompleteItemProps = Parameters< + Parameters>[0]['render'] +>[0] diff --git a/src/components/Autocomplete/useAutocomplete.ts b/src/components/Autocomplete/useAutocomplete.ts new file mode 100644 index 0000000000..191eaabe59 --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete.ts @@ -0,0 +1,112 @@ +import {useCallback} from 'react' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {keepPreviousData, useQuery} from '@tanstack/react-query' + +import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {STALE} from '#/state/queries' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' +import { + type AutocompleteItem, + type AutocompleteItemType, + type AutocompleteProfile, +} from '#/components/Autocomplete/types' + +const DEFAULT_MOD_OPTS = { + userDid: undefined, + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, +} + +export function useAutocomplete({ + type, + query, + limit, +}: { + type: AutocompleteItemType + query: string + limit?: number +}) { + const agent = useAgent() + const moderationOpts = useModerationOpts() + + return useQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: [ + 'autocomplete', + { + type, + query, + }, + ], + async queryFn() { + if (type === 'profile') { + // TODO return recents + if (!query) return [] + + const res = await agent.searchActorsTypeahead({ + q: query, + limit: limit || 8, + }) + + return (res?.data.actors || []).map(profile => ({ + key: profile.did, + type: 'profile' as const, + value: '@' + profile.handle, + profile, + })) + } + + return [] + }, + select: useCallback( + (items: AutocompleteItem[]) => { + const seen = new Set() + let results: AutocompleteItem[] = [] + + for (const item of items) { + if (seen.has(item.key)) continue + seen.add(item.key) + + if (item.type === 'profile') { + const moderated = moderateProfileItem({ + query, + item, + moderationOpts: moderationOpts || DEFAULT_MOD_OPTS, + }) + if (moderated) results.push(moderated) + } else { + results.push(item) + } + } + + return results + }, + [query, moderationOpts], + ), + placeholderData: keepPreviousData, + }) +} + +function moderateProfileItem({ + query, + item, + moderationOpts, +}: { + query: string + item: AutocompleteProfile + moderationOpts: ModerationOpts +}) { + const modui = moderateProfile(item.profile, moderationOpts).ui('profileList') + const isExactMatch = query && item.profile.handle.toLowerCase() === query + + if ( + (isExactMatch && !moduiContainsHideableOffense(modui)) || + !modui.filter || + isJustAMute(modui) + ) { + return item + } + + return null +} diff --git a/src/components/Autocomplete/util.ts b/src/components/Autocomplete/util.ts new file mode 100644 index 0000000000..cf16aa8839 --- /dev/null +++ b/src/components/Autocomplete/util.ts @@ -0,0 +1,12 @@ +export function parseAutocompleteItemType(type: string) { + switch (type) { + case 'mention': + return 'profile' + case 'tag': + return 'tag' + case 'emoji': + return 'emoji' + default: + throw new Error(`Unknown autocomplete item type: ${type}`) + } +} diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx new file mode 100644 index 0000000000..4170db21ba --- /dev/null +++ b/src/components/Composer/index.tsx @@ -0,0 +1,533 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { + TextInput, + type TextInputProps, + type TextInputSubmitEditingEvent, + View, +} from 'react-native' +import Animated, { + type SharedValue, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {useSift, type UseSiftReturn} from '@bsky.app/sift' +import { + type TapperActiveFacet, + type TapperFacet, + type TapperSnapshot, + useTapper, +} from '@bsky.app/tapper' + +import {HITSLOP_10} from '#/lib/constants' +import {mergeRefs} from '#/lib/merge-refs' +import { + atoms as a, + extractPadding, + type TextStyleProp, + useAlf, + type ViewStyleProp, + web, +} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' +import { + Autocomplete as AutocompleteBase, + AutocompleteItemProfile, + parseAutocompleteItemType, + useAutocomplete, +} from '#/components/Autocomplete' +import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' +import {Span, Text} from '#/components/Typography' +import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' + +/* + * ─── Types ──────────────────────────────────────────────────────────────────── + */ + +export type SubmitRequest = + | { + platform: 'web' + shiftKey: boolean + metaKey: boolean + nativeEvent: KeyboardEvent + } + | { + platform: 'native' + nativeEvent: TextInputSubmitEditingEvent + } + +/** + * Bail-out API for special cases where a parent component needs to + * imperatively control the Composer (e.g. clearing the input on submit). + * Prefer props/callbacks for normal data flow. + */ +export type ComposerInternalApi = { + input?: ReturnType['input'] + clear: () => void + insert(text: string): void +} + +export function useComposerInternalApiRef() { + return useRef(null) +} + +/* + * ─── Contexts ───────────────────────────────────────────────────────────────── + */ + +type ComposerContextValue = { + tapper: { + on: ReturnType['on'] + insert: ReturnType['insert'] + input: ReturnType['input'] + inputProps: ReturnType['inputProps'] + } + sift: UseSiftReturn + inputScrollSharedValue: SharedValue + onRequestSubmit?: (request: SubmitRequest) => void +} + +const ComposerContext = createContext(null) +ComposerContext.displayName = 'ComposerContext' + +export function useComposerContext() { + const ctx = useContext(ComposerContext) + if (!ctx) { + throw new Error('useComposerContext must be used within a Composer.Root') + } + return ctx +} + +type ComposerStateContextValue = { + state: TapperSnapshot +} + +const ComposerStateContext = createContext( + null, +) +ComposerStateContext.displayName = 'ComposerStateContext' + +export function useComposerStateContext() { + const ctx = useContext(ComposerStateContext) + if (!ctx) { + throw new Error( + 'useComposerStateContext must be used within a Composer.Root', + ) + } + return ctx +} + +/* + * ─── Root ───────────────────────────────────────────────────────────────────── + */ + +export type RootProps = { + children: React.ReactNode + initialText?: string + onChange?: (text: string) => void + onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void + onFacetCommitted?: (facet: TapperFacet) => void + onRequestSubmit?: (request: SubmitRequest) => void + internalApiRef?: React.Ref +} + +export function Root({ + children, + initialText, + onChange: onChangeOuter, + onActiveFacet: onActiveFacetOuter, + onFacetCommitted: onFacetCommittedOuter, + onRequestSubmit, + internalApiRef, +}: RootProps) { + const tapper = useTapper({ + initialText, + }) + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'top-start', + dynamicWidth: IS_WEB, + }) + const inputScrollSharedValue = useSharedValue(0) + + const callbackRefs = useRef({ + onActiveFacetOuter, + onFacetCommittedOuter, + }) + callbackRefs.current = { + onActiveFacetOuter, + onFacetCommittedOuter, + } + + useImperativeHandle( + internalApiRef, + () => ({ + input: tapper.input, + clear: () => { + tapper.inputProps.onChangeText('') + inputScrollSharedValue.value = 0 + }, + insert: tapper.insert, + }), + [tapper.input, tapper.insert, inputScrollSharedValue], + ) + + /* + * Skip the initial mount to avoid an unnecessary re-render — the parent + * already knows the initial value since it passed `initialText`. + */ + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + onChangeOuter?.(tapper.state.text) + }, [tapper.state.text, onChangeOuter]) + + useEffect(() => { + const offActiveFacet = tapper.on('activeFacet', facet => { + callbackRefs.current.onActiveFacetOuter?.(facet) + }) + const offFacetCommitted = tapper.on('facetCommitted', facet => { + callbackRefs.current.onFacetCommittedOuter?.(facet) + }) + return () => { + offActiveFacet() + offFacetCommitted() + } + }, [tapper.on]) + + const composerCtx = useMemo( + () => ({ + tapper: { + on: tapper.on, + insert: tapper.insert, + input: tapper.input, + inputProps: tapper.inputProps, + }, + sift, + inputScrollSharedValue, + onRequestSubmit, + }), + [ + tapper.on, + tapper.insert, + tapper.input, + tapper.inputProps, + sift, + inputScrollSharedValue, + onRequestSubmit, + ], + ) + + const stateCtx = useMemo( + () => ({state: tapper.state}), + [tapper.state], + ) + + return ( + + + {children} + + + ) +} + +/* + * ─── Input ──────────────────────────────────────────────────────────────────── + */ + +export type InputProps = Omit< + TextInputProps, + | 'value' + | 'onChangeText' + | 'onSelectionChange' + | 'selection' + | 'style' + | 'onSubmitEditing' +> & { + label: string + ref?: React.Ref + style?: ViewStyleProp['style'] + padding?: Parameters[0] + textStyle?: TextStyleProp['style'] + initialNumberOfLines?: number + maxNumberOfLines?: number +} + +export function Input({ + label, + placeholder, + style, + padding, + textStyle: rawTextStyle, + initialNumberOfLines = 1, + maxNumberOfLines, + ...rest +}: InputProps) { + const {theme: t, fonts} = useAlf() + const {tapper, sift, inputScrollSharedValue, onRequestSubmit} = + useComposerContext() + const {state} = useComposerStateContext() + const textInputRef = useRef(null) + + const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { + const ts = normalizeTextStyles( + [a.leading_snug, rawTextStyle, t.atoms.text], + { + fontScale: fonts.scaleMultiplier, + fontFamily: fonts.family, + flags: {}, + }, + ) + const p = padding + ? extractPadding(padding) + : {paddingTop: 0, paddingBottom: 0} + const lineHeight = ts.lineHeight || 20 + const verticalSpace = p.paddingTop + p.paddingBottom + const mh = lineHeight * initialNumberOfLines + verticalSpace + const xh = maxNumberOfLines + ? lineHeight * maxNumberOfLines + verticalSpace + : 999 + const tas = IS_WEB + ? {height: lineHeight + verticalSpace} + : {minHeight: mh, maxHeight: xh} + + return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh} + }, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) + + const prevHeight = useRef(0) + useEffect(() => { + if (IS_WEB) { + const el = textInputRef.current as unknown as HTMLTextAreaElement + if (!el) return + el.style.height = '0px' + const scrollHeight = el.scrollHeight + const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight) + el.style.height = `${nextHeight}px` + el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' + if (nextHeight !== prevHeight.current) { + prevHeight.current = nextHeight + sift.updatePosition() + } + return + } + + textInputRef.current?.measure((_x, _y, _w, h) => { + if (h !== prevHeight.current) { + prevHeight.current = h + sift.updatePosition() + } + }) + }, [state.text, minHeight, maxHeight, sift]) + + const previewScrollStyle = useAnimatedStyle(() => ({ + transform: [{translateY: -inputScrollSharedValue.value}], + })) + + const isComposing = useRef(false) + const onKeyPressWeb = useCallback( + (e: React.KeyboardEvent | any) => { + if (IS_WEB_TOUCH_DEVICE) return + if (isComposing.current) return + + /* + * On Safari, the final keydown to dismiss an IME is also "Enter" with + * keyCode 229. Chrome/Firefox don't have this problem. + * + * @see https://github.com/bluesky-social/social-app/issues/4178 + */ + if (e.key === 'Enter' && e.keyCode === 229) return + + if (e.key === 'Enter') { + onRequestSubmit?.({ + platform: 'web', + shiftKey: e.shiftKey, + metaKey: e.metaKey, + nativeEvent: e.nativeEvent, + }) + } + }, + [onRequestSubmit], + ) + + const textContent = ( + + {state.nodes.map((node, i) => { + switch (node.type) { + case 'text': + return {node.value} + case 'trigger': + case 'facet': + return ( + + {node.raw} + + ) + } + })} + + ) + + return ( + + {IS_WEB && ( + + + {textContent} + + + )} + { + onRequestSubmit?.({platform: 'native', nativeEvent: e}) + }} + style={[ + textStyle, + padding, + a.relative, + a.z_20, + a.border_0, + { + color: 'transparent', + background: 'transparent', + textAlignVertical: 'top', + includeFontPadding: false, + }, + textAreaStyle, + web({ + resize: 'none', + outline: 'none', + caretColor: textStyle.color ?? 'black', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overscrollBehavior: 'none', + ...textAreaStyle, + }), + ]} + {...rest} + {...tapper.inputProps} + value={undefined} + {...sift.targetProps} + ref={mergeRefs([ + textInputRef, + rest.ref, + tapper.inputProps.ref, + sift.targetProps.ref, + ])} + onBlur={e => { + rest.onBlur?.(e) + }} + onKeyPress={IS_WEB ? onKeyPressWeb : undefined} + onScroll={e => { + if (IS_WEB) { + inputScrollSharedValue.value = (e.target as any).scrollTop + } else { + inputScrollSharedValue.value = e.nativeEvent.contentOffset.y + } + }} + // @ts-ignore web only + onCompositionStart={() => { + isComposing.current = true + }} + // @ts-ignore web only + onCompositionEnd={() => { + isComposing.current = false + }}> + {IS_WEB ? null : textContent} + + + ) +} + +export function Autocomplete() { + const {tapper, sift} = useComposerContext() + const [activeFacet, setActiveFacet] = useState(null) + + useEffect(() => { + const off = tapper.on('activeFacet', facet => { + setActiveFacet(facet) + }) + return off + }, [tapper.on]) + + const updatePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updatePosition) + useOnKeyboard('keyboardDidHide', updatePosition) + + if (!activeFacet) return null + + return ( + setActiveFacet(null)} + /> + ) +} + +function AutocompleteInner({ + sift, + activeFacet, + onDismiss, +}: { + sift: UseSiftReturn + activeFacet: TapperActiveFacet + onDismiss: () => void +}) { + const {data} = useAutocomplete({ + type: parseAutocompleteItemType(activeFacet.type), + query: activeFacet.value, + }) + + return data ? ( + { + if (props.item.type === 'profile') { + return + } + return + }} + onSelect={item => { + activeFacet.replace(item.value) + }} + onDismiss={onDismiss} + /> + ) : null +} diff --git a/src/screens/Messages/components/ComposerMonolithic.tsx b/src/screens/Messages/components/ComposerMonolithic.tsx new file mode 100644 index 0000000000..a1d9cbe618 --- /dev/null +++ b/src/screens/Messages/components/ComposerMonolithic.tsx @@ -0,0 +1,747 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { + Pressable, + TextInput, + type TextInputProps, + type TextInputSubmitEditingEvent, + View, +} from 'react-native' +import Animated, { + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import {Sift, SiftItem, useSift} from '@bsky.app/sift' +import { + type TapperActiveFacet, + type TapperFacet, + useTapper, +} from '@bsky.app/tapper' +import {useLingui} from '@lingui/react/macro' +import {countGraphemes} from 'unicode-segmenter/grapheme' + +import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {mergeRefs} from '#/lib/merge-refs' +import {isBskyPostUrl} from '#/lib/strings/url-helpers' +import {useEmail} from '#/state/email-verification' +import { + useMessageDraft, + useSaveMessageDraft, +} from '#/state/messages/message-drafts' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import { + type Emoji, + EmojiPicker, + type EmojiPickerState, +} from '#/view/com/composer/text-input/web/EmojiPicker' +import { + atoms as a, + extractPadding, + type TextStyleProp, + useAlf, + useTheme, + type ViewStyleProp, + web, +} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' +import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' +import {Portal} from '#/components/Portal' +import * as Toast from '#/components/Toast' +import {Span, Text} from '#/components/Typography' +import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' + +export type SubmitRequest = + | { + platform: 'web' + shiftKey: boolean + metaKey: boolean + nativeEvent: KeyboardEvent + } + | { + platform: 'native' + nativeEvent: TextInputSubmitEditingEvent + } + +/** + * Bail-out API for special cases where a parent component needs to + * imperatively control the Composer (e.g. clearing the input on submit). + * Prefer props/callbacks for normal data flow. + */ +export type ComposerInternalApi = { + input?: ReturnType['input'] + clear: () => void + insert(text: string): void +} + +export function useComposerInternalApiRef() { + return useRef(null) +} + +export type ComposerProps = Omit< + TextInputProps, + 'value' | 'onSelectionChange' | 'selection' | 'style' | 'onSubmitEditing' +> & { + /** + * Required a11y label, used for accessibilityHint as well unless that prop is specified. + */ + label: string + /** + * Optional forwarded ref. + */ + ref?: React.Ref + /** + * Styles applied to the input container. To style the text, use the + * `textStyle` prop. + */ + style?: ViewStyleProp['style'] + /** + * Padding applied to the `TextInput` and the facet preview container. + */ + padding?: Parameters[0] + /** + * Shared text style applied to both the preview overlay and the input. + * Must match exactly for pixel-perfect alignment. + */ + textStyle?: TextStyleProp['style'] + /** + * Sets a default height on the input, but still allows for expansion + */ + initialNumberOfLines?: number + /** + * Sets the max height on the input + */ + maxNumberOfLines?: number + /** + * When a facet is active (e.g. the user is typing after a trigger), this callbacks is called with the active facet info. When the facet is committed (e.g. the user selects an autocomplete suggestion or finishes typing), the `onFacetCommitted` callback is called with the committed facet info. + */ + onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void + /** + * Called when a facet is committed, either by selecting an autocomplete suggestion or by finishing typing. The committed facet info is passed as an argument. + */ + onFacetCommitted?: (facet: TapperFacet) => void + /** + * Called when the user presses Enter on web. Includes modifier key state + * and the native event for calling `preventDefault()`. On native, fired + * from a submit button press. + */ + onRequestSubmit?: (request: SubmitRequest) => void + /** + * Ref to the internal imperative API. See {@link ComposerInternalApi}. + */ + internalApiRef?: React.Ref +} + +function Composer({ + children, + label, + placeholder, + defaultValue, + style, + padding, + textStyle: rawTextStyle, + initialNumberOfLines = 1, + maxNumberOfLines, + onChangeText: onChangeTextOuter, + onActiveFacet: onActiveFacetOuter, + onFacetCommitted: onFacetCommittedOuter, + internalApiRef, + onRequestSubmit, + ...rest +}: ComposerProps) { + const {theme: t, fonts} = useAlf() + const tapper = useTapper({ + initialText: defaultValue, + }) + const callbackRefs = useRef({ + onActiveFacetOuter, + onFacetCommittedOuter, + }) + callbackRefs.current = { + onActiveFacetOuter, + onFacetCommittedOuter, + } + const scrollY = useSharedValue(0) + + useImperativeHandle( + internalApiRef, + () => ({ + input: tapper.input, + clear: () => { + tapper.inputProps.onChangeText('') + scrollY.value = 0 + }, + insert: tapper.insert, + }), + [tapper.inputProps, tapper.input, tapper.insert, scrollY], + ) + + const [activeFacet, setActiveFacet] = useState(null) + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'top-start', + dynamicWidth: IS_WEB, + }) + + /* + * Skip the initial mount to avoid an unnecessary re-render — the parent + * already knows the initial value since it passed `defaultValue`. + */ + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + onChangeTextOuter?.(tapper.state.text) + }, [tapper.state.text, onChangeTextOuter]) + + useEffect(() => { + const offActiveFacet = tapper.on('activeFacet', activeFacet => { + setActiveFacet(activeFacet) + callbackRefs.current.onActiveFacetOuter?.(activeFacet) + }) + const offFacetCommitted = tapper.on('facetCommitted', facet => { + callbackRefs.current.onFacetCommittedOuter?.(facet) + }) + return () => { + offActiveFacet() + offFacetCommitted() + } + }, []) + + const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { + const textStyle = normalizeTextStyles( + [a.leading_snug, rawTextStyle, t.atoms.text], + { + fontScale: fonts.scaleMultiplier, + fontFamily: fonts.family, + flags: {}, + }, + ) + const p = padding + ? extractPadding(padding) + : { + paddingTop: 0, + paddingBottom: 0, + } + const lineHeight = textStyle.lineHeight || 20 + const verticalSpace = p.paddingTop + p.paddingBottom + const minHeight = lineHeight * initialNumberOfLines + verticalSpace + const maxHeight = maxNumberOfLines + ? lineHeight * maxNumberOfLines + verticalSpace + : 999 + const textAreaStyle = IS_WEB + ? { + height: (textStyle.lineHeight || 20) + p.paddingTop + p.paddingBottom, + } + : {minHeight, maxHeight} + + /* + * On iOS especially, TextInput and Text line height does not render the + * same way, but setting this to undefined and using the default font + * metrics works fine. + */ + if (!IS_WEB) { + // disabled for now to eval the text as children + // delete textStyle.lineHeight + } + + return { + textStyle, + textAreaStyle, + minHeight, + maxHeight, + } + }, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) + + const updateAutocompletePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updateAutocompletePosition) + useOnKeyboard('keyboardDidHide', updateAutocompletePosition) + + const prevHeight = useRef(0) + useEffect(() => { + if (IS_WEB) { + const el = tapper.input.element as unknown as HTMLTextAreaElement + if (!el) return + el.style.height = '0px' + const scrollHeight = el.scrollHeight + const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight) + el.style.height = `${nextHeight}px` + el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' + if (nextHeight !== prevHeight.current) { + prevHeight.current = nextHeight + updateAutocompletePosition() + } + return + } + + tapper.input.element?.measure((_x, _y, _w, h) => { + if (h !== prevHeight.current) { + prevHeight.current = h + updateAutocompletePosition() + } + }) + }, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition]) + + const previewScrollStyle = useAnimatedStyle(() => ({ + transform: [{translateY: -scrollY.value}], + })) + + const isComposing = useRef(false) + const onKeyPressWeb = useCallback( + (e: React.KeyboardEvent | any) => { + /* + * On mobile web phones, we want to keep the same behavior as the native + * app. Do not submit the message in these cases. + */ + if (IS_WEB_TOUCH_DEVICE) return + + // Don't submit the form when the Japanese or any other IME is composing + if (isComposing.current) return + + /** + * On Safari, the final keydown event to dismiss the IME - which is the + * enter key - is also "Enter" below. Obviously, this causes problems + * because the final dismissal should _not_ submit the text, but should + * just stop the IME editing. This is the behavior of Chrome and Firefox, + * but not Safari. Keycode is deprecated, however the alternative seems + * to only be to compare the timestamp from the onCompositionEnd event to + * the timestamp of the keydown event, which is not reliable. For + * example, this hack uses that method: + * https://github.com/ProseMirror/prosemirror-view/pull/44. However, from + * my 500ms resulted in far too long of a delay, and a subsequent enter + * press would often just end up doing nothing. A shorter time frame was + * also not great, since it was too short to be reliable (i.e. an older + * system might have a larger time gap between the two events firing. + * + * @see https://github.com/bluesky-social/social-app/issues/4178 + * @see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ + * @see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html + */ + if (IS_WEB && e.key === 'Enter' && e.keyCode === 229) { + return + } + + if (e.key === 'Enter') { + onRequestSubmit?.({ + platform: 'web', + shiftKey: e.shiftKey, + metaKey: e.metaKey, + nativeEvent: e.nativeEvent, + }) + } + }, + [onRequestSubmit], + ) + + const textContent = ( + + {tapper.state.nodes.map((node, i) => { + switch (node.type) { + case 'text': { + return {node.value} + } + case 'trigger': + case 'facet': { + return ( + + {node.raw} + + ) + } + } + })} + + ) + + return ( + <> + + {IS_WEB && ( + + + {textContent} + + + )} + { + onRequestSubmit?.({platform: 'native', nativeEvent: e}) + }} + style={[ + textStyle, + padding, + a.relative, + a.z_20, + a.border_0, + { + color: 'transparent', + background: 'transparent', + textAlignVertical: 'top', + includeFontPadding: false, + }, + textAreaStyle, + web({ + resize: 'none', + outline: 'none', + caretColor: textStyle.color ?? 'black', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overscrollBehavior: 'none', + ...textAreaStyle, + }), + ]} + {...rest} + {...tapper.inputProps} + value={undefined} + {...sift.targetProps} + ref={mergeRefs([ + rest.ref, + tapper.inputProps.ref, + sift.targetProps.ref, + ])} + onBlur={e => { + rest.onBlur?.(e) + setActiveFacet(null) + }} + onKeyPress={IS_WEB ? onKeyPressWeb : undefined} + onScroll={e => { + if (IS_WEB) { + scrollY.value = (e.target as any).scrollTop + } else { + scrollY.value = e.nativeEvent.contentOffset.y + } + }} + // @ts-ignore web only + onCompositionStart={() => { + isComposing.current = true + }} + // @ts-ignore web only + onCompositionEnd={() => { + isComposing.current = false + }}> + {IS_WEB ? null : textContent} + + + {children} + + + {activeFacet && ( + + { + activeFacet?.replace(item.value) + }} + onDismiss={() => setActiveFacet(null)} + style={[ + a.overflow_hidden, + a.rounded_md, + a.border, + t.atoms.border_contrast_low, + t.atoms.bg, + !IS_WEB && a.w_full, + ]} + render={({active, props, item}) => ( + [ + a.px_md, + a.py_sm, + (active || s.hovered) && t.atoms.bg_contrast_50, + ]}> + {item.label} + + )} + /> + + )} + + ) +} + +export function MessageComposer({ + onSendMessage, + hasEmbed, + setEmbed, + children, +}: { + onSendMessage: (message: string) => void + hasEmbed: boolean + setEmbed: (embedUrl: string | undefined) => void + children?: React.ReactNode +}) { + const t = useTheme() + const {t: l} = useLingui() + const playHaptic = useHaptics() + const {needsEmailVerification} = useEmail() + const editable = !needsEmailVerification + const {getDraft, clearDraft} = useMessageDraft() + const [emojiPickerState, setEmojiPickerState] = useState({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, + }) + const composerInternalApiRef = useComposerInternalApiRef() + + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + const [text, setText] = useState(getDraft) + useSaveMessageDraft(text) + + const openEmojiPicker = useCallback((pos: any) => { + setEmojiPickerState({isOpen: true, pos}) + }, []) + + const onSubmit = useCallback(() => { + if (!editable) return + if (!hasEmbed && text.trim() === '') return + if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) { + Toast.show(l`Message is too long`, { + type: 'error', + }) + return + } + + clearDraft() + onSendMessage(text) + playHaptic() + setEmbed(undefined) + composerInternalApiRef.current?.clear() + + if (IS_WEB) { + composerInternalApiRef.current?.input?.focus() + } + }, [ + l, + editable, + hasEmbed, + text, + clearDraft, + onSendMessage, + playHaptic, + setEmbed, + composerInternalApiRef, + ]) + + useEffect(() => { + function onEmojiInserted(emoji: Emoji) { + composerInternalApiRef.current?.insert(emoji.native) + } + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) + return () => { + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) + } + }, []) + + return ( + <> + + {children} + + { + if (facet.type === 'url' && isBskyPostUrl(facet.value)) { + setEmbed(facet.value) + } + }} + onRequestSubmit={req => { + if (req.platform === 'web' && req.shiftKey) return + req.nativeEvent.preventDefault() + onSubmit() + }}> + {IS_WEB && ( + { + e.currentTarget.measure( + (_fx, _fy, _width, _height, px, py) => { + openEmojiPicker?.({ + top: py, + left: px, + right: px, + bottom: py, + nextFocusRef: { + current: + composerInternalApiRef.current?.input?.element, + }, + }) + }, + ) + }} + style={[ + a.overflow_hidden, + a.absolute, + a.rounded_full, + a.align_center, + a.justify_center, + a.z_30, + { + height: 30, + width: 30, + top: 7, + left: 7, + }, + ]} + accessibilityLabel={l`Open emoji picker`} + accessibilityHint=""> + {state => ( + + + + )} + + )} + + + + + + + + {IS_WEB && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + + ) +} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 6db22c4522..9ee5896426 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -1,34 +1,10 @@ -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import { - Pressable, - TextInput, - type TextInputProps, - type TextInputSubmitEditingEvent, - View, -} from 'react-native' -import Animated, { - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated' -import {Sift, SiftItem, useSift} from '@bsky.app/sift' -import { - type TapperActiveFacet, - type TapperFacet, - useTapper, -} from '@bsky.app/tapper' +import {useCallback, useEffect, useState} from 'react' +import {Pressable, View} from 'react-native' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' -import {mergeRefs} from '#/lib/merge-refs' import {isBskyPostUrl} from '#/lib/strings/url-helpers' import {useEmail} from '#/state/email-verification' import { @@ -41,483 +17,13 @@ import { EmojiPicker, type EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker' -import { - atoms as a, - extractPadding, - type TextStyleProp, - useAlf, - useTheme, - type ViewStyleProp, - web, -} from '#/alf' -import {normalizeTextStyles} from '#/alf/typography' +import {atoms as a, useTheme} from '#/alf' +import * as Composer from '#/components/Composer' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' -import {Portal} from '#/components/Portal' import * as Toast from '#/components/Toast' -import {Span, Text} from '#/components/Typography' -import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' - -export type SubmitRequest = - | { - platform: 'web' - shiftKey: boolean - metaKey: boolean - nativeEvent: KeyboardEvent - } - | { - platform: 'native' - nativeEvent: TextInputSubmitEditingEvent - } - -/** - * Bail-out API for special cases where a parent component needs to - * imperatively control the Composer (e.g. clearing the input on submit). - * Prefer props/callbacks for normal data flow. - */ -export type ComposerInternalApi = { - input?: ReturnType['input'] - clear: () => void - insert(text: string): void -} - -export function useComposerInternalApiRef() { - return useRef(null) -} - -export type ComposerProps = Omit< - TextInputProps, - 'value' | 'onSelectionChange' | 'selection' | 'style' | 'onSubmitEditing' -> & { - /** - * Required a11y label, used for accessibilityHint as well unless that prop is specified. - */ - label: string - /** - * Optional forwarded ref. - */ - ref?: React.Ref - /** - * Styles applied to the input container. To style the text, use the - * `textStyle` prop. - */ - style?: ViewStyleProp['style'] - /** - * Padding applied to the `TextInput` and the facet preview container. - */ - padding?: Parameters[0] - /** - * Shared text style applied to both the preview overlay and the input. - * Must match exactly for pixel-perfect alignment. - */ - textStyle?: TextStyleProp['style'] - /** - * Sets a default height on the input, but still allows for expansion - */ - initialNumberOfLines?: number - /** - * Sets the max height on the input - */ - maxNumberOfLines?: number - /** - * When a facet is active (e.g. the user is typing after a trigger), this callbacks is called with the active facet info. When the facet is committed (e.g. the user selects an autocomplete suggestion or finishes typing), the `onFacetCommitted` callback is called with the committed facet info. - */ - onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void - /** - * Called when a facet is committed, either by selecting an autocomplete suggestion or by finishing typing. The committed facet info is passed as an argument. - */ - onFacetCommitted?: (facet: TapperFacet) => void - /** - * Called when the user presses Enter on web. Includes modifier key state - * and the native event for calling `preventDefault()`. On native, fired - * from a submit button press. - */ - onRequestSubmit?: (request: SubmitRequest) => void - /** - * Ref to the internal imperative API. See {@link ComposerInternalApi}. - */ - internalApiRef?: React.Ref -} - -function Composer({ - children, - label, - placeholder, - defaultValue, - style, - padding, - textStyle: rawTextStyle, - initialNumberOfLines = 1, - maxNumberOfLines, - onChangeText: onChangeTextOuter, - onActiveFacet: onActiveFacetOuter, - onFacetCommitted: onFacetCommittedOuter, - internalApiRef, - onRequestSubmit, - ...rest -}: ComposerProps) { - const {theme: t, fonts} = useAlf() - const textInputRef = useRef(null) - const tapper = useTapper({ - initialText: defaultValue, - }) - const callbackRefs = useRef({ - onActiveFacetOuter, - onFacetCommittedOuter, - }) - callbackRefs.current = { - onActiveFacetOuter, - onFacetCommittedOuter, - } - const scrollY = useSharedValue(0) - - useImperativeHandle( - internalApiRef, - () => ({ - input: tapper.input, - clear: () => { - tapper.inputProps.onChangeText('') - scrollY.value = 0 - }, - insert: tapper.insert, - }), - [tapper.inputProps, tapper.input, tapper.insert, scrollY], - ) - - const [activeFacet, setActiveFacet] = useState(null) - const sift = useSift({ - offset: a.p_sm.padding, - placement: 'top-start', - dynamicWidth: IS_WEB, - }) - - /* - * Skip the initial mount to avoid an unnecessary re-render — the parent - * already knows the initial value since it passed `defaultValue`. - */ - const isFirstRender = useRef(true) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return - } - onChangeTextOuter?.(tapper.state.text) - }, [tapper.state.text, onChangeTextOuter]) - - useEffect(() => { - const offActiveFacet = tapper.on('activeFacet', activeFacet => { - setActiveFacet(activeFacet) - callbackRefs.current.onActiveFacetOuter?.(activeFacet) - }) - const offFacetCommitted = tapper.on('facetCommitted', facet => { - callbackRefs.current.onFacetCommittedOuter?.(facet) - }) - return () => { - offActiveFacet() - offFacetCommitted() - } - }, []) - - const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { - const textStyle = normalizeTextStyles( - [a.leading_snug, rawTextStyle, t.atoms.text], - { - fontScale: fonts.scaleMultiplier, - fontFamily: fonts.family, - flags: {}, - }, - ) - const p = padding - ? extractPadding(padding) - : { - paddingTop: 0, - paddingBottom: 0, - } - const lineHeight = textStyle.lineHeight || 20 - const verticalSpace = p.paddingTop + p.paddingBottom - const minHeight = lineHeight * initialNumberOfLines + verticalSpace - const maxHeight = maxNumberOfLines - ? lineHeight * maxNumberOfLines + verticalSpace - : 999 - const textAreaStyle = IS_WEB - ? { - height: (textStyle.lineHeight || 20) + p.paddingTop + p.paddingBottom, - } - : {minHeight, maxHeight} - - /* - * On iOS especially, TextInput and Text line height does not render the - * same way, but setting this to undefined and using the default font - * metrics works fine. - */ - if (!IS_WEB) { - // disabled for now to eval the text as children - // delete textStyle.lineHeight - } - - return { - textStyle, - textAreaStyle, - minHeight, - maxHeight, - } - }, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) - - const updateAutocompletePosition = useCallback(() => { - sift.updatePosition() - }, [sift]) - - useOnKeyboard('keyboardDidShow', updateAutocompletePosition) - useOnKeyboard('keyboardDidHide', updateAutocompletePosition) - - const prevHeight = useRef(0) - useEffect(() => { - if (IS_WEB) { - const el = textInputRef.current as unknown as HTMLTextAreaElement - if (!el) return - el.style.height = '0px' - const scrollHeight = el.scrollHeight - const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight) - el.style.height = `${nextHeight}px` - el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' - if (nextHeight !== prevHeight.current) { - prevHeight.current = nextHeight - updateAutocompletePosition() - } - return - } - - textInputRef.current?.measure((_x, _y, _w, h) => { - if (h !== prevHeight.current) { - prevHeight.current = h - updateAutocompletePosition() - } - }) - }, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition]) - - const previewScrollStyle = useAnimatedStyle(() => ({ - transform: [{translateY: -scrollY.value}], - })) - - const isComposing = useRef(false) - const onKeyPressWeb = useCallback( - (e: React.KeyboardEvent | any) => { - /* - * On mobile web phones, we want to keep the same behavior as the native - * app. Do not submit the message in these cases. - */ - if (IS_WEB_TOUCH_DEVICE) return - - // Don't submit the form when the Japanese or any other IME is composing - if (isComposing.current) return - - /** - * On Safari, the final keydown event to dismiss the IME - which is the - * enter key - is also "Enter" below. Obviously, this causes problems - * because the final dismissal should _not_ submit the text, but should - * just stop the IME editing. This is the behavior of Chrome and Firefox, - * but not Safari. Keycode is deprecated, however the alternative seems - * to only be to compare the timestamp from the onCompositionEnd event to - * the timestamp of the keydown event, which is not reliable. For - * example, this hack uses that method: - * https://github.com/ProseMirror/prosemirror-view/pull/44. However, from - * my 500ms resulted in far too long of a delay, and a subsequent enter - * press would often just end up doing nothing. A shorter time frame was - * also not great, since it was too short to be reliable (i.e. an older - * system might have a larger time gap between the two events firing. - * - * @see https://github.com/bluesky-social/social-app/issues/4178 - * @see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ - * @see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html - */ - if (IS_WEB && e.key === 'Enter' && e.keyCode === 229) { - return - } - - if (e.key === 'Enter') { - onRequestSubmit?.({ - platform: 'web', - shiftKey: e.shiftKey, - metaKey: e.metaKey, - nativeEvent: e.nativeEvent, - }) - } - }, - [onRequestSubmit], - ) - - const textContent = ( - - {tapper.state.nodes.map((node, i) => { - switch (node.type) { - case 'text': { - return {node.value} - } - case 'trigger': - case 'facet': { - return ( - - {node.raw} - - ) - } - } - })} - - ) - - return ( - <> - - {IS_WEB && ( - - - {textContent} - - - )} - { - onRequestSubmit?.({platform: 'native', nativeEvent: e}) - }} - style={[ - textStyle, - padding, - a.relative, - a.z_20, - a.border_0, - { - color: 'transparent', - background: 'transparent', - textAlignVertical: 'top', - includeFontPadding: false, - }, - textAreaStyle, - web({ - resize: 'none', - outline: 'none', - caretColor: textStyle.color ?? 'black', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - overscrollBehavior: 'none', - ...textAreaStyle, - }), - ]} - {...rest} - {...tapper.inputProps} - value={undefined} - {...sift.targetProps} - ref={mergeRefs([ - textInputRef, - rest.ref, - tapper.inputProps.ref, - sift.targetProps.ref, - ])} - onBlur={e => { - rest.onBlur?.(e) - setActiveFacet(null) - }} - onKeyPress={IS_WEB ? onKeyPressWeb : undefined} - onScroll={e => { - if (IS_WEB) { - scrollY.value = (e.target as any).scrollTop - } else { - scrollY.value = e.nativeEvent.contentOffset.y - } - }} - // @ts-ignore web only - onCompositionStart={() => { - isComposing.current = true - }} - // @ts-ignore web only - onCompositionEnd={() => { - isComposing.current = false - }}> - {IS_WEB ? null : textContent} - - - {children} - - - {activeFacet && ( - - { - activeFacet?.replace(item.value) - }} - onDismiss={() => setActiveFacet(null)} - style={[ - a.overflow_hidden, - a.rounded_md, - a.border, - t.atoms.border_contrast_low, - t.atoms.bg, - !IS_WEB && a.w_full, - ]} - render={({active, props, item}) => ( - [ - a.px_md, - a.py_sm, - (active || s.hovered) && t.atoms.bg_contrast_50, - ]}> - {item.label} - - )} - /> - - )} - - ) -} +import {IS_WEB} from '#/env' export function MessageComposer({ onSendMessage, @@ -540,7 +46,7 @@ export function MessageComposer({ isOpen: false, pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, }) - const composerInternalApiRef = useComposerInternalApiRef() + const composerInternalApiRef = Composer.useComposerInternalApiRef() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { @@ -601,59 +107,62 @@ export function MessageComposer({ <> {children} - - { + if (facet.type === 'url' && isBskyPostUrl(facet.value)) { + setEmbed(facet.value) + } + }} + onRequestSubmit={req => { + if (req.platform === 'web' && req.shiftKey) return + req.nativeEvent.preventDefault() + onSubmit() + }}> + + { - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { - setEmbed(facet.value) - } - }} - onRequestSubmit={req => { - if (req.platform === 'web' && req.shiftKey) return - req.nativeEvent.preventDefault() - onSubmit() - }}> + IS_WEB + ? { + paddingLeft: 30 + a.p_sm.padding, + } + : {}, + ]} + textStyle={[a.text_md, a.leading_snug]} + onFocus={onFocus} + onBlur={onBlur} + /> + {IS_WEB && ( { @@ -733,8 +242,10 @@ export function MessageComposer({ style={[a.relative, {left: 1}]} /> - - + + + + {IS_WEB && (