about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Dialog/context.ts10
-rw-r--r--src/components/Dialog/index.tsx131
-rw-r--r--src/components/Dialog/index.web.tsx57
-rw-r--r--src/components/Dialog/types.ts27
-rw-r--r--src/components/Link.tsx86
-rw-r--r--src/components/Prompt.tsx2
-rw-r--r--src/components/RichText.tsx27
-rw-r--r--src/components/Typography.tsx38
-rw-r--r--src/components/icons/Times.tsx5
9 files changed, 218 insertions, 165 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index b28b9f5a2..f0c7c983a 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -1,7 +1,11 @@
 import React from 'react'
 
 import {useDialogStateContext} from '#/state/dialogs'
-import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
+import {
+  DialogContextProps,
+  DialogControlProps,
+  DialogOuterProps,
+} from '#/components/Dialog/types'
 
 export const Context = React.createContext<DialogContextProps>({
   close: () => {},
@@ -11,7 +15,7 @@ export function useDialogContext() {
   return React.useContext(Context)
 }
 
-export function useDialogControl() {
+export function useDialogControl(): DialogOuterProps['control'] {
   const id = React.useId()
   const control = React.useRef<DialogControlProps>({
     open: () => {},
@@ -30,6 +34,6 @@ export function useDialogControl() {
   return {
     ref: control,
     open: () => control.current.open(),
-    close: () => control.current.close(),
+    close: cb => control.current.close(cb),
   }
 }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 9132e68de..27f43afd3 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -8,7 +8,7 @@ import BottomSheet, {
 } from '@gorhom/bottom-sheet'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 
-import {useTheme, atoms as a} from '#/alf'
+import {useTheme, atoms as a, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 import {createInput} from '#/components/forms/TextField'
 
@@ -35,12 +35,30 @@ export function Outer({
   const sheetOptions = nativeOptions?.sheet || {}
   const hasSnapPoints = !!sheetOptions.snapPoints
   const insets = useSafeAreaInsets()
+  const closeCallback = React.useRef<() => void>()
 
-  const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
-    sheet.current?.snapToIndex(i)
-  }, [])
+  /*
+   * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
+   */
+  const [openIndex, setOpenIndex] = React.useState(-1)
+
+  /*
+   * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
+   */
+  const isOpen = openIndex > -1
+
+  const open = React.useCallback<DialogControlProps['open']>(
+    ({index} = {}) => {
+      // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
+      setOpenIndex(index || 0)
+    },
+    [setOpenIndex],
+  )
 
-  const close = React.useCallback(() => {
+  const close = React.useCallback<DialogControlProps['close']>(cb => {
+    if (cb) {
+      closeCallback.current = cb
+    }
     sheet.current?.close()
   }, [])
 
@@ -56,78 +74,85 @@ export function Outer({
   const onChange = React.useCallback(
     (index: number) => {
       if (index === -1) {
+        closeCallback.current?.()
+        closeCallback.current = undefined
         onClose?.()
+        setOpenIndex(-1)
       }
     },
-    [onClose],
+    [onClose, setOpenIndex],
   )
 
   const context = React.useMemo(() => ({close}), [close])
 
   return (
-    <Portal>
-      <BottomSheet
-        enableDynamicSizing={!hasSnapPoints}
-        enablePanDownToClose
-        keyboardBehavior="interactive"
-        android_keyboardInputMode="adjustResize"
-        keyboardBlurBehavior="restore"
-        topInset={insets.top}
-        {...sheetOptions}
-        ref={sheet}
-        index={-1}
-        backgroundStyle={{backgroundColor: 'transparent'}}
-        backdropComponent={props => (
-          <BottomSheetBackdrop
-            opacity={0.4}
-            appearsOnIndex={0}
-            disappearsOnIndex={-1}
-            {...props}
-          />
-        )}
-        handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
-        handleStyle={{display: 'none'}}
-        onChange={onChange}>
-        <Context.Provider value={context}>
-          <View
-            style={[
-              a.absolute,
-              a.inset_0,
-              t.atoms.bg,
-              {
-                borderTopLeftRadius: 40,
-                borderTopRightRadius: 40,
-                height: Dimensions.get('window').height * 2,
-              },
-            ]}
-          />
-          {children}
-        </Context.Provider>
-      </BottomSheet>
-    </Portal>
+    isOpen && (
+      <Portal>
+        <BottomSheet
+          enableDynamicSizing={!hasSnapPoints}
+          enablePanDownToClose
+          keyboardBehavior="interactive"
+          android_keyboardInputMode="adjustResize"
+          keyboardBlurBehavior="restore"
+          topInset={insets.top}
+          {...sheetOptions}
+          snapPoints={sheetOptions.snapPoints || ['100%']}
+          ref={sheet}
+          index={openIndex}
+          backgroundStyle={{backgroundColor: 'transparent'}}
+          backdropComponent={props => (
+            <BottomSheetBackdrop
+              opacity={0.4}
+              appearsOnIndex={0}
+              disappearsOnIndex={-1}
+              {...props}
+              style={[flatten(props.style), t.atoms.bg_contrast_300]}
+            />
+          )}
+          handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
+          handleStyle={{display: 'none'}}
+          onChange={onChange}>
+          <Context.Provider value={context}>
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                t.atoms.bg,
+                {
+                  borderTopLeftRadius: 40,
+                  borderTopRightRadius: 40,
+                  height: Dimensions.get('window').height * 2,
+                },
+              ]}
+            />
+            {children}
+          </Context.Provider>
+        </BottomSheet>
+      </Portal>
+    )
   )
 }
 
-// TODO a11y props here, or is that handled by the sheet?
-export function Inner(props: DialogInnerProps) {
+export function Inner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetView
       style={[
-        a.p_lg,
+        a.p_xl,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
           paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
         },
+        flatten(style),
       ]}>
-      {props.children}
+      {children}
     </BottomSheetView>
   )
 }
 
-export function ScrollableInner(props: DialogInnerProps) {
+export function ScrollableInner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
     <BottomSheetScrollView
@@ -136,13 +161,15 @@ export function ScrollableInner(props: DialogInnerProps) {
       style={[
         a.flex_1, // main diff is this
         a.p_xl,
+        a.h_full,
         {
           paddingTop: 40,
           borderTopLeftRadius: 40,
           borderTopRightRadius: 40,
         },
+        flatten(style),
       ]}>
-      {props.children}
+      {children}
       <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
     </BottomSheetScrollView>
   )
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 305c00e97..79441fb5e 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -5,11 +5,13 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 
 import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
 import {Context} from '#/components/Dialog/context'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 
 export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
@@ -18,9 +20,9 @@ export {Input} from '#/components/forms/TextField'
 const stopPropagation = (e: any) => e.stopPropagation()
 
 export function Outer({
+  children,
   control,
   onClose,
-  children,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const {_} = useLingui()
   const t = useTheme()
@@ -147,7 +149,7 @@ export function Inner({
           a.rounded_md,
           a.w_full,
           a.border,
-          gtMobile ? a.p_xl : a.p_lg,
+          gtMobile ? a.p_2xl : a.p_xl,
           t.atoms.bg,
           {
             maxWidth: 600,
@@ -156,7 +158,7 @@ export function Inner({
             shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
             shadowRadius: 30,
           },
-          ...(Array.isArray(style) ? style : [style || {}]),
+          flatten(style),
         ]}>
         {children}
       </Animated.View>
@@ -170,25 +172,28 @@ export function Handle() {
   return null
 }
 
-/**
- * TODO(eric) unused rn
- */
-// export function Close() {
-//   const {_} = useLingui()
-//   const t = useTheme()
-//   const {close} = useDialogContext()
-//   return (
-//     <View
-//       style={[
-//         a.absolute,
-//         a.z_10,
-//         {
-//           top: a.pt_lg.paddingTop,
-//           right: a.pr_lg.paddingRight,
-//         },
-//       ]}>
-//       <Button onPress={close} label={_(msg`Close active dialog`)}>
-//       </Button>
-//     </View>
-//   )
-// }
+export function Close() {
+  const {_} = useLingui()
+  const {close} = React.useContext(Context)
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.z_10,
+        {
+          top: a.pt_md.paddingTop,
+          right: a.pr_md.paddingRight,
+        },
+      ]}>
+      <Button
+        size="small"
+        variant="ghost"
+        color="primary"
+        shape="round"
+        onPress={close}
+        label={_(msg`Close active dialog`)}>
+        <ButtonIcon icon={X} size="md" />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index d36784183..75ba825ac 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -1,24 +1,34 @@
 import React from 'react'
-import type {ViewStyle, AccessibilityProps} from 'react-native'
+import type {AccessibilityProps} from 'react-native'
 import {BottomSheetProps} from '@gorhom/bottom-sheet'
 
+import {ViewStyleProp} from '#/alf'
+
 type A11yProps = Required<AccessibilityProps>
 
 export type DialogContextProps = {
   close: () => void
 }
 
+export type DialogControlOpenOptions = {
+  /**
+   * NATIVE ONLY
+   *
+   * Optional index of the snap point to open the bottom sheet to. Defaults to
+   * 0, which is the first snap point (i.e. "open").
+   */
+  index?: number
+}
+
 export type DialogControlProps = {
-  open: (index?: number) => void
-  close: () => void
+  open: (options?: DialogControlOpenOptions) => void
+  close: (callback?: () => void) => void
 }
 
 export type DialogOuterProps = {
   control: {
     ref: React.RefObject<DialogControlProps>
-    open: (index?: number) => void
-    close: () => void
-  }
+  } & DialogControlProps
   onClose?: () => void
   nativeOptions?: {
     sheet?: Omit<BottomSheetProps, 'children'>
@@ -26,10 +36,7 @@ export type DialogOuterProps = {
   webOptions?: {}
 }
 
-type DialogInnerPropsBase<T> = React.PropsWithChildren<{
-  style?: ViewStyle
-}> &
-  T
+type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
 export type DialogInnerProps =
   | DialogInnerPropsBase<{
       label?: undefined
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index afd30b5ee..593b0863a 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,9 +1,5 @@
 import React from 'react'
-import {
-  GestureResponderEvent,
-  Linking,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import {GestureResponderEvent, Linking} from 'react-native'
 import {
   useLinkProps,
   useNavigation,
@@ -23,7 +19,7 @@ import {
 } from '#/lib/strings/url-helpers'
 import {useModalControls} from '#/state/modals'
 import {router} from '#/routes'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
 
 /**
  * Only available within a `Link`, since that inherits from `Button`.
@@ -55,11 +51,12 @@ type BaseLinkProps = Pick<
   warnOnMismatchingTextChild?: boolean
 
   /**
-   * Callback for when the link is pressed.
+   * Callback for when the link is pressed. Prevent default and return `false`
+   * to exit early and prevent navigation.
    *
    * DO NOT use this for navigation, that's what the `to` prop is for.
    */
-  onPress?: (e: GestureResponderEvent) => void
+  onPress?: (e: GestureResponderEvent) => void | false
 
   /**
    * Web-only attribute. Sets `download` attr on web.
@@ -86,7 +83,9 @@ export function useLink({
 
   const onPress = React.useCallback(
     (e: GestureResponderEvent) => {
-      outerOnPress?.(e)
+      const exitEarlyIfFalse = outerOnPress?.(e)
+
+      if (exitEarlyIfFalse === false) return
 
       const requiresWarning = Boolean(
         warnOnMismatchingTextChild &&
@@ -217,7 +216,7 @@ export function Link({
 }
 
 export type InlineLinkProps = React.PropsWithChildren<
-  BaseLinkProps & TextStyleProp
+  BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
 >
 
 export function InlineLink({
@@ -228,6 +227,7 @@ export function InlineLink({
   style,
   onPress: outerOnPress,
   download,
+  selectable,
   ...rest
 }: InlineLinkProps) {
   const t = useTheme()
@@ -253,43 +253,41 @@ export function InlineLink({
   const flattenedStyle = flatten(style)
 
   return (
-    <TouchableWithoutFeedback
-      accessibilityRole="button"
+    <Text
+      selectable={selectable}
+      label={href}
+      {...rest}
+      style={[
+        {color: t.palette.primary_500},
+        (hovered || focused || pressed) && {
+          outline: 0,
+          textDecorationLine: 'underline',
+          textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
+        },
+        flattenedStyle,
+      ]}
+      role="link"
       onPress={download ? undefined : onPress}
       onPressIn={onPressIn}
       onPressOut={onPressOut}
       onFocus={onFocus}
-      onBlur={onBlur}>
-      <Text
-        label={href}
-        {...rest}
-        style={[
-          {color: t.palette.primary_500},
-          (hovered || focused || pressed) && {
-            outline: 0,
-            textDecorationLine: 'underline',
-            textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
-          },
-          flattenedStyle,
-        ]}
-        role="link"
-        onMouseEnter={onHoverIn}
-        onMouseLeave={onHoverOut}
-        accessibilityRole="link"
-        href={href}
-        {...web({
-          hrefAttrs: {
-            target: download ? undefined : isExternal ? 'blank' : undefined,
-            rel: isExternal ? 'noopener noreferrer' : undefined,
-            download,
-          },
-          dataSet: {
-            // default to no underline, apply this ourselves
-            noUnderline: '1',
-          },
-        })}>
-        {children}
-      </Text>
-    </TouchableWithoutFeedback>
+      onBlur={onBlur}
+      onMouseEnter={onHoverIn}
+      onMouseLeave={onHoverOut}
+      accessibilityRole="link"
+      href={href}
+      {...web({
+        hrefAttrs: {
+          target: download ? undefined : isExternal ? 'blank' : undefined,
+          rel: isExternal ? 'noopener noreferrer' : undefined,
+          download,
+        },
+        dataSet: {
+          // default to no underline, apply this ourselves
+          noUnderline: '1',
+        },
+      })}>
+      {children}
+    </Text>
   )
 }
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 2c79d27cf..411679102 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -41,7 +41,7 @@ export function Outer({
         <Dialog.Inner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={{width: 'auto', maxWidth: 400}}>
+          style={[{width: 'auto', maxWidth: 400}]}>
           {children}
         </Dialog.Inner>
       </Context.Provider>
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 068ee99e0..c72fcabdd 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
 import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
 
-import {atoms as a, TextStyleProp} from '#/alf'
+import {atoms as a, TextStyleProp, flatten} from '#/alf'
 import {InlineLink} from '#/components/Link'
-import {Text} from '#/components/Typography'
+import {Text, TextProps} from '#/components/Typography'
 import {toShortUrl} from 'lib/strings/url-helpers'
 import {getAgent} from '#/state/session'
 
@@ -16,18 +16,20 @@ export function RichText({
   numberOfLines,
   disableLinks,
   resolveFacets = false,
-}: TextStyleProp & {
-  value: RichTextAPI | string
-  testID?: string
-  numberOfLines?: number
-  disableLinks?: boolean
-  resolveFacets?: boolean
-}) {
+  selectable,
+}: TextStyleProp &
+  Pick<TextProps, 'selectable'> & {
+    value: RichTextAPI | string
+    testID?: string
+    numberOfLines?: number
+    disableLinks?: boolean
+    resolveFacets?: boolean
+  }) {
   const detected = React.useRef(false)
   const [richText, setRichText] = React.useState<RichTextAPI>(() =>
     value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
   )
-  const styles = [a.leading_normal, style]
+  const styles = [a.leading_snug, flatten(style)]
 
   React.useEffect(() => {
     if (!resolveFacets) return
@@ -50,6 +52,7 @@ export function RichText({
     if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
       return (
         <Text
+          selectable={selectable}
           testID={testID}
           style={[
             {
@@ -65,6 +68,7 @@ export function RichText({
     }
     return (
       <Text
+        selectable={selectable}
         testID={testID}
         style={styles}
         numberOfLines={numberOfLines}
@@ -88,6 +92,7 @@ export function RichText({
     ) {
       els.push(
         <InlineLink
+          selectable={selectable}
           key={key}
           to={`/profile/${mention.did}`}
           style={[...styles, {pointerEvents: 'auto'}]}
@@ -102,6 +107,7 @@ export function RichText({
       } else {
         els.push(
           <InlineLink
+            selectable={selectable}
             key={key}
             to={link.uri}
             style={[...styles, {pointerEvents: 'auto'}]}
@@ -120,6 +126,7 @@ export function RichText({
 
   return (
     <Text
+      selectable={selectable}
       testID={testID}
       style={styles}
       numberOfLines={numberOfLines}
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index b34f51018..c9ab7a8a1 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,7 +1,16 @@
 import React from 'react'
-import {Text as RNText, TextStyle, TextProps} from 'react-native'
+import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
+import {UITextView} from 'react-native-ui-text-view'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
+import {isIOS} from '#/platform/detection'
+
+export type TextProps = RNTextProps & {
+  /**
+   * Lets the user select text, to use the native copy and paste functionality.
+   */
+  selectable?: boolean
+}
 
 /**
  * Util to calculate lineHeight from a text size atom and a leading atom
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
 /**
  * Our main text component. Use this most of the time.
  */
-export function Text({style, ...rest}: TextProps) {
+export function Text({style, selectable, ...rest}: TextProps) {
   const t = useTheme()
   const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
-  return <RNText style={s} {...rest} />
+  return selectable && isIOS ? (
+    <UITextView style={s} {...rest} />
+  ) : (
+    <RNText selectable={selectable} style={s} {...rest} />
+  )
 }
 
 export function createHeadingElement({level}: {level: number}) {
   return function HeadingElement({style, ...rest}: TextProps) {
-    const t = useTheme()
     const attr =
       web({
         role: 'heading',
         'aria-level': level,
       }) || {}
-    return (
-      <RNText
-        {...attr}
-        {...rest}
-        style={normalizeTextStyles([t.atoms.text, flatten(style)])}
-      />
-    )
+    return <Text {...attr} {...rest} style={style} />
   }
 }
 
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
 export const H5 = createHeadingElement({level: 5})
 export const H6 = createHeadingElement({level: 6})
 export function P({style, ...rest}: TextProps) {
-  const t = useTheme()
   const attr =
     web({
       role: 'paragraph',
     }) || {}
   return (
-    <RNText
+    <Text
       {...attr}
       {...rest}
-      style={normalizeTextStyles([
-        atoms.text_md,
-        atoms.leading_normal,
-        t.atoms.text,
-        flatten(style),
-      ])}
+      style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
     />
   )
 }
diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx
new file mode 100644
index 000000000..678ac3fcb
--- /dev/null
+++ b/src/components/icons/Times.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z',
+})