about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/lightbox/Image.tsx1
-rw-r--r--src/view/com/lightbox/Images.tsx35
-rw-r--r--src/view/com/lightbox/Lightbox.tsx68
-rw-r--r--src/view/com/util/PostEmbeds.tsx19
-rw-r--r--src/view/com/util/gestures/HorzSwipe.tsx5
-rw-r--r--src/view/com/util/gestures/Swipe.tsx232
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx15
7 files changed, 339 insertions, 36 deletions
diff --git a/src/view/com/lightbox/Image.tsx b/src/view/com/lightbox/Image.tsx
index 2e3307774..a620e949e 100644
--- a/src/view/com/lightbox/Image.tsx
+++ b/src/view/com/lightbox/Image.tsx
@@ -4,7 +4,6 @@ import {Image, StyleSheet, useWindowDimensions, View} from 'react-native'
 export function Component({uri}: {uri: string}) {
   const winDim = useWindowDimensions()
   const top = winDim.height / 2 - (winDim.width - 40) / 2 - 100
-  console.log(uri)
   return (
     <View style={[styles.container, {top}]}>
       <Image style={styles.image} source={{uri}} />
diff --git a/src/view/com/lightbox/Images.tsx b/src/view/com/lightbox/Images.tsx
new file mode 100644
index 000000000..6f84dfe7c
--- /dev/null
+++ b/src/view/com/lightbox/Images.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import {Image, StyleSheet, useWindowDimensions, View} from 'react-native'
+
+export function Component({uris, index}: {uris: string[]; index: number}) {
+  const winDim = useWindowDimensions()
+  const left = index * winDim.width * -1
+  return (
+    <View style={[styles.container, {left}]}>
+      {uris.map((uri, i) => (
+        <Image
+          key={i}
+          style={[styles.image, {left: i * winDim.width}]}
+          source={{uri}}
+        />
+      ))}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    width: '100%',
+  },
+  image: {
+    position: 'absolute',
+    top: 200,
+    left: 0,
+    resizeMode: 'contain',
+    width: '100%',
+    aspectRatio: 1,
+  },
+})
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 198f20391..f6c89b69b 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,20 +1,41 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {
+  Animated,
+  StyleSheet,
+  TouchableWithoutFeedback,
+  useWindowDimensions,
+  View,
+} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Swipe, Dir} from '../util/gestures/Swipe'
 import {useStores} from '../../../state'
+import {useAnimatedValue} from '../../lib/useAnimatedValue'
 
 import * as models from '../../../state/models/shell-ui'
 
 import * as ProfileImageLightbox from './ProfileImage'
 import * as ImageLightbox from './Image'
+import * as ImagesLightbox from './Images'
 
 export const Lightbox = observer(function Lightbox() {
   const store = useStores()
+  const winDim = useWindowDimensions()
+  const panX = useAnimatedValue(0)
+  const panY = useAnimatedValue(0)
 
   const onClose = () => {
     store.shell.closeLightbox()
   }
+  const onSwipeEnd = (dir: Dir) => {
+    if (dir === Dir.Up || dir === Dir.Down) {
+      onClose()
+    } else if (dir === Dir.Left) {
+      store.shell.activeLightbox?.onSwipeLeft()
+    } else if (dir === Dir.Right) {
+      store.shell.activeLightbox?.onSwipeRight()
+    }
+  }
 
   if (!store.shell.isLightboxActive) {
     return <View />
@@ -33,18 +54,49 @@ export const Lightbox = observer(function Lightbox() {
         {...(store.shell.activeLightbox as models.ImageLightbox)}
       />
     )
+  } else if (store.shell.activeLightbox?.name === 'images') {
+    element = (
+      <ImagesLightbox.Component
+        {...(store.shell.activeLightbox as models.ImagesLightbox)}
+      />
+    )
   } else {
     return <View />
   }
 
+  const translateX = Animated.multiply(panX, winDim.width * -1)
+  const translateY = Animated.multiply(panY, winDim.height * -1)
+  const swipeTransform = {transform: [{translateX}, {translateY}]}
+  const swipeOpacity = {
+    opacity: panY.interpolate({
+      inputRange: [-1, 0, 1],
+      outputRange: [0, 1, 0],
+    }),
+  }
+
   return (
-    <>
-      <TouchableOpacity style={styles.bg} onPress={onClose} />
-      <TouchableOpacity style={styles.xIcon} onPress={onClose}>
-        <FontAwesomeIcon icon="x" size={24} style={{color: '#fff'}} />
-      </TouchableOpacity>
-      {element}
-    </>
+    <View style={StyleSheet.absoluteFill}>
+      <Swipe
+        panX={panX}
+        panY={panY}
+        swipeEnabled
+        canSwipeLeft={store.shell.activeLightbox.canSwipeLeft}
+        canSwipeRight={store.shell.activeLightbox.canSwipeRight}
+        canSwipeUp
+        canSwipeDown
+        hasPriority
+        onSwipeEnd={onSwipeEnd}>
+        <TouchableWithoutFeedback onPress={onClose}>
+          <Animated.View style={[styles.bg, swipeOpacity]} />
+        </TouchableWithoutFeedback>
+        <TouchableWithoutFeedback onPress={onClose}>
+          <View style={styles.xIcon}>
+            <FontAwesomeIcon icon="x" size={24} style={{color: '#fff'}} />
+          </View>
+        </TouchableWithoutFeedback>
+        <Animated.View style={swipeTransform}>{element}</Animated.View>
+      </Swipe>
+    </View>
   )
 })
 
diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx
index 5e886409e..ae9212a72 100644
--- a/src/view/com/util/PostEmbeds.tsx
+++ b/src/view/com/util/PostEmbeds.tsx
@@ -1,25 +1,19 @@
-import React, {useEffect, useState} from 'react'
+import React from 'react'
 import {
-  ActivityIndicator,
-  Image,
   ImageStyle,
   StyleSheet,
   StyleProp,
   Text,
-  TouchableWithoutFeedback,
   View,
   ViewStyle,
 } from 'react-native'
-import {
-  Record as PostRecord,
-  Entity,
-} from '../../../third-party/api/src/client/types/app/bsky/feed/post'
 import * as AppBskyEmbedImages from '../../../third-party/api/src/client/types/app/bsky/embed/images'
 import * as AppBskyEmbedExternal from '../../../third-party/api/src/client/types/app/bsky/embed/external'
 import {Link} from '../util/Link'
-import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta'
 import {colors} from '../../lib/styles'
 import {AutoSizedImage} from './images/AutoSizedImage'
+import {ImagesLightbox} from '../../../state/models/shell-ui'
+import {useStores} from '../../../state'
 
 type Embed =
   | AppBskyEmbedImages.Presented
@@ -33,14 +27,19 @@ export function PostEmbeds({
   embed?: Embed
   style?: StyleProp<ViewStyle>
 }) {
+  const store = useStores()
   if (embed?.$type === 'app.bsky.embed.images#presented') {
     const imgEmbed = embed as AppBskyEmbedImages.Presented
     if (imgEmbed.images.length > 0) {
+      const uris = imgEmbed.images.map(img => img.fullsize)
+      const openLightbox = (index: number) => {
+        store.shell.openLightbox(new ImagesLightbox(uris, index))
+      }
       const Thumb = ({i, style}: {i: number; style: StyleProp<ImageStyle>}) => (
         <AutoSizedImage
           style={style}
           uri={imgEmbed.images[i].thumb}
-          fullSizeUri={imgEmbed.images[i].fullsize}
+          onPress={() => openLightbox(i)}
         />
       )
       if (imgEmbed.images.length === 4) {
diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx
index 8caa3dea8..6dcdcf918 100644
--- a/src/view/com/util/gestures/HorzSwipe.tsx
+++ b/src/view/com/util/gestures/HorzSwipe.tsx
@@ -72,11 +72,6 @@ export function HorzSwipe({
     setDir(0)
     onSwipeStart?.()
 
-    // TODO
-    // if (keyboardDismissMode === 'on-drag') {
-    //   Keyboard.dismiss()
-    // }
-
     panX.stopAnimation()
     // @ts-expect-error: _value is private, but docs use it as well
     panX.setOffset(panX._value)
diff --git a/src/view/com/util/gestures/Swipe.tsx b/src/view/com/util/gestures/Swipe.tsx
new file mode 100644
index 000000000..f6d600d02
--- /dev/null
+++ b/src/view/com/util/gestures/Swipe.tsx
@@ -0,0 +1,232 @@
+import React, {useState} from 'react'
+import {
+  Animated,
+  GestureResponderEvent,
+  I18nManager,
+  PanResponder,
+  PanResponderGestureState,
+  useWindowDimensions,
+  View,
+} from 'react-native'
+import {clamp} from 'lodash'
+
+export enum Dir {
+  None,
+  Up,
+  Down,
+  Left,
+  Right,
+}
+
+interface Props {
+  panX: Animated.Value
+  panY: Animated.Value
+  canSwipeLeft?: boolean
+  canSwipeRight?: boolean
+  canSwipeUp?: boolean
+  canSwipeDown?: boolean
+  swipeEnabled?: boolean
+  hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture
+  horzDistThresholdDivisor?: number
+  vertDistThresholdDivisor?: number
+  useNativeDriver?: boolean
+  onSwipeStart?: () => void
+  onSwipeStartDirection?: (dir: Dir) => void
+  onSwipeEnd?: (dir: Dir) => void
+  children: React.ReactNode
+}
+
+export function Swipe({
+  panX,
+  panY,
+  canSwipeLeft = false,
+  canSwipeRight = false,
+  canSwipeUp = false,
+  canSwipeDown = false,
+  swipeEnabled = true,
+  hasPriority = false,
+  horzDistThresholdDivisor = 1.75,
+  vertDistThresholdDivisor = 1.75,
+  useNativeDriver = false,
+  onSwipeStart,
+  onSwipeStartDirection,
+  onSwipeEnd,
+  children,
+}: Props) {
+  const winDim = useWindowDimensions()
+  const [dir, setDir] = useState<Dir>(Dir.None)
+
+  const swipeVelocityThreshold = 35
+  const swipeHorzDistanceThreshold = winDim.width / horzDistThresholdDivisor
+  const swipeVertDistanceThreshold = winDim.height / vertDistThresholdDivisor
+
+  const isMovingHorizontally = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    return (
+      Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) &&
+      Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25)
+    )
+  }
+  const isMovingVertically = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    return (
+      Math.abs(gestureState.dy) > Math.abs(gestureState.dx * 1.25) &&
+      Math.abs(gestureState.vy) > Math.abs(gestureState.vx * 1.25)
+    )
+  }
+
+  const canDir = (d: Dir) => {
+    if (d === Dir.Left) return canSwipeLeft
+    if (d === Dir.Right) return canSwipeRight
+    if (d === Dir.Up) return canSwipeUp
+    if (d === Dir.Down) return canSwipeDown
+    return false
+  }
+  const isHorz = (d: Dir) => d === Dir.Left || d === Dir.Right
+  const isVert = (d: Dir) => d === Dir.Up || d === Dir.Down
+
+  const canMoveScreen = (
+    event: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    if (swipeEnabled === false) {
+      return false
+    }
+
+    const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
+    const dy = gestureState.dy
+    const willHandle =
+      (isMovingHorizontally(event, gestureState) &&
+        ((dx > 0 && canSwipeLeft) || (dx < 0 && canSwipeRight))) ||
+      (isMovingVertically(event, gestureState) &&
+        ((dy > 0 && canSwipeUp) || (dy < 0 && canSwipeDown)))
+    return willHandle
+  }
+
+  const startGesture = () => {
+    setDir(Dir.None)
+    onSwipeStart?.()
+
+    panX.stopAnimation()
+    // @ts-expect-error: _value is private, but docs use it as well
+    panX.setOffset(panX._value)
+    panY.stopAnimation()
+    // @ts-expect-error: _value is private, but docs use it as well
+    panY.setOffset(panY._value)
+  }
+
+  const respondToGesture = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
+    const dy = gestureState.dy
+
+    let newDir = Dir.None
+    if (dir === Dir.None) {
+      // establish if the user is swiping horz or vert
+      if (Math.abs(dx) > Math.abs(dy)) {
+        newDir = dx > 0 ? Dir.Left : Dir.Right
+      } else {
+        newDir = dy > 0 ? Dir.Up : Dir.Down
+      }
+    } else if (isHorz(dir)) {
+      // direction update
+      newDir = dx > 0 ? Dir.Left : Dir.Right
+    } else if (isVert(dir)) {
+      // direction update
+      newDir = dy > 0 ? Dir.Up : Dir.Down
+    }
+
+    if (isHorz(newDir)) {
+      panX.setValue(
+        clamp(
+          dx / swipeHorzDistanceThreshold,
+          canSwipeRight ? -1 : 0,
+          canSwipeLeft ? 1 : 0,
+        ) * -1,
+      )
+      panY.setValue(0)
+    } else if (isVert(newDir)) {
+      panY.setValue(
+        clamp(
+          dy / swipeVertDistanceThreshold,
+          canSwipeDown ? -1 : 0,
+          canSwipeUp ? 1 : 0,
+        ) * -1,
+      )
+      panX.setValue(0)
+    }
+
+    if (!canDir(newDir)) {
+      newDir = Dir.None
+    }
+    if (newDir !== dir) {
+      setDir(newDir)
+      onSwipeStartDirection?.(newDir)
+    }
+  }
+
+  const finishGesture = (
+    _: GestureResponderEvent,
+    gestureState: PanResponderGestureState,
+  ) => {
+    const finish = (finalDir: dir) => () => {
+      if (finalDir !== Dir.None) {
+        onSwipeEnd?.(finalDir)
+      }
+      setDir(Dir.None)
+      panX.flattenOffset()
+      panX.setValue(0)
+      panY.flattenOffset()
+      panY.setValue(0)
+    }
+    if (
+      isHorz(dir) &&
+      (Math.abs(gestureState.dx) > swipeHorzDistanceThreshold / 4 ||
+        Math.abs(gestureState.vx) > swipeVelocityThreshold)
+    ) {
+      Animated.timing(panX, {
+        toValue: dir === Dir.Left ? -1 : 1,
+        duration: 100,
+        useNativeDriver,
+      }).start(finish(dir))
+    } else if (
+      isVert(dir) &&
+      (Math.abs(gestureState.dy) > swipeVertDistanceThreshold / 8 ||
+        Math.abs(gestureState.vy) > swipeVelocityThreshold)
+    ) {
+      Animated.timing(panY, {
+        toValue: dir === Dir.Up ? -1 : 1,
+        duration: 100,
+        useNativeDriver,
+      }).start(finish(dir))
+    } else {
+      onSwipeEnd?.(Dir.None)
+      Animated.timing(panX, {
+        toValue: 0,
+        duration: 100,
+        useNativeDriver,
+      }).start(finish(Dir.None))
+    }
+  }
+
+  const panResponder = PanResponder.create({
+    onMoveShouldSetPanResponder: canMoveScreen,
+    onPanResponderGrant: startGesture,
+    onPanResponderMove: respondToGesture,
+    onPanResponderTerminate: finishGesture,
+    onPanResponderRelease: finishGesture,
+    onPanResponderTerminationRequest: () => !hasPriority,
+  })
+
+  return (
+    <View {...panResponder.panHandlers} style={{flex: 1}}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 80cd0fa9a..4728f42df 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -9,8 +9,6 @@ import {
   TouchableWithoutFeedback,
   View,
 } from 'react-native'
-import {ImageLightbox} from '../../../../state/models/shell-ui'
-import {useStores} from '../../../../state'
 import {colors} from '../../../lib/styles'
 
 const MAX_HEIGHT = 300
@@ -22,14 +20,13 @@ interface Dim {
 
 export function AutoSizedImage({
   uri,
-  fullSizeUri,
+  onPress,
   style,
 }: {
   uri: string
-  fullSizeUri?: string
+  onPress?: () => void
   style: StyleProp<ImageStyle>
 }) {
-  const store = useStores()
   const [error, setError] = useState<string | undefined>()
   const [imgInfo, setImgInfo] = useState<Dim | undefined>()
   const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
@@ -74,15 +71,9 @@ export function AutoSizedImage({
     })
   }
 
-  const onPressImage = () => {
-    if (fullSizeUri) {
-      store.shell.openLightbox(new ImageLightbox(fullSizeUri))
-    }
-  }
-
   return (
     <View style={style}>
-      <TouchableWithoutFeedback onPress={onPressImage}>
+      <TouchableWithoutFeedback onPress={onPress}>
         {error ? (
           <View style={[styles.container, styles.errorContainer]}>
             <Text style={styles.error}>{error}</Text>