diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt index 049f88542..820c7326a 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt @@ -14,6 +14,7 @@ import expo.modules.ReactActivityDelegateWrapper class MainActivity : NavigationActivity() { private var HWKeyboardConnected = false private val foldableObserver = FoldableObserver.getInstance(this) + private var lastOrientation: Int = Configuration.ORIENTATION_UNDEFINED /** * Returns the name of the main component registered from JavaScript. This is used to schedule @@ -34,6 +35,7 @@ class MainActivity : NavigationActivity() { super.onCreate(null) setContentView(R.layout.launch_screen) setHWKeyboardConnected() + lastOrientation = this.resources.configuration.orientation foldableObserver.onCreate() } @@ -54,6 +56,11 @@ class MainActivity : NavigationActivity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + val newOrientation = newConfig.orientation + if (newOrientation != lastOrientation) { + lastOrientation = newOrientation + foldableObserver.handleWindowLayoutInfo() + } if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) { HWKeyboardConnected = true } else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) { diff --git a/app/components/files/image_file.tsx b/app/components/files/image_file.tsx index 3a12c1537..a1364bb03 100644 --- a/app/components/files/image_file.tsx +++ b/app/components/files/image_file.tsx @@ -90,7 +90,7 @@ const ImageFile = ({ setFailed(true); }, []); - const imageProps = () => { + const imageProps = useMemo(() => { const props: ProgressiveImageProps = {}; if (file.localPath) { @@ -111,7 +111,7 @@ const ImageFile = ({ } return props; - }; + }, [file, inViewPort, serverUrl]); let imageDimensions = getImageDimensions(); if (isSingleImage && (!imageDimensions || (imageDimensions?.height === 0 && imageDimensions?.width === 0))) { @@ -126,7 +126,7 @@ const ImageFile = ({ tintDefaultSource={!file.localPath && !failed} onError={handleError} contentFit={contentFit} - {...imageProps()} + {...imageProps} /> ); diff --git a/app/components/pressable_opacity/index.tsx b/app/components/pressable_opacity/index.tsx new file mode 100644 index 000000000..5aea461db --- /dev/null +++ b/app/components/pressable_opacity/index.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Pressable} from 'react-native-gesture-handler'; +import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +type Props = { + children: React.ReactNode; + onPress: () => void; + style?: StyleProp; +} + +export default function PressableOpacity({children, onPress, style}: Props) { + const cancelOpacity = useSharedValue(1); + + const cancelAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: cancelOpacity.value, + }; + }); + + const cancelPressIn = useCallback(() => { + cancelOpacity.value = withTiming(0.5, {duration: 100}); + }, []); + + const cancelPressOut = useCallback(() => { + cancelOpacity.value = withTiming(1, {duration: 100}); + }, []); + + return ( + + {children} + + ); +} diff --git a/app/components/progress_bar/index.tsx b/app/components/progress_bar/index.tsx index cccdc157c..15df066b7 100644 --- a/app/components/progress_bar/index.tsx +++ b/app/components/progress_bar/index.tsx @@ -50,7 +50,6 @@ const ProgressBar = ({color, containerStyle, progress, withCursor, style, onSeek const progressValue = useSharedValue(progress); const isGestureActive = useSharedValue(false); - // eslint-disable-next-line new-cap const panGesture = Gesture.Pan(). onStart(() => { isGestureActive.value = true; @@ -68,7 +67,6 @@ const ProgressBar = ({color, containerStyle, progress, withCursor, style, onSeek isGestureActive.value = false; }); - // eslint-disable-next-line new-cap const tapGesture = Gesture.Tap(). onStart(() => { isGestureActive.value = true; @@ -84,7 +82,6 @@ const ProgressBar = ({color, containerStyle, progress, withCursor, style, onSeek isGestureActive.value = false; }); - // eslint-disable-next-line new-cap const composedGestures = Gesture.Race(tapGesture, panGesture); const progressAnimatedStyle = useAnimatedStyle(() => { diff --git a/app/constants/gallery.ts b/app/constants/gallery.ts index 202979815..c780fb0d2 100644 --- a/app/constants/gallery.ts +++ b/app/constants/gallery.ts @@ -3,3 +3,12 @@ export const GALLERY_FOOTER_HEIGHT = 75; export const VIDEO_INSET = 100; +export const ANDROID_VIDEO_INSET = 20; + +export const DOUBLE_TAP_SCALE = 4; +export const MAX_SCALE = 7; +export const MIN_SCALE = 0.7; +export const OVER_SCALE = 0.5; + +export const MIN_VELOCITY = 700; +export const MAX_VELOCITY = 3000; diff --git a/app/context/gallery/index.tsx b/app/context/gallery/index.tsx index df99e4098..aedf6334f 100644 --- a/app/context/gallery/index.tsx +++ b/app/context/gallery/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {useEffect, useLayoutEffect} from 'react'; -import Animated, {makeMutable, runOnUI, type AnimatedRef} from 'react-native-reanimated'; +import {makeMutable, runOnUI, type AnimatedRef, type SharedValue} from 'react-native-reanimated'; import type {GalleryManagerSharedValues} from '@typings/screens/gallery'; @@ -24,7 +24,7 @@ class Gallery { private init = false; private timeout: NodeJS.Timeout | null = null; - public refsByIndexSV: Animated.SharedValue = makeMutable({}); + public refsByIndexSV: SharedValue = makeMutable({}); public sharedValues: GalleryManagerSharedValues = { width: makeMutable(0), @@ -35,6 +35,7 @@ class Gallery { activeIndex: makeMutable(0), targetWidth: makeMutable(0), targetHeight: makeMutable(0), + scale: makeMutable(1), }; public items = new Map(); diff --git a/app/hooks/gallery.test.ts b/app/hooks/gallery.test.ts index a2a0c2c73..e4e49e9a2 100644 --- a/app/hooks/gallery.test.ts +++ b/app/hooks/gallery.test.ts @@ -6,7 +6,7 @@ import {useSharedValue} from 'react-native-reanimated'; import {useGallery} from '@context/gallery'; -import {useGalleryControls, useGalleryItem, diff, useCreateAnimatedGestureHandler} from './gallery'; +import {useGalleryControls, useGalleryItem, diff} from './gallery'; jest.mock('react-native-reanimated', () => ({ Easing: { @@ -56,289 +56,31 @@ describe('gallery hooks', () => { expect(context.___diffs[name].prev).toBe(value); }); - describe('useCreateAnimatedGestureHandler', () => { - const handlers = { - onInit: jest.fn(), - onGesture: jest.fn(), - shouldHandleEvent: jest.fn(), - onEvent: jest.fn(), - shouldCancel: jest.fn(), - onEnd: jest.fn(), - onStart: jest.fn(), - onActive: jest.fn(), - onFail: jest.fn(), - onCancel: jest.fn(), - onFinish: jest.fn(), - beforeEach: jest.fn(), - afterEach: jest.fn(), - }; - - test('iOS and state is active', () => { - const context = {__initialized: false, _shouldSkip: false} as any; - - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - expect(handler).toBeInstanceOf(Function); - - const event = { - nativeEvent: { - state: 4, - oldState: 1, - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.onInit).toHaveBeenCalled(); - expect(handlers.onGesture).toHaveBeenCalled(); - expect(handlers.shouldHandleEvent).not.toHaveBeenCalled(); - expect(handlers.onEvent).toHaveBeenCalled(); - expect(handlers.shouldCancel).toHaveBeenCalled(); - expect(handlers.onEnd).not.toHaveBeenCalled(); - expect(handlers.onStart).not.toHaveBeenCalled(); - expect(handlers.onActive).toHaveBeenCalled(); - expect(handlers.onFail).not.toHaveBeenCalled(); - expect(handlers.onCancel).not.toHaveBeenCalled(); - expect(handlers.onFinish).not.toHaveBeenCalled(); - expect(handlers.beforeEach).toHaveBeenCalled(); - expect(handlers.afterEach).toHaveBeenCalled(); - - // Check the expected values of context - expect(context.__initialized).toBe(true); - expect(context._shouldSkip).toBe(false); - }); - - test('State is end, oldState is active', () => { - const context = {__initialized: false, _shouldSkip: false} as any; - - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - expect(handler).toBeInstanceOf(Function); - - const event = { - nativeEvent: { - state: 5, - oldState: 4, - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.onEnd).toHaveBeenCalled(); - - // Check the expected values of context - expect(context.__initialized).toBe(true); - expect(context._shouldCancel).toBeUndefined(); - expect(context._shouldSkip).toBeUndefined(); - }); - - test('State is began, oldState is active, velocity non-zero', () => { - const context = {__initialized: false, _shouldSkip: false} as any; - - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - expect(handler).toBeInstanceOf(Function); - - const event = { - nativeEvent: { - state: 2, - oldState: 4, - velocityX: 1, - velocityY: 1, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.shouldHandleEvent).toHaveBeenCalled(); - - // Check the expected values of context - expect(context.__initialized).toBe(true); - expect(context._shouldCancel).toBeUndefined(); - expect(context._shouldSkip).toBeUndefined(); - }); - - test('should skip handling event if shouldHandleEvent returns false', () => { - const context = {__initialized: false, _shouldSkip: undefined} as any; - - handlers.shouldHandleEvent.mockReturnValueOnce(false); - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - expect(handler).toBeInstanceOf(Function); - - const event = { - nativeEvent: { - state: 2, - velocityX: 1, - velocityY: 1, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.shouldHandleEvent).toHaveBeenCalled(); - expect(context._shouldSkip).toBe(true); - }); - - test('handles shouldCancel returning true', () => { - const context = {__initialized: true, _shouldSkip: false} as any; - handlers.shouldCancel.mockReturnValueOnce(true); - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - const event = { - nativeEvent: { - state: 4, // ACTIVE - oldState: 2, // BEGAN - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.shouldCancel).toHaveBeenCalled(); - expect(handlers.onEnd).toHaveBeenCalledWith( - event.nativeEvent, - context, - true, // cancelled = true - ); - expect(handlers.onActive).not.toHaveBeenCalled(); - }); - - test('skips event handling when context._shouldSkip is undefined', () => { - const context = {__initialized: true} as any; - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - const event = { - nativeEvent: { - state: 4, // ACTIVE - oldState: 3, // CANCELLED - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.onEvent).not.toHaveBeenCalled(); - expect(handlers.onActive).not.toHaveBeenCalled(); - }); - - test('handles CANCELLED state when oldState is ACTIVE', () => { - const context = {__initialized: true, _shouldSkip: false} as any; - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - const event = { - nativeEvent: { - state: 3, // CANCELLED - oldState: 4, // ACTIVE - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.onCancel).toHaveBeenCalled(); - expect(handlers.onFinish).toHaveBeenCalledWith( - event.nativeEvent, - context, - true, // cancelled = true - ); - }); - - test('handles FAILED state when oldState is ACTIVE', () => { - const context = {__initialized: true, _shouldSkip: false} as any; - (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: context})); - - const {result} = renderHook(() => useCreateAnimatedGestureHandler(handlers)); - const handler = result.current; - - const event = { - nativeEvent: { - state: 1, // FAILED - oldState: 4, // ACTIVE - velocityX: 0, - velocityY: 0, - }, - }; - - act(() => { - handler(event.nativeEvent as any); - }); - - expect(handlers.onFail).toHaveBeenCalled(); - expect(handlers.onFinish).toHaveBeenCalledWith( - event.nativeEvent, - context, - true, // failed = true - ); - }); - }); - test('useGalleryControls', () => { (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: false})); const {result} = renderHook(() => useGalleryControls()); act(() => { - result.current.setControlsHidden(true); + result.current.hideHeaderAndFooter(true); }); - expect(result.current.controlsHidden.value).toBe(true); + expect(result.current.headerAndFooterHidden.value).toBe(true); act(() => { - result.current.setControlsHidden(false); + result.current.hideHeaderAndFooter(false); }); - expect(result.current.controlsHidden.value).toBe(false); + expect(result.current.headerAndFooterHidden.value).toBe(false); }); - test('useGalleryControls does not update controlsHidden when same value is set', () => { + test('useGalleryControls does not update headerAndFooterHidden when same value is set', () => { const mockWithTiming = jest.fn(); (useSharedValue as jest.Mock).mockImplementationOnce(() => ({value: true})); const {result} = renderHook(() => useGalleryControls()); act(() => { - result.current.setControlsHidden(true); + result.current.hideHeaderAndFooter(true); }); expect(mockWithTiming).not.toHaveBeenCalled(); diff --git a/app/hooks/gallery.ts b/app/hooks/gallery.ts index 5d2ede247..ff444aa9b 100644 --- a/app/hooks/gallery.ts +++ b/app/hooks/gallery.ts @@ -2,18 +2,14 @@ // See LICENSE.txt for license information. import {useCallback, useEffect} from 'react'; -import {Platform} from 'react-native'; import { - Easing, runOnJS, useAnimatedRef, useAnimatedStyle, useEvent, + Easing, runOnJS, useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming, type WithTimingConfig, } from 'react-native-reanimated'; import {useGallery} from '@context/gallery'; -import type {Context, GestureHandlers, OnGestureEvent} from '@typings/screens/gallery'; -import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler'; - export function diff(context: any, name: string, value: any) { 'worklet'; @@ -36,136 +32,19 @@ export function diff(context: any, name: string, value: any) { return d.stash; } -export function useCreateAnimatedGestureHandler(handlers: GestureHandlers) { - const sharedContext = useSharedValue({ - __initialized: false, - }); - - const isAndroid = Platform.OS === 'android'; - - const handler = (event: T['nativeEvent']) => { - 'worklet'; - - const FAILED = 1; - const BEGAN = 2; - const CANCELLED = 3; - const ACTIVE = 4; - const END = 5; - - const context = sharedContext.value; - if (handlers.onInit && !context.__initialized) { - context.__initialized = true; - handlers.onInit(event, context); - } - - if (handlers.onGesture) { - handlers.onGesture(event, context); - } - - const stateDiff = diff(context, 'pinchState', event.state); - - const pinchBeganAndroid = stateDiff === ACTIVE - BEGAN ? event.state === ACTIVE : false; - - const isBegan = isAndroid ? pinchBeganAndroid : (event.state === BEGAN || event.oldState === BEGAN) && - (event.velocityX !== 0 || event.velocityY !== 0); - - if (isBegan) { - if (handlers.shouldHandleEvent) { - context._shouldSkip = !handlers.shouldHandleEvent(event, context); - } else { - context._shouldSkip = false; - } - } else if (typeof context._shouldSkip === 'undefined') { - return; - } - - if (!context._shouldSkip && !context._shouldCancel) { - if (handlers.onEvent) { - handlers.onEvent(event, context); - } - - if (handlers.shouldCancel) { - context._shouldCancel = handlers.shouldCancel(event, context); - - if (context._shouldCancel) { - if (handlers.onEnd) { - handlers.onEnd(event, context, true); - } - return; - } - } - - if (handlers.beforeEach) { - handlers.beforeEach(event, context); - } - - if (isBegan && handlers.onStart) { - handlers.onStart(event, context); - } - - if (event.state === ACTIVE && handlers.onActive) { - handlers.onActive(event, context); - } - if (event.oldState === ACTIVE && event.state === END && handlers.onEnd) { - handlers.onEnd(event, context, false); - } - if (event.oldState === ACTIVE && event.state === FAILED && handlers.onFail) { - handlers.onFail(event, context); - } - if (event.oldState === ACTIVE && event.state === CANCELLED && handlers.onCancel) { - handlers.onCancel(event, context); - } - if (event.oldState === ACTIVE) { - if (handlers.onFinish) { - handlers.onFinish( - event, - context, - event.state === CANCELLED || event.state === FAILED, - ); - } - } - - if (handlers.afterEach) { - handlers.afterEach(event, context); - } - } - - // clean up context - if (event.oldState === ACTIVE) { - context._shouldSkip = undefined; - context._shouldCancel = undefined; - } - }; - - return handler; -} - -export function useAnimatedGestureHandler( - handlers: GestureHandlers, -): OnGestureEvent { - const handler = useCallback( - useCreateAnimatedGestureHandler(handlers), - [], - ); - - return useEvent( - handler, ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], false, - ); -} +export const translateYConfig: WithTimingConfig = { + duration: 400, + easing: Easing.bezier(0.33, 0.01, 0, 1), +}; export function useGalleryControls() { - const controlsHidden = useSharedValue(false); - - const translateYConfig: WithTimingConfig = { - duration: 400, - easing: Easing.bezier(0.33, 0.01, 0, 1), - }; + const headerAndFooterHidden = useSharedValue(false); const headerStyles = useAnimatedStyle(() => ({ - opacity: controlsHidden.value ? withTiming(0) : withTiming(1), + opacity: headerAndFooterHidden.value ? withTiming(0) : withTiming(1), transform: [ { - translateY: controlsHidden.value ? withTiming(-100, translateYConfig) : withTiming(0, translateYConfig), + translateY: headerAndFooterHidden.value ? withTiming(-100, translateYConfig) : withTiming(0, translateYConfig), }, ], position: 'absolute', @@ -175,10 +54,10 @@ export function useGalleryControls() { })); const footerStyles = useAnimatedStyle(() => ({ - opacity: controlsHidden.value ? withTiming(0) : withTiming(1), + opacity: headerAndFooterHidden.value ? withTiming(0) : withTiming(1), transform: [ { - translateY: controlsHidden.value ? withTiming(100, translateYConfig) : withTiming(0, translateYConfig), + translateY: headerAndFooterHidden.value ? withTiming(100, translateYConfig) : withTiming(0, translateYConfig), }, ], position: 'absolute', @@ -187,21 +66,27 @@ export function useGalleryControls() { zIndex: 1, })); - const setControlsHidden = useCallback((hidden: boolean) => { + const hideHeaderAndFooter = useCallback((hidden?: boolean) => { 'worklet'; - if (controlsHidden.value === hidden) { + if (hidden == null) { + // if we don't pass hidden, then we toggle the current value + headerAndFooterHidden.value = !headerAndFooterHidden.value; return; } - controlsHidden.value = hidden; + if (headerAndFooterHidden.value === hidden) { + return; + } + + headerAndFooterHidden.value = hidden; }, []); return { - controlsHidden, + headerAndFooterHidden, headerStyles, footerStyles, - setControlsHidden, + hideHeaderAndFooter, }; } diff --git a/app/products/calls/context.ts b/app/products/calls/context.ts deleted file mode 100644 index 3453e1ed5..000000000 --- a/app/products/calls/context.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {createContext} from 'react'; - -export const CaptionsEnabledContext = createContext([]); diff --git a/app/screens/gallery/animation_config/spring.ts b/app/screens/gallery/animation_config/spring.ts new file mode 100644 index 000000000..a5cafa53c --- /dev/null +++ b/app/screens/gallery/animation_config/spring.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {GestureStateChangeEvent, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; +import type {WithSpringConfig} from 'react-native-reanimated'; + +export const pagerPanSpringConfig = (evt: GestureStateChangeEvent) => { + 'worklet'; + return { + stiffness: Math.min(500, Math.max(100, Math.abs(evt.velocityX) * 10)), // 🔥 More velocity = less stiffness + damping: Math.max(20, Math.abs(evt.velocityX) * 0.2), // 🔥 More velocity = less damping + mass: 1.5, // 🔥 A little extra mass for smooth movement + overshootClamping: true, // 🔥 Prevents bounce + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, + }; +}; + +export function pagerSpringVelocityConfig(velocity: number): WithSpringConfig { + 'worklet'; + + const ratio = 1.1; + const mass = 0.4; + const stiffness = 100.0; + + return { + stiffness, + mass, + damping: ratio * 2.0 * Math.sqrt(mass * stiffness), + restDisplacementThreshold: 1, + restSpeedThreshold: 5, + velocity, + }; +} + +export const transformerSpringConfig: WithSpringConfig = { + stiffness: 200, + damping: 25, + mass: 1, + overshootClamping: true, + restDisplacementThreshold: 0.00001, + restSpeedThreshold: 0.00001, +}; diff --git a/app/screens/gallery/animation_config/timing.ts b/app/screens/gallery/animation_config/timing.ts new file mode 100644 index 000000000..91003d68a --- /dev/null +++ b/app/screens/gallery/animation_config/timing.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Easing, type WithTimingConfig} from 'react-native-reanimated'; + +export const transformerTimingConfig: WithTimingConfig = { + duration: 250, + easing: Easing.bezier(0.33, 0.01, 0, 1), +}; + +export const pagerTimingConfig: WithTimingConfig = { + duration: 250, + easing: Easing.bezier(0.5002, 0.2902, 0.3214, 0.9962), +}; diff --git a/app/screens/gallery/footer/actions/index.tsx b/app/screens/gallery/footer/actions/index.tsx index 6b86d2f8b..290e87ec0 100644 --- a/app/screens/gallery/footer/actions/index.tsx +++ b/app/screens/gallery/footer/actions/index.tsx @@ -5,8 +5,6 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import React from 'react'; import {StyleSheet, View} from 'react-native'; -import InvertedAction from '@screens/gallery/footer/actions/inverted_action'; - import Action from './action'; type Props = { @@ -17,9 +15,6 @@ type Props = { onCopyPublicLink: () => void; onDownload: () => void; onShare: () => void; - hasCaptions: boolean; - captionEnabled: boolean; - onCaptionsPress: () => void; } const styles = StyleSheet.create({ @@ -32,7 +27,7 @@ const styles = StyleSheet.create({ const Actions = ({ canDownloadFiles, disabled, enablePublicLinks, fileId, onCopyPublicLink, - onDownload, onShare, hasCaptions, captionEnabled, onCaptionsPress, + onDownload, onShare, }: Props) => { const managedConfig = useManagedConfig(); const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true'; @@ -45,13 +40,6 @@ const Actions = ({ iconName='link-variant' onPress={onCopyPublicLink} />} - {hasCaptions && - - } {canDownloadFiles && <> void; - style?: StyleProp; -} - -const pressedStyle = ({pressed}: PressableStateCallbackType) => { - let opacity = 1; - if (Platform.OS === 'ios' && pressed) { - opacity = 0.5; - } - - return [{opacity}]; -}; - -const baseStyle = StyleSheet.create({ - container: { - width: 40, - height: 40, - borderRadius: 4, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#000', - }, - containerActivated: { - backgroundColor: '#fff', - }, -}); - -const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'}; - -const InvertedAction = ({activated, iconName, onPress, style}: Props) => { - const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([ - pressedStyle(pressed), - baseStyle.container, - activated && baseStyle.containerActivated, - style, - ]), [style, activated]); - - return ( - - - - ); -}; - -export default InvertedAction; diff --git a/app/screens/gallery/footer/download_with_action/index.tsx b/app/screens/gallery/footer/download_with_action/index.tsx index 142ff7bcb..7b51b1f68 100644 --- a/app/screens/gallery/footer/download_with_action/index.tsx +++ b/app/screens/gallery/footer/download_with_action/index.tsx @@ -9,7 +9,6 @@ import React, {useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Platform, StyleSheet, Text, View} from 'react-native'; import FileViewer from 'react-native-file-viewer'; -import {TouchableOpacity} from 'react-native-gesture-handler'; import {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import Share from 'react-native-share'; @@ -17,6 +16,7 @@ import Share from 'react-native-share'; import {updateLocalFilePath} from '@actions/local/file'; import {downloadFile, downloadProfileImage} from '@actions/remote/file'; import CompassIcon from '@components/compass_icon'; +import PressableOpacity from '@components/pressable_opacity'; import ProgressBar from '@components/progress_bar'; import Toast from '@components/toast'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; @@ -356,13 +356,13 @@ const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSu /> - + - + } diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx index a581c0ec8..e79aa3383 100644 --- a/app/screens/gallery/footer/footer.tsx +++ b/app/screens/gallery/footer/footer.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {DeviceEventEmitter, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; -import {SafeAreaView, type Edge, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Events} from '@constants'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; @@ -37,13 +37,8 @@ type Props = { post?: PostModel; style: StyleProp; teammateNameDisplay: string; - hasCaptions: boolean; - captionEnabled: boolean; - onCaptionsPress: () => void; } -const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); -const edges: Edge[] = ['left', 'right']; const styles = StyleSheet.create({ container: { alignItems: 'center', @@ -63,7 +58,6 @@ const Footer = ({ author, canDownloadFiles, channelName, currentUserId, enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview, hideActions, isDirectChannel, item, post, style, teammateNameDisplay, - hasCaptions, captionEnabled, onCaptionsPress, }: Props) => { const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid'); const [action, setAction] = useState('none'); @@ -106,9 +100,7 @@ const Footer = ({ }, []); return ( - {['downloading', 'sharing'].includes(action) && !enableSecureFilePreview && canDownloadFiles && @@ -149,14 +141,11 @@ const Footer = ({ onCopyPublicLink={handleCopyLink} onDownload={handleDownload} onShare={handleShare} - hasCaptions={hasCaptions} - captionEnabled={captionEnabled} - onCaptionsPress={onCaptionsPress} /> } - + ); }; diff --git a/app/screens/gallery/gallery.tsx b/app/screens/gallery/gallery.tsx index ef9cee7f8..50d7242cd 100644 --- a/app/screens/gallery/gallery.tsx +++ b/app/screens/gallery/gallery.tsx @@ -2,32 +2,35 @@ // See LICENSE.txt for license information. import {Image} from 'expo-image'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {BackHandler} from 'react-native'; -import Animated, {runOnJS, runOnUI, useAnimatedReaction} from 'react-native-reanimated'; +import Animated, {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated'; +import {buildFilePreviewUrl} from '@actions/remote/file'; import {useGallery} from '@context/gallery'; -import {freezeOtherScreens, measureItem} from '@utils/gallery'; +import {useServerUrl} from '@context/server'; +import {isGif} from '@utils/file'; +import {freezeOtherScreens, galleryItemToFileInfo, measureItem} from '@utils/gallery'; -import DocumentRenderer from './document_renderer'; import LightboxSwipeout, {type LightboxSwipeoutRef, type RenderItemInfo} from './lightbox_swipeout'; import Backdrop, {type BackdropProps} from './lightbox_swipeout/backdrop'; -import VideoRenderer from './video_renderer'; +import DocumentRenderer from './renderers/document'; +import VideoRenderer from './renderers/video'; import GalleryViewer from './viewer'; -import type {ImageRendererProps} from './image_renderer'; -import type {GalleryItemType} from '@typings/screens/gallery'; +import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery'; const AnimatedImage = Animated.createAnimatedComponent(Image); interface GalleryProps { + headerAndFooterHidden: SharedValue; galleryIdentifier: string; initialIndex: number; items: GalleryItemType[]; onIndexChange?: (index: number) => void; onHide: () => void; targetDimensions: { width: number; height: number }; - onShouldHideControls: (hide: boolean) => void; + hideHeaderAndFooter: (hide: boolean) => void; } export interface GalleryRef { @@ -35,18 +38,21 @@ export interface GalleryRef { } const Gallery = forwardRef(({ + headerAndFooterHidden, galleryIdentifier, initialIndex, items, onHide, targetDimensions, - onShouldHideControls, + hideHeaderAndFooter, onIndexChange, }: GalleryProps, ref) => { const {refsByIndexSV, sharedValues} = useGallery(galleryIdentifier); const [localIndex, setLocalIndex] = useState(initialIndex); const lightboxRef = useRef(null); const item = items[localIndex]; + const fileInfo = useMemo(() => galleryItemToFileInfo(item), [item]); + const serverUrl = useServerUrl(); const close = () => { lightboxRef.current?.closeLightbox(); @@ -89,7 +95,7 @@ const Gallery = forwardRef(({ (index) => { const galleryItems = refsByIndexSV.value; - if (index > -1 && galleryItems[index]) { + if (index > -1 && galleryItems[index] && items[index].type !== 'file') { measureItem(galleryItems[index].ref, sharedValues); } }, @@ -118,7 +124,7 @@ const Gallery = forwardRef(({ 'worklet'; if (Math.abs(translateY) > 8) { - onShouldHideControls(true); + hideHeaderAndFooter(true); } } @@ -126,10 +132,10 @@ const Gallery = forwardRef(({ 'worklet'; runOnJS(freezeOtherScreens)(true); - onShouldHideControls(false); + hideHeaderAndFooter(false); } - function hideLightboxItem() { + const hideLightboxItem = useCallback(() => { 'worklet'; sharedValues.width.value = 0; @@ -140,14 +146,15 @@ const Gallery = forwardRef(({ sharedValues.y.value = 0; runOnJS(onHide)(); - } + }, []); const onRenderItem = useCallback((info: RenderItemInfo) => { if (item.type === 'video' && item.posterUri) { return ( ); } @@ -155,7 +162,7 @@ const Gallery = forwardRef(({ return null; }, [item]); - const onRenderPage = useCallback((props: ImageRendererProps, idx: number) => { + const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => { switch (props.item.type) { case 'video': return ( @@ -163,14 +170,14 @@ const Gallery = forwardRef(({ {...props} index={idx} initialIndex={initialIndex} - onShouldHideControls={onShouldHideControls} + hideHeaderAndFooter={hideHeaderAndFooter} /> ); case 'file': return ( ); default: @@ -178,33 +185,38 @@ const Gallery = forwardRef(({ } }, []); + const source = useMemo(() => { + if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) { + return buildFilePreviewUrl(serverUrl, fileInfo.id); + } + + return fileInfo.localPath || fileInfo.uri || item.uri; + }, [fileInfo, item.uri, serverUrl]); + return ( - {({onGesture, shouldHandleEvent}) => ( - - )} + ); }); diff --git a/app/screens/gallery/header/index.tsx b/app/screens/gallery/header/index.tsx index f67787f50..ad6a726e0 100644 --- a/app/screens/gallery/header/index.tsx +++ b/app/screens/gallery/header/index.tsx @@ -2,13 +2,14 @@ // See LICENSE.txt for license information. import React, {useMemo} from 'react'; -import {type StyleProp, StyleSheet, useWindowDimensions, View, type ViewStyle} from 'react-native'; -import {TouchableOpacity} from 'react-native-gesture-handler'; +import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'; import Animated, {type AnimatedStyle} from 'react-native-reanimated'; import {SafeAreaView, type Edge, useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; +import PressableOpacity from '@components/pressable_opacity'; +import {useWindowDimensions} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import {changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -39,16 +40,15 @@ const styles = StyleSheet.create({ }, }); -const edges: Edge[] = ['left', 'right']; +const edges: Edge[] = []; const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); const Header = ({index, onClose, style, total}: Props) => { const insets = useSafeAreaInsets(); const {width} = useWindowDimensions(); const height = useDefaultHeaderHeight() - insets.top; - const {top} = useSafeAreaInsets(); - const topContainerStyle = useMemo(() => [{height: top, backgroundColor: '#000'}], [top]); - const containerStyle = useMemo(() => [styles.container, {height}], [height]); + const topContainerStyle = useMemo(() => [{height: insets.top, backgroundColor: '#000'}], [insets.top]); + const containerStyle = useMemo(() => [styles.container, {height, paddingHorizontal: insets.left / 2}], [height, insets.left]); const iconStyle = useMemo(() => [{width: height}, styles.icon], [height]); const titleStyle = useMemo(() => ({width: width - (height * 2)}), [height, width]); const titleValue = useMemo(() => ({index: index + 1, total}), [index, total]); @@ -60,7 +60,7 @@ const Header = ({index, onClose, style, total}: Props) => { > - @@ -69,7 +69,7 @@ const Header = ({index, onClose, style, total}: Props) => { name='close' size={24} /> - + void; - onGesture?: PagerProps['onGesture']; - onPagerEnabledGesture?: PagerProps['onEnabledGesture']; - shouldPagerHandleGestureEvent?: PagerProps['shouldHandleGestureEvent']; - onShouldHideControls?: (shouldHide: boolean) => void; - } - -export type ImageRendererProps = RenderPageProps & Handlers - -function ImageRenderer({ - height, - isPageActive, - isPagerInProgress, - item, - onDoubleTap, - onInteraction, - onPageStateChange, - onTap, - pagerRefs, - width, -}: ImageRendererProps) { - const targetDimensions = useMemo(() => ({height, width}), [height, width]); - - return ( - - ); -} - -export default ImageRenderer; diff --git a/app/screens/gallery/image_renderer/transformer.tsx b/app/screens/gallery/image_renderer/transformer.tsx deleted file mode 100644 index 3aa439089..000000000 --- a/app/screens/gallery/image_renderer/transformer.tsx +++ /dev/null @@ -1,606 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Image, type ImageSource} from 'expo-image'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {StyleSheet} from 'react-native'; -import { - PanGestureHandler, type PanGestureHandlerGestureEvent, PinchGestureHandler, type PinchGestureHandlerGestureEvent, - State, TapGestureHandler, type TapGestureHandlerGestureEvent, -} from 'react-native-gesture-handler'; -import Animated, { - cancelAnimation, Easing, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, - useSharedValue, - withDecay, withSpring, type WithSpringConfig, withTiming, type WithTimingConfig, -} from 'react-native-reanimated'; -import {SvgUri} from 'react-native-svg'; - -import {useAnimatedGestureHandler} from '@hooks/gallery'; -import {clamp, workletNoop} from '@utils/gallery'; -import * as vec from '@utils/gallery/vectors'; -import {calculateDimensions} from '@utils/images'; - -const styles = StyleSheet.create({ - container: { - flex: 1, - width: '100%', - }, - wrapper: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - }, - svg: { - backgroundColor: '#FFF', - borderRadius: 8, - }, -}); - -const springConfig: WithSpringConfig = { - stiffness: 1000, - damping: 500, - mass: 3, - overshootClamping: true, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, -}; - -const timingConfig: WithTimingConfig = { - duration: 250, - easing: Easing.bezier(0.33, 0.01, 0, 1), -}; - -export interface RenderImageProps { - width: number; - height: number; - source: ImageSource; - onLoad: () => void; -} - -export type InteractionType = 'scale' | 'pan'; - -export interface ImageTransformerReusableProps { - onDoubleTap?: (isScaled: boolean) => void; - onInteraction?: (type: InteractionType) => void; - onTap?: (isScaled: boolean) => void; -} - -export interface ImageTransformerProps extends ImageTransformerReusableProps { - enabled?: boolean; - height: number; - isActive?: Animated.SharedValue; - isSvg: boolean; - onStateChange?: (isActive: boolean) => void; - outerGestureHandlerActive?: Animated.SharedValue; - outerGestureHandlerRefs?: Array>; - source: ImageSource | string; - targetDimensions: { width: number; height: number }; - width: number; -} - -const DOUBLE_TAP_SCALE = 3; -const MAX_SCALE = 3; -const MIN_SCALE = 0.7; -const OVER_SCALE = 0.5; - -function checkIsNotUsed(handlerState: Animated.SharedValue) { - 'worklet'; - - return ( - handlerState.value !== State.UNDETERMINED && - handlerState.value !== State.END - ); -} - -const ImageTransformer = ( - { - enabled = true, height, isActive, - onDoubleTap = workletNoop, onInteraction = workletNoop, onStateChange = workletNoop, onTap = workletNoop, - outerGestureHandlerActive, outerGestureHandlerRefs = [], source, isSvg, - targetDimensions, width, - }: ImageTransformerProps) => { - const imageSource = typeof source === 'string' ? {uri: source} : source; - const [pinchEnabled, setPinchEnabledState] = useState(true); - const interactionsEnabled = useSharedValue(false); - - const setInteractionsEnabled = useCallback((value: boolean) => { - interactionsEnabled.value = value; - }, []); - - const onLoadImageSuccess = useCallback(() => { - setInteractionsEnabled(true); - }, []); - - const disablePinch = useCallback(() => { - setPinchEnabledState(false); - }, []); - - useEffect(() => { - if (!pinchEnabled) { - setPinchEnabledState(true); - } - }, [pinchEnabled]); - - const pinchRef = useRef(null); - const panRef = useRef(null); - const tapRef = useRef(null); - const doubleTapRef = useRef(null); - - const panState = useSharedValue(State.UNDETERMINED); - const pinchState = useSharedValue(State.UNDETERMINED); - - const scale = useSharedValue(1); - const scaleOffset = useSharedValue(1); - const translation = vec.useSharedVector(0, 0); - const panVelocity = vec.useSharedVector(0, 0); - const scaleTranslation = vec.useSharedVector(0, 0); - const offset = vec.useSharedVector(0, 0); - - const canvas = vec.create(targetDimensions.width, targetDimensions.height); - - const {width: targetWidth, height: targetHeight} = calculateDimensions( - height, - width, - targetDimensions.width, - targetDimensions.height, - ); - - const image = vec.create(targetWidth, targetHeight); - - const canPanVertically = useDerivedValue(() => { - return targetDimensions.height < targetHeight * scale.value; - }, []); - - function resetSharedState(animated?: boolean) { - 'worklet'; - - if (animated) { - scale.value = withTiming(1, timingConfig); - scaleOffset.value = 1; - - vec.set(offset, () => withTiming(0, timingConfig)); - } else { - scale.value = 1; - scaleOffset.value = 1; - vec.set(translation, 0); - vec.set(scaleTranslation, 0); - vec.set(offset, 0); - } - } - - const maybeRunOnEnd = () => { - 'worklet'; - - const target = vec.create(0, 0); - - const fixedScale = clamp(MIN_SCALE, scale.value, MAX_SCALE); - const scaledImage = image.y * fixedScale; - const rightBoundary = (canvas.x / 2) * (fixedScale - 1); - - let topBoundary = 0; - - if (canvas.y < scaledImage) { - topBoundary = Math.abs(scaledImage - canvas.y) / 2; - } - - const maxVector = vec.create(rightBoundary, topBoundary); - const minVector = vec.invert(maxVector); - - if (!canPanVertically.value) { - offset.y.value = withSpring(target.y, springConfig); - } - - // we should handle this only if pan or pinch handlers has been used already - if ( - (checkIsNotUsed(panState) || checkIsNotUsed(pinchState)) && - pinchState.value !== State.CANCELLED - ) { - return; - } - - if ( - vec.eq(offset, 0) && - vec.eq(translation, 0) && - vec.eq(scaleTranslation, 0) && - scale.value === 1 - ) { - // we don't need to run any animations - return; - } - - if (scale.value <= 1) { - // just center it - vec.set(offset, () => withTiming(0, timingConfig)); - return; - } - - vec.set(target, vec.clamp(offset, minVector, maxVector)); - - const deceleration = 0.9915; - - const isInBoundaryX = target.x === offset.x.value; - const isInBoundaryY = target.y === offset.y.value; - - if (isInBoundaryX) { - if ( - Math.abs(panVelocity.x.value) > 0 && - scale.value <= MAX_SCALE - ) { - offset.x.value = withDecay({ - velocity: panVelocity.x.value, - clamp: [minVector.x, maxVector.x], - deceleration, - }); - } - } else { - offset.x.value = withSpring(target.x, springConfig); - } - - if (isInBoundaryY) { - if ( - Math.abs(panVelocity.y.value) > 0 && - scale.value <= MAX_SCALE && - offset.y.value !== minVector.y && - offset.y.value !== maxVector.y - ) { - offset.y.value = withDecay({ - velocity: panVelocity.y.value, - clamp: [minVector.y, maxVector.y], - deceleration, - }); - } - } else { - offset.y.value = withSpring(target.y, springConfig); - } - }; - - const onPanEvent = useAnimatedGestureHandler; pan: vec.Vector}>({ - onInit: (_, ctx) => { - ctx.panOffset = vec.create(0, 0); - }, - - shouldHandleEvent: () => { - return ( - scale.value > 1 && - (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && - interactionsEnabled.value - ); - }, - - beforeEach: (evt, ctx) => { - ctx.pan = vec.create(evt.translationX, evt.translationY); - const velocity = vec.create(evt.velocityX, evt.velocityY); - - vec.set(panVelocity, velocity); - }, - - onStart: (_, ctx) => { - cancelAnimation(offset.x); - cancelAnimation(offset.y); - ctx.panOffset = vec.create(0, 0); - onInteraction('pan'); - }, - - onActive: (evt, ctx) => { - panState.value = evt.state; - - if (scale.value > 1) { - if (evt.numberOfPointers > 1) { - // store pan offset during the pan with two fingers (during the pinch) - vec.set(ctx.panOffset, ctx.pan); - } else { - // subtract the offset and assign fixed pan - const nextTranslate = vec.add(ctx.pan, vec.invert(ctx.panOffset)); - translation.x.value = nextTranslate.x; - - if (canPanVertically.value) { - translation.y.value = nextTranslate.y; - } - } - } - }, - - onEnd: (evt, ctx) => { - panState.value = evt.state; - - vec.set(ctx.panOffset, 0); - vec.set(offset, vec.add(offset, translation)); - vec.set(translation, 0); - - maybeRunOnEnd(); - - vec.set(panVelocity, 0); - }, - }); - - useAnimatedReaction( - () => { - if (typeof isActive === 'undefined') { - return true; - } - - return isActive.value; - }, - (currentActive) => { - if (!currentActive) { - resetSharedState(); - } - }, - ); - - const onScaleEvent = useAnimatedGestureHandler< - PinchGestureHandlerGestureEvent, - { - origin: vec.Vector; - adjustFocal: vec.Vector; - gestureScale: number; - nextScale: number; - } - >({ - onInit: (_, ctx) => { - ctx.origin = vec.create(0, 0); - ctx.gestureScale = 1; - ctx.adjustFocal = vec.create(0, 0); - }, - - shouldHandleEvent: (evt) => { - return ( - evt.numberOfPointers === 2 && - (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && - interactionsEnabled.value - ); - }, - - beforeEach: (evt, ctx) => { - // calculate the overall scale value - // also limits this.event.scale - ctx.nextScale = clamp(evt.scale * scaleOffset.value, MIN_SCALE, MAX_SCALE + OVER_SCALE); - - if (ctx.nextScale > MIN_SCALE && ctx.nextScale < MAX_SCALE + OVER_SCALE) { - ctx.gestureScale = evt.scale; - } - - // this is just to be able to use with vectors - const focal = vec.create(evt.focalX, evt.focalY); - const CENTER = vec.divide(canvas, 2); - - // since it works even when you release one finger - if (evt.numberOfPointers === 2) { - // focal with translate offset - // it alow us to scale into different point even then we pan the image - ctx.adjustFocal = vec.sub(focal, vec.add(CENTER, offset)); - } else if (evt.state === State.ACTIVE && evt.numberOfPointers !== 2) { - runOnJS(disablePinch)(); - } - }, - - afterEach: (evt, ctx) => { - if (evt.state === State.END || evt.state === State.CANCELLED) { - return; - } - - scale.value = ctx.nextScale; - }, - - onStart: (_, ctx) => { - onInteraction('scale'); - cancelAnimation(offset.x); - cancelAnimation(offset.y); - vec.set(ctx.origin, ctx.adjustFocal); - }, - - onActive: (evt, ctx) => { - pinchState.value = evt.state; - - const pinch = vec.sub(ctx.adjustFocal, ctx.origin); - - const nextTranslation = vec.add(pinch, ctx.origin, vec.multiply(-1, ctx.gestureScale, ctx.origin)); - - vec.set(scaleTranslation, nextTranslation); - }, - - onFinish: (evt, ctx) => { - // reset gestureScale value - ctx.gestureScale = 1; - pinchState.value = evt.state; - - // store scale value - scaleOffset.value = scale.value; - - vec.set(offset, vec.add(offset, scaleTranslation)); - vec.set(scaleTranslation, 0); - - if (scaleOffset.value < 1) { - // make sure we don't add stuff below the 1 - scaleOffset.value = 1; - - // this runs the timing animation - scale.value = withTiming(1, timingConfig); - } else if (scaleOffset.value > MAX_SCALE) { - scaleOffset.value = MAX_SCALE; - scale.value = withTiming(MAX_SCALE, timingConfig); - } - - maybeRunOnEnd(); - }, - }); - - const onTapEvent = useAnimatedGestureHandler({ - shouldHandleEvent: (evt) => { - return ( - evt.numberOfPointers === 1 && - (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && - interactionsEnabled.value && scale.value === 1 - ); - }, - - onStart: () => { - cancelAnimation(offset.x); - cancelAnimation(offset.y); - }, - - onActive: () => { - onTap(scale.value > 1); - }, - - onEnd: () => { - maybeRunOnEnd(); - }, - }); - - function handleScaleTo(x: number, y: number) { - 'worklet'; - - scale.value = withTiming(DOUBLE_TAP_SCALE, timingConfig); - scaleOffset.value = DOUBLE_TAP_SCALE; - - const targetImageSize = vec.multiply(image, DOUBLE_TAP_SCALE); - - const CENTER = vec.divide(canvas, 2); - const imageCenter = vec.divide(image, 2); - - const focal = vec.create(x, y); - - const origin = vec.multiply( - -1, - vec.sub(vec.divide(targetImageSize, 2), CENTER), - ); - - const koef = vec.sub( - vec.multiply(vec.divide(1, imageCenter), focal), - 1, - ); - - const target = vec.multiply(origin, koef); - - if (targetImageSize.y < canvas.y) { - target.y = 0; - } - - offset.x.value = withTiming(target.x, timingConfig); - offset.y.value = withTiming(target.y, timingConfig); - } - - const onDoubleTapEvent = useAnimatedGestureHandler({ - shouldHandleEvent: (evt) => { - return ( - evt.numberOfPointers === 1 && - (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && - interactionsEnabled.value - ); - }, - - onActive: ({x, y}) => { - onDoubleTap(scale.value > 1); - - if (scale.value > 1) { - resetSharedState(true); - } else { - handleScaleTo(x, y); - } - }, - }); - - const animatedStyles = useAnimatedStyle(() => { - const noOffset = offset.x.value === 0 && offset.y.value === 0; - const noTranslation = translation.x.value === 0 && translation.y.value === 0; - const noScaleTranslation = scaleTranslation.x.value === 0 && scaleTranslation.y.value === 0; - const isInactive = scale.value === 1 && noOffset && noTranslation && noScaleTranslation; - - onStateChange(isInactive); - - return { - transform: [ - { - translateX: - scaleTranslation.x.value + - translation.x.value + - offset.x.value, - }, - { - translateY: - scaleTranslation.y.value + - translation.y.value + - offset.y.value, - }, - {scale: scale.value}, - ], - }; - }, []); - - let element; - if (isSvg) { - element = ( - - ); - } else { - element = ( - - ); - } - - return ( - - - - - - - - - - {element} - - - - - - - - - - ); -}; - -export default React.memo(ImageTransformer); - diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index aadc50a00..8158de623 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -5,8 +5,6 @@ import RNUtils from '@mattermost/rnutils'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native'; -import {CaptionsEnabledContext} from '@calls/context'; -import {hasCaptions} from '@calls/utils'; import {Events} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet, useWindowDimensions} from '@hooks/device'; @@ -31,33 +29,25 @@ type Props = { } const styles = StyleSheet.create({ - flex: {flex: 1}, + container: { + flex: 1, + }, }); const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialIndex, items}: Props) => { const dim = useWindowDimensions(); const isTablet = useIsTablet(); const [localIndex, setLocalIndex] = useState(initialIndex); - const [captionsEnabled, setCaptionsEnabled] = useState(new Array(items.length).fill(true)); - const [captionsAvailable, setCaptionsAvailable] = useState([]); - const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls(); - const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim]); + const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls(); const galleryRef = useRef(null); - useEffect(() => { - const captions = items.reduce((acc, item) => { - acc.push(hasCaptions(item.postProps)); - return acc; - }, [] as boolean[]); - setCaptionsAvailable(captions); - }, [items]); + const containerStyle = useMemo(() => { + if (Platform.OS === 'ios') { + return dim; + } - const onCaptionsPressIdx = useCallback((idx: number) => { - const enabled = [...captionsEnabled]; - enabled[idx] = !enabled[idx]; - setCaptionsEnabled(enabled); - }, [captionsEnabled, setCaptionsEnabled]); - const onCaptionsPress = useCallback(() => onCaptionsPressIdx(localIndex), [localIndex, onCaptionsPressIdx]); + return styles.container; + }, [dim]); const onClose = useCallback(() => { // We keep the un freeze here as we want @@ -99,37 +89,33 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde useAndroidHardwareBackHandler(componentId, close); return ( - - -
- -