Refactor gallery gestures (#8752)
This commit is contained in:
parent
19a258c755
commit
5eb3a1ab41
77 changed files with 4641 additions and 2291 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
43
app/components/pressable_opacity/index.tsx
Normal file
43
app/components/pressable_opacity/index.tsx
Normal file
|
|
@ -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<ViewStyle>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
onPressIn={cancelPressIn}
|
||||
onPressOut={cancelPressOut}
|
||||
onPress={onPress}
|
||||
style={[style, cancelAnimatedStyle]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<GalleryManagerItems> = makeMutable({});
|
||||
public refsByIndexSV: SharedValue<GalleryManagerItems> = 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<number, GalleryManagerItem>();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<T extends GestureHandlerGestureEvent, TContext extends Context>(handlers: GestureHandlers<T['nativeEvent'], TContext>) {
|
||||
const sharedContext = useSharedValue<any>({
|
||||
__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<T extends GestureHandlerGestureEvent, TContext extends Context>(
|
||||
handlers: GestureHandlers<T['nativeEvent'], TContext>,
|
||||
): OnGestureEvent<T> {
|
||||
const handler = useCallback(
|
||||
useCreateAnimatedGestureHandler<T, TContext>(handlers),
|
||||
[],
|
||||
);
|
||||
|
||||
return useEvent<any, TContext>(
|
||||
handler, ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], false,
|
||||
);
|
||||
}
|
||||
|
||||
export function useGalleryControls() {
|
||||
const controlsHidden = useSharedValue(false);
|
||||
|
||||
const translateYConfig: WithTimingConfig = {
|
||||
export const translateYConfig: WithTimingConfig = {
|
||||
duration: 400,
|
||||
easing: Easing.bezier(0.33, 0.01, 0, 1),
|
||||
};
|
||||
|
||||
export function useGalleryControls() {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean[]>([]);
|
||||
43
app/screens/gallery/animation_config/spring.ts
Normal file
43
app/screens/gallery/animation_config/spring.ts
Normal file
|
|
@ -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<PanGestureHandlerEventPayload>) => {
|
||||
'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,
|
||||
};
|
||||
14
app/screens/gallery/animation_config/timing.ts
Normal file
14
app/screens/gallery/animation_config/timing.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
|
|
@ -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<ManagedConfig>();
|
||||
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
|
||||
|
|
@ -45,13 +40,6 @@ const Actions = ({
|
|||
iconName='link-variant'
|
||||
onPress={onCopyPublicLink}
|
||||
/>}
|
||||
{hasCaptions &&
|
||||
<InvertedAction
|
||||
activated={captionEnabled}
|
||||
iconName='closed-caption-outline'
|
||||
onPress={onCaptionsPress}
|
||||
/>
|
||||
}
|
||||
{canDownloadFiles &&
|
||||
<>
|
||||
<Action
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
type PressableAndroidRippleConfig,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
|
||||
type Props = {
|
||||
activated: boolean;
|
||||
iconName: string;
|
||||
onPress: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
android_ripple={androidRippleConfig}
|
||||
hitSlop={4}
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<CompassIcon
|
||||
color={activated ? '#000' : '#fff'}
|
||||
name={iconName}
|
||||
size={24}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvertedAction;
|
||||
|
|
@ -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
|
|||
/>
|
||||
</View>
|
||||
<View style={styles.option}>
|
||||
<TouchableOpacity onPress={cancel}>
|
||||
<PressableOpacity onPress={cancel}>
|
||||
<CompassIcon
|
||||
color='#FFF'
|
||||
name='close'
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ViewStyle>;
|
||||
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<GalleryAction>('none');
|
||||
|
|
@ -106,9 +100,7 @@ const Footer = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatedSafeAreaView
|
||||
mode='padding'
|
||||
edges={edges}
|
||||
<Animated.View
|
||||
style={[style]}
|
||||
>
|
||||
{['downloading', 'sharing'].includes(action) && !enableSecureFilePreview && canDownloadFiles &&
|
||||
|
|
@ -149,14 +141,11 @@ const Footer = ({
|
|||
onCopyPublicLink={handleCopyLink}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
hasCaptions={hasCaptions}
|
||||
captionEnabled={captionEnabled}
|
||||
onCaptionsPress={onCaptionsPress}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
<View style={bottomStyle}/>
|
||||
</AnimatedSafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
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<GalleryRef, GalleryProps>(({
|
||||
headerAndFooterHidden,
|
||||
galleryIdentifier,
|
||||
initialIndex,
|
||||
items,
|
||||
onHide,
|
||||
targetDimensions,
|
||||
onShouldHideControls,
|
||||
hideHeaderAndFooter,
|
||||
onIndexChange,
|
||||
}: GalleryProps, ref) => {
|
||||
const {refsByIndexSV, sharedValues} = useGallery(galleryIdentifier);
|
||||
const [localIndex, setLocalIndex] = useState(initialIndex);
|
||||
const lightboxRef = useRef<LightboxSwipeoutRef>(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<GalleryRef, GalleryProps>(({
|
|||
(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<GalleryRef, GalleryProps>(({
|
|||
'worklet';
|
||||
|
||||
if (Math.abs(translateY) > 8) {
|
||||
onShouldHideControls(true);
|
||||
hideHeaderAndFooter(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,10 +132,10 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
'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<GalleryRef, GalleryProps>(({
|
|||
sharedValues.y.value = 0;
|
||||
|
||||
runOnJS(onHide)();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRenderItem = useCallback((info: RenderItemInfo) => {
|
||||
if (item.type === 'video' && item.posterUri) {
|
||||
return (
|
||||
<AnimatedImage
|
||||
source={{uri: item.posterUri}}
|
||||
placeholder={{uri: item.posterUri}}
|
||||
style={info.itemStyles}
|
||||
placeholderContentFit='cover'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -155,7 +162,7 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
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<GalleryRef, GalleryProps>(({
|
|||
{...props}
|
||||
index={idx}
|
||||
initialIndex={initialIndex}
|
||||
onShouldHideControls={onShouldHideControls}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<DocumentRenderer
|
||||
item={props.item}
|
||||
onShouldHideControls={onShouldHideControls}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
|
@ -178,33 +185,38 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
}
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<LightboxSwipeout
|
||||
headerAndFooterHidden={headerAndFooterHidden}
|
||||
ref={lightboxRef}
|
||||
target={item}
|
||||
onAnimationFinished={hideLightboxItem}
|
||||
sharedValues={sharedValues}
|
||||
source={item.uri}
|
||||
source={source}
|
||||
onSwipeActive={onSwipeActive}
|
||||
onSwipeFailure={onSwipeFailure}
|
||||
renderBackdropComponent={renderBackdropComponent}
|
||||
targetDimensions={targetDimensions}
|
||||
renderItem={onRenderItem}
|
||||
>
|
||||
{({onGesture, shouldHandleEvent}) => (
|
||||
<GalleryViewer
|
||||
items={items}
|
||||
onIndexChange={onIndexChangeWorklet}
|
||||
shouldPagerHandleGestureEvent={shouldHandleEvent}
|
||||
onShouldHideControls={onShouldHideControls}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
height={targetDimensions.height}
|
||||
width={targetDimensions.width}
|
||||
initialIndex={initialIndex}
|
||||
onPagerEnabledGesture={onGesture}
|
||||
numToRender={1}
|
||||
renderPage={onRenderPage}
|
||||
/>
|
||||
)}
|
||||
</LightboxSwipeout>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
>
|
||||
<Animated.View style={topContainerStyle}/>
|
||||
<Animated.View style={containerStyle}>
|
||||
<TouchableOpacity
|
||||
<PressableOpacity
|
||||
onPress={onClose}
|
||||
style={iconStyle}
|
||||
>
|
||||
|
|
@ -69,7 +69,7 @@ const Header = ({index, onClose, style, total}: Props) => {
|
|||
name='close'
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</PressableOpacity>
|
||||
<View style={titleStyle}>
|
||||
<FormattedText
|
||||
id='mobile.gallery.title'
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
|
||||
import ImageTransformer, {type ImageTransformerProps} from './transformer';
|
||||
|
||||
import type {PagerProps} from '../pager';
|
||||
import type {RenderPageProps} from '../pager/page';
|
||||
|
||||
export interface Handlers {
|
||||
onTap?: ImageTransformerProps['onTap'];
|
||||
onDoubleTap?: ImageTransformerProps['onDoubleTap'];
|
||||
onInteraction?: ImageTransformerProps['onInteraction'];
|
||||
onPagerTranslateChange?: (translateX: number) => 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 (
|
||||
<ImageTransformer
|
||||
outerGestureHandlerActive={isPagerInProgress}
|
||||
isActive={isPageActive}
|
||||
targetDimensions={targetDimensions}
|
||||
height={item.height}
|
||||
isSvg={item.extension === 'svg'}
|
||||
onStateChange={onPageStateChange}
|
||||
outerGestureHandlerRefs={pagerRefs}
|
||||
source={item.uri}
|
||||
width={item.width}
|
||||
onDoubleTap={onDoubleTap}
|
||||
onTap={onTap}
|
||||
onInteraction={onInteraction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageRenderer;
|
||||
|
|
@ -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<boolean>;
|
||||
isSvg: boolean;
|
||||
onStateChange?: (isActive: boolean) => void;
|
||||
outerGestureHandlerActive?: Animated.SharedValue<boolean>;
|
||||
outerGestureHandlerRefs?: Array<React.Ref<unknown>>;
|
||||
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<State>) {
|
||||
'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>(State.UNDETERMINED);
|
||||
const pinchState = useSharedValue<State>(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<PanGestureHandlerGestureEvent, {panOffset: vec.Vector<number>; pan: vec.Vector<number>}>({
|
||||
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<number>;
|
||||
adjustFocal: vec.Vector<number>;
|
||||
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<TapGestureHandlerGestureEvent, {}>({
|
||||
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 = (
|
||||
<SvgUri
|
||||
uri={imageSource.uri!}
|
||||
style={styles.svg}
|
||||
width={Math.min(targetDimensions.width, targetDimensions.height)}
|
||||
height={Math.min(targetDimensions.width, targetDimensions.height)}
|
||||
onLayout={onLoadImageSuccess}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Image
|
||||
onLoad={onLoadImageSuccess}
|
||||
source={imageSource}
|
||||
style={{width: targetWidth, height: targetHeight}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container]}>
|
||||
<PinchGestureHandler
|
||||
enabled={enabled && pinchEnabled}
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onScaleEvent}
|
||||
simultaneousHandlers={[panRef, tapRef, ...outerGestureHandlerRefs]}
|
||||
>
|
||||
<Animated.View style={[StyleSheet.absoluteFill]}>
|
||||
<PanGestureHandler
|
||||
enabled={enabled}
|
||||
ref={panRef}
|
||||
minDist={4}
|
||||
avgTouches={true}
|
||||
simultaneousHandlers={[pinchRef, tapRef, ...outerGestureHandlerRefs]}
|
||||
onGestureEvent={onPanEvent}
|
||||
>
|
||||
<Animated.View style={[StyleSheet.absoluteFill]}>
|
||||
<TapGestureHandler
|
||||
enabled={enabled}
|
||||
ref={tapRef}
|
||||
numberOfTaps={1}
|
||||
maxDeltaX={8}
|
||||
maxDeltaY={8}
|
||||
simultaneousHandlers={[pinchRef, panRef, ...outerGestureHandlerRefs]}
|
||||
waitFor={doubleTapRef}
|
||||
onGestureEvent={onTapEvent}
|
||||
>
|
||||
<Animated.View style={styles.wrapper}>
|
||||
<TapGestureHandler
|
||||
enabled={enabled}
|
||||
ref={doubleTapRef}
|
||||
numberOfTaps={2}
|
||||
maxDelayMs={140}
|
||||
maxDeltaX={16}
|
||||
maxDeltaY={16}
|
||||
simultaneousHandlers={[pinchRef, panRef, ...outerGestureHandlerRefs]}
|
||||
onGestureEvent={onDoubleTapEvent}
|
||||
>
|
||||
<Animated.View style={animatedStyles}>
|
||||
{element}
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</Animated.View>
|
||||
</PinchGestureHandler>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ImageTransformer);
|
||||
|
||||
|
|
@ -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<boolean[]>(new Array(items.length).fill(true));
|
||||
const [captionsAvailable, setCaptionsAvailable] = useState<boolean[]>([]);
|
||||
const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls();
|
||||
const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim]);
|
||||
const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls();
|
||||
const galleryRef = useRef<GalleryRef>(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,9 +89,8 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
|
|||
useAndroidHardwareBackHandler(componentId, close);
|
||||
|
||||
return (
|
||||
<CaptionsEnabledContext.Provider value={captionsEnabled}>
|
||||
<View
|
||||
style={styles.flex}
|
||||
style={containerStyle}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||
>
|
||||
<Header
|
||||
|
|
@ -111,25 +100,22 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
|
|||
total={items.length}
|
||||
/>
|
||||
<Gallery
|
||||
headerAndFooterHidden={headerAndFooterHidden}
|
||||
galleryIdentifier={galleryIdentifier}
|
||||
initialIndex={initialIndex}
|
||||
items={items}
|
||||
onHide={close}
|
||||
onIndexChange={onIndexChange}
|
||||
onShouldHideControls={setControlsHidden}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
ref={galleryRef}
|
||||
targetDimensions={dimensions}
|
||||
targetDimensions={dim}
|
||||
/>
|
||||
<Footer
|
||||
hideActions={hideActions}
|
||||
item={items[localIndex]}
|
||||
style={footerStyles}
|
||||
hasCaptions={captionsAvailable[localIndex]}
|
||||
captionEnabled={captionsEnabled[localIndex]}
|
||||
onCaptionsPress={onCaptionsPress}
|
||||
/>
|
||||
</View>
|
||||
</CaptionsEnabledContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
|
||||
import React from 'react';
|
||||
import {StyleSheet, type ViewStyle} from 'react-native';
|
||||
import Animated, {type AnimatedStyleProp, Extrapolate, interpolate, type SharedValue, useAnimatedStyle} from 'react-native-reanimated';
|
||||
import Animated, {type AnimatedStyle, interpolate, type SharedValue, useAnimatedStyle} from 'react-native-reanimated';
|
||||
|
||||
export type BackdropProps = {
|
||||
animatedStyles: AnimatedStyleProp<ViewStyle>;
|
||||
animatedStyles: AnimatedStyle<ViewStyle>;
|
||||
translateY: SharedValue<number>;
|
||||
}
|
||||
|
||||
|
|
@ -20,10 +20,10 @@ const Backdrop = ({animatedStyles, translateY}: BackdropProps) => {
|
|||
const customBackdropStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
Math.abs(translateY.value),
|
||||
Math.abs(translateY.value / 3),
|
||||
[0, 100],
|
||||
[1, 0],
|
||||
Extrapolate.CLAMP,
|
||||
'clamp',
|
||||
),
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
46
app/screens/gallery/lightbox_swipeout/context/index.tsx
Normal file
46
app/screens/gallery/lightbox_swipeout/context/index.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useContext} from 'react';
|
||||
|
||||
import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
import type {ImageSize} from 'react-native';
|
||||
import type {SharedValue} from 'react-native-reanimated';
|
||||
|
||||
export type LightboxSharedValues = {
|
||||
headerAndFooterHidden: SharedValue<boolean>;
|
||||
animationProgress: SharedValue<number>;
|
||||
childrenOpacity: SharedValue<number>;
|
||||
childTranslateY: SharedValue<number>;
|
||||
imageOpacity: SharedValue<number>;
|
||||
opacity: SharedValue<number>;
|
||||
scale: SharedValue<number>;
|
||||
target: GalleryItemType;
|
||||
targetDimensions: ImageSize;
|
||||
translateX: SharedValue<number>;
|
||||
translateY: SharedValue<number>;
|
||||
allowsOtherGestures: () => boolean;
|
||||
isVisibleImage: () => boolean;
|
||||
onAnimationFinished: () => void;
|
||||
onSwipeActive: (translateY: number) => void;
|
||||
onSwipeFailure: () => void;
|
||||
};
|
||||
|
||||
const LightboxContext = React.createContext<LightboxSharedValues | null>(null);
|
||||
|
||||
export const LightboxProvider: React.FC<{sharedValues: LightboxSharedValues; children: React.ReactNode}> = ({children, sharedValues}) => {
|
||||
return (
|
||||
<LightboxContext.Provider value={sharedValues}>
|
||||
{children}
|
||||
</LightboxContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLightboxSharedValues = () => {
|
||||
const context = useContext(LightboxContext);
|
||||
if (!context) {
|
||||
throw new Error('useLightboxSharedValues must be used within a LightboxProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,29 +1,22 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image, type ImageSource} from 'expo-image';
|
||||
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react';
|
||||
import {type ImageStyle, StyleSheet, View, type ViewStyle} from 'react-native';
|
||||
import Animated, {
|
||||
cancelAnimation, Easing, interpolate, runOnJS, runOnUI,
|
||||
useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming, type WithTimingConfig,
|
||||
import {type ImageSource} from 'expo-image';
|
||||
import React, {forwardRef, useImperativeHandle, useMemo} from 'react';
|
||||
import {type ImageSize, type ImageStyle, type ViewStyle} from 'react-native';
|
||||
import {
|
||||
cancelAnimation,
|
||||
useAnimatedReaction, useSharedValue, withTiming,
|
||||
type SharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import {useCreateAnimatedGestureHandler} from '@hooks/gallery';
|
||||
import {freezeOtherScreens} from '@utils/gallery';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {pagerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
|
||||
import {LightboxProvider, type LightboxSharedValues} from './context';
|
||||
import Lightbox from './lightbox';
|
||||
|
||||
import type {BackdropProps} from './backdrop';
|
||||
import type {GalleryItemType, GalleryManagerSharedValues} from '@typings/screens/gallery';
|
||||
import type {
|
||||
GestureHandlerGestureEventNativeEvent,
|
||||
PanGestureHandlerEventPayload, PanGestureHandlerGestureEvent,
|
||||
} from 'react-native-gesture-handler';
|
||||
|
||||
interface Size {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface RenderItemInfo {
|
||||
source: ImageSource;
|
||||
|
|
@ -32,13 +25,9 @@ export interface RenderItemInfo {
|
|||
itemStyles: ViewStyle | ImageStyle;
|
||||
}
|
||||
|
||||
interface LightboxSwipeoutChildren {
|
||||
onGesture: (evt: GestureHandlerGestureEventNativeEvent & PanGestureHandlerEventPayload) => void;
|
||||
shouldHandleEvent: () => boolean;
|
||||
}
|
||||
|
||||
interface LightboxSwipeoutProps {
|
||||
children: ({onGesture, shouldHandleEvent}: LightboxSwipeoutChildren) => JSX.Element;
|
||||
children: React.ReactNode;
|
||||
headerAndFooterHidden: SharedValue<boolean>;
|
||||
onAnimationFinished: () => void;
|
||||
onSwipeActive: (translateY: number) => void;
|
||||
onSwipeFailure: () => void;
|
||||
|
|
@ -47,27 +36,19 @@ interface LightboxSwipeoutProps {
|
|||
sharedValues: GalleryManagerSharedValues;
|
||||
source: ImageSource | string;
|
||||
target: GalleryItemType;
|
||||
targetDimensions: Size;
|
||||
targetDimensions: ImageSize;
|
||||
}
|
||||
|
||||
export interface LightboxSwipeoutRef {
|
||||
closeLightbox: () => void;
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
const timingConfig: WithTimingConfig = {
|
||||
duration: 250,
|
||||
easing: Easing.bezier(0.5002, 0.2902, 0.3214, 0.9962),
|
||||
};
|
||||
|
||||
const LightboxSwipeout = forwardRef<LightboxSwipeoutRef, LightboxSwipeoutProps>(({
|
||||
onAnimationFinished, children, onSwipeActive, onSwipeFailure,
|
||||
onAnimationFinished, children, headerAndFooterHidden, onSwipeActive, onSwipeFailure,
|
||||
renderBackdropComponent, renderItem,
|
||||
sharedValues, source, target, targetDimensions,
|
||||
}: LightboxSwipeoutProps, ref) => {
|
||||
const imageSource: ImageSource = typeof source === 'string' ? {uri: source} : source;
|
||||
const {x, y, width, height, opacity, targetWidth, targetHeight} = sharedValues;
|
||||
const {x, y, opacity} = sharedValues;
|
||||
const animationProgress = useSharedValue(0);
|
||||
const childTranslateY = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
|
|
@ -75,7 +56,6 @@ const LightboxSwipeout = forwardRef<LightboxSwipeoutRef, LightboxSwipeoutProps>(
|
|||
const scale = useSharedValue(1);
|
||||
const lightboxImageOpacity = useSharedValue(1);
|
||||
const childrenOpacity = useSharedValue(0);
|
||||
const [renderChildren, setRenderChildren] = useState<boolean>(false);
|
||||
|
||||
const shouldHandleEvent = () => {
|
||||
'worklet';
|
||||
|
|
@ -90,7 +70,7 @@ const LightboxSwipeout = forwardRef<LightboxSwipeoutRef, LightboxSwipeoutProps>(
|
|||
childrenOpacity.value = 0;
|
||||
animationProgress.value = withTiming(
|
||||
0,
|
||||
timingConfig,
|
||||
pagerTimingConfig,
|
||||
() => {
|
||||
'worklet';
|
||||
|
||||
|
|
@ -109,24 +89,6 @@ const LightboxSwipeout = forwardRef<LightboxSwipeoutRef, LightboxSwipeoutProps>(
|
|||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
requestAnimationFrame(() => {
|
||||
opacity.value = 0;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
animationProgress.value = withTiming(1, timingConfig, () => {
|
||||
'worklet';
|
||||
|
||||
childrenOpacity.value = 1;
|
||||
runOnJS(setRenderChildren)(true);
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
closeLightbox,
|
||||
}));
|
||||
|
|
@ -142,212 +104,40 @@ const LightboxSwipeout = forwardRef<LightboxSwipeoutRef, LightboxSwipeoutProps>(
|
|||
);
|
||||
};
|
||||
|
||||
const handler = useCallback(
|
||||
useCreateAnimatedGestureHandler<PanGestureHandlerGestureEvent, {}>({
|
||||
shouldHandleEvent: (evt) => {
|
||||
'worklet';
|
||||
const lightboxSharedValues: LightboxSharedValues = useMemo(() => ({
|
||||
headerAndFooterHidden,
|
||||
animationProgress,
|
||||
childrenOpacity,
|
||||
childTranslateY,
|
||||
imageOpacity: lightboxImageOpacity,
|
||||
opacity,
|
||||
scale,
|
||||
translateX,
|
||||
translateY,
|
||||
target,
|
||||
targetDimensions,
|
||||
allowsOtherGestures: shouldHandleEvent,
|
||||
isVisibleImage,
|
||||
onAnimationFinished,
|
||||
onSwipeActive,
|
||||
onSwipeFailure,
|
||||
|
||||
const shouldHandle = (
|
||||
evt.numberOfPointers === 1 &&
|
||||
Math.abs(evt.velocityX) < Math.abs(evt.velocityY) &&
|
||||
animationProgress.value === 1
|
||||
);
|
||||
|
||||
if (shouldHandle) {
|
||||
runOnJS(freezeOtherScreens)(false);
|
||||
}
|
||||
return shouldHandle;
|
||||
},
|
||||
|
||||
onStart: () => {
|
||||
'worklet';
|
||||
|
||||
lightboxImageOpacity.value = 1;
|
||||
childrenOpacity.value = 0;
|
||||
},
|
||||
|
||||
onActive: (evt) => {
|
||||
'worklet';
|
||||
|
||||
childTranslateY.value = evt.translationY;
|
||||
|
||||
onSwipeActive(childTranslateY.value);
|
||||
},
|
||||
|
||||
onEnd: (evt) => {
|
||||
'worklet';
|
||||
|
||||
const enoughVelocity = Math.abs(evt.velocityY) > 30;
|
||||
const rightDirection =
|
||||
(evt.translationY > 0 && evt.velocityY > 0) ||
|
||||
(evt.translationY < 0 && evt.velocityY < 0);
|
||||
|
||||
if (enoughVelocity && rightDirection) {
|
||||
const elementVisible = isVisibleImage();
|
||||
|
||||
if (elementVisible) {
|
||||
lightboxImageOpacity.value = 1;
|
||||
childrenOpacity.value = 0;
|
||||
animationProgress.value = withTiming(
|
||||
0,
|
||||
timingConfig,
|
||||
() => {
|
||||
'worklet';
|
||||
|
||||
opacity.value = 1;
|
||||
onAnimationFinished();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const maybeInvert = (v: number) => {
|
||||
const invert = evt.velocityY < 0;
|
||||
return invert ? -v : v;
|
||||
};
|
||||
|
||||
opacity.value = 1;
|
||||
|
||||
childTranslateY.value = withSpring(
|
||||
maybeInvert((target.height || targetDimensions.height) * 2),
|
||||
{
|
||||
stiffness: 50,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
overshootClamping: true,
|
||||
velocity:
|
||||
Math.abs(evt.velocityY) < 1200 ? maybeInvert(1200) : evt.velocityY,
|
||||
},
|
||||
() => {
|
||||
onAnimationFinished();
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lightboxImageOpacity.value = 0;
|
||||
childrenOpacity.value = 1;
|
||||
|
||||
childTranslateY.value = withSpring(0, {
|
||||
stiffness: 1000,
|
||||
damping: 500,
|
||||
mass: 2,
|
||||
restDisplacementThreshold: 10,
|
||||
restSpeedThreshold: 10,
|
||||
velocity: evt.velocityY,
|
||||
});
|
||||
|
||||
onSwipeFailure();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
function onChildrenLayout() {
|
||||
if (lightboxImageOpacity.value === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
lightboxImageOpacity.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
const childrenAnimateStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: childrenOpacity.value,
|
||||
transform: [{translateY: childTranslateY.value}],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const backdropStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'black',
|
||||
opacity: animationProgress.value,
|
||||
};
|
||||
});
|
||||
|
||||
const itemStyles = useAnimatedStyle(() => {
|
||||
const interpolateProgress = (range: [number, number]) =>
|
||||
interpolate(animationProgress.value, [0, 1], range);
|
||||
|
||||
const {width: tw, height: th} = calculateDimensions(
|
||||
target.height,
|
||||
target.width,
|
||||
targetDimensions.width,
|
||||
targetDimensions.height,
|
||||
);
|
||||
|
||||
const targetX = (targetDimensions.width - tw) / 2;
|
||||
const targetY =
|
||||
(targetDimensions.height - th) / 2;
|
||||
|
||||
const top =
|
||||
translateY.value +
|
||||
interpolateProgress([y.value, targetY + childTranslateY.value]);
|
||||
const left =
|
||||
translateX.value + interpolateProgress([x.value, targetX]);
|
||||
|
||||
return {
|
||||
opacity: lightboxImageOpacity.value,
|
||||
position: 'absolute',
|
||||
top,
|
||||
left,
|
||||
width: interpolateProgress([width.value, tw]),
|
||||
height: interpolateProgress([height.value, th]),
|
||||
transform: [
|
||||
{
|
||||
scale: scale.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
// The remaining dependencies does not need to be added as they
|
||||
// are already included in the sharedValues object
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [target, targetDimensions, shouldHandleEvent, isVisibleImage, onAnimationFinished, onSwipeActive, onSwipeFailure]);
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
{renderBackdropComponent &&
|
||||
renderBackdropComponent({
|
||||
animatedStyles: backdropStyles,
|
||||
translateY: childTranslateY,
|
||||
})}
|
||||
|
||||
<Animated.View style={StyleSheet.absoluteFillObject}>
|
||||
{
|
||||
target.type !== 'image' &&
|
||||
target.type !== 'avatar' &&
|
||||
typeof renderItem === 'function' ? (
|
||||
renderItem({
|
||||
source: imageSource,
|
||||
width: targetWidth.value,
|
||||
height: targetHeight.value,
|
||||
itemStyles,
|
||||
})
|
||||
) : (
|
||||
<AnimatedImage
|
||||
source={imageSource}
|
||||
style={itemStyles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, childrenAnimateStyle]}
|
||||
<LightboxProvider sharedValues={lightboxSharedValues}>
|
||||
<Lightbox
|
||||
renderBackdropComponent={renderBackdropComponent}
|
||||
renderItem={renderItem}
|
||||
sharedValues={sharedValues}
|
||||
source={source}
|
||||
>
|
||||
{renderChildren && (
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill]}
|
||||
onLayout={onChildrenLayout}
|
||||
>
|
||||
{children({onGesture: handler, shouldHandleEvent})}
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
{children}
|
||||
</Lightbox>
|
||||
</LightboxProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
202
app/screens/gallery/lightbox_swipeout/lightbox.tsx
Normal file
202
app/screens/gallery/lightbox_swipeout/lightbox.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image, type ImageSource} from 'expo-image';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {type ImageStyle, Platform, StyleSheet, View, type ViewStyle} from 'react-native';
|
||||
import Animated, {
|
||||
interpolate, runOnJS, runOnUI,
|
||||
useAnimatedStyle, withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
|
||||
import {pagerTimingConfig} from '../animation_config/timing';
|
||||
|
||||
import {useLightboxSharedValues} from './context';
|
||||
|
||||
import type {BackdropProps} from './backdrop';
|
||||
import type {GalleryManagerSharedValues} from '@typings/screens/gallery';
|
||||
|
||||
export interface RenderItemInfo {
|
||||
source: ImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
itemStyles: ViewStyle | ImageStyle;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
children: React.ReactNode;
|
||||
renderBackdropComponent?: (info: BackdropProps) => JSX.Element;
|
||||
renderItem: (info: RenderItemInfo) => JSX.Element | null;
|
||||
sharedValues: GalleryManagerSharedValues;
|
||||
source: ImageSource | string;
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Lightbox({
|
||||
children, renderBackdropComponent, renderItem,
|
||||
sharedValues, source,
|
||||
}: LightboxProps) {
|
||||
const imageSource: ImageSource = typeof source === 'string' ? {uri: source} : source;
|
||||
const {x, y, width, height} = sharedValues;
|
||||
const {
|
||||
animationProgress,
|
||||
childTranslateY,
|
||||
translateX,
|
||||
translateY,
|
||||
scale,
|
||||
imageOpacity,
|
||||
childrenOpacity,
|
||||
opacity,
|
||||
target,
|
||||
targetDimensions,
|
||||
} = useLightboxSharedValues();
|
||||
const [renderChildren, setRenderChildren] = useState<boolean>(false);
|
||||
const childLayoutTimeoutRef = React.useRef<NodeJS.Timeout>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const headerHeight = useDefaultHeaderHeight() - insets.top;
|
||||
const targetHeightDiff = Platform.OS === 'ios' ? 0 : (headerHeight / 2);
|
||||
|
||||
const animateOnMount = () => {
|
||||
'worklet';
|
||||
requestAnimationFrame(() => {
|
||||
opacity.value = 0;
|
||||
});
|
||||
|
||||
animationProgress.value = withTiming(1, pagerTimingConfig, () => {
|
||||
'worklet';
|
||||
|
||||
childrenOpacity.value = 1;
|
||||
runOnJS(setRenderChildren)(true);
|
||||
runOnJS(onChildrenLayout)();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
runOnUI(animateOnMount)();
|
||||
|
||||
return () => {
|
||||
if (childLayoutTimeoutRef.current) {
|
||||
clearTimeout(childLayoutTimeoutRef.current);
|
||||
childLayoutTimeoutRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {width: tw, height: th} = useMemo(() => calculateDimensions(
|
||||
target.height,
|
||||
target.width,
|
||||
targetDimensions.width,
|
||||
targetDimensions.height,
|
||||
true,
|
||||
), [targetDimensions.width, targetDimensions.height, target.width, target.height]);
|
||||
|
||||
function onChildrenLayout() {
|
||||
if (imageOpacity.value === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
childLayoutTimeoutRef.current = setTimeout(() => {
|
||||
imageOpacity.value = withTiming(0, {duration: 300});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const childrenAnimateStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: childrenOpacity.value,
|
||||
transform: [{translateY: childTranslateY.value}],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const backdropStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'black',
|
||||
opacity: animationProgress.value,
|
||||
};
|
||||
});
|
||||
|
||||
const itemStyles = useAnimatedStyle(() => {
|
||||
const interpolateProgress = (range: [number, number]) =>
|
||||
interpolate(animationProgress.value, [0, 1], range);
|
||||
|
||||
const targetX = (targetDimensions.width - tw) / 2;
|
||||
const targetY = ((targetDimensions.height - th) / 2) - targetHeightDiff;
|
||||
|
||||
const top =
|
||||
translateY.value +
|
||||
interpolateProgress([y.value, targetY + childTranslateY.value]);
|
||||
const left =
|
||||
translateX.value + interpolateProgress([x.value, targetX]);
|
||||
|
||||
return {
|
||||
opacity: imageOpacity.value,
|
||||
position: 'absolute',
|
||||
top,
|
||||
left,
|
||||
width: interpolateProgress([width.value, tw]),
|
||||
height: interpolateProgress([height.value, th]),
|
||||
transform: [
|
||||
{
|
||||
scale: scale.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{renderBackdropComponent &&
|
||||
renderBackdropComponent({
|
||||
animatedStyles: backdropStyles,
|
||||
translateY: childTranslateY,
|
||||
})}
|
||||
|
||||
<Animated.View style={StyleSheet.absoluteFill}>
|
||||
{
|
||||
target.type !== 'image' &&
|
||||
target.type !== 'avatar' &&
|
||||
typeof renderItem === 'function' ? (
|
||||
renderItem({
|
||||
source: imageSource,
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
itemStyles,
|
||||
})
|
||||
) : (
|
||||
<AnimatedImage
|
||||
placeholder={imageSource}
|
||||
placeholderContentFit='cover'
|
||||
style={itemStyles}
|
||||
autoplay={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, childrenAnimateStyle]}
|
||||
>
|
||||
{renderChildren && children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
105
app/screens/gallery/pager/context/index.tsx
Normal file
105
app/screens/gallery/pager/context/index.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useContext} from 'react';
|
||||
|
||||
import type {DerivedValue, SharedValue} from 'react-native-reanimated';
|
||||
|
||||
export type PagerSharedValues = {
|
||||
sharedWidth: SharedValue<number>;
|
||||
gutterWidthToUse: number;
|
||||
isActive: SharedValue<boolean>;
|
||||
velocity: SharedValue<number>;
|
||||
index: SharedValue<number>;
|
||||
length: SharedValue<number>;
|
||||
pagerX: SharedValue<number>;
|
||||
toValueAnimation: SharedValue<number>;
|
||||
offsetX: DerivedValue<number>;
|
||||
totalWidth: DerivedValue<number>;
|
||||
isPagerInProgress: DerivedValue<boolean>;
|
||||
getPageTranslate: (i: number, w?: number) => number;
|
||||
activeIndex: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
};
|
||||
|
||||
const PagerContext = React.createContext<PagerSharedValues | null>(null);
|
||||
|
||||
export const PagerProvider: React.FC<{sharedValues: PagerSharedValues; children: React.ReactNode}> = ({children, sharedValues}) => {
|
||||
return (
|
||||
<PagerContext.Provider value={sharedValues}>
|
||||
{children}
|
||||
</PagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePagerSharedValues = () => {
|
||||
const context = useContext(PagerContext);
|
||||
if (!context) {
|
||||
throw new Error('usePagerSharedValues must be used within a PagerProvider');
|
||||
}
|
||||
|
||||
const {
|
||||
sharedWidth,
|
||||
gutterWidthToUse,
|
||||
isActive,
|
||||
offsetX,
|
||||
index,
|
||||
length,
|
||||
getPageTranslate,
|
||||
} = context;
|
||||
|
||||
function onPageStateChange(value: boolean) {
|
||||
'worklet';
|
||||
|
||||
isActive.value = value;
|
||||
}
|
||||
|
||||
function getCanSwipe(currentTranslate = 0) {
|
||||
'worklet';
|
||||
|
||||
const nextTranslate = offsetX.value + currentTranslate;
|
||||
|
||||
if (nextTranslate > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalTranslate = (sharedWidth.value * (length.value - 1)) + (gutterWidthToUse * (length.value - 1));
|
||||
|
||||
if (Math.abs(nextTranslate) >= totalTranslate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const getNextIndex = (v: number) => {
|
||||
'worklet';
|
||||
|
||||
const currentTranslate = Math.abs(getPageTranslate(index.value));
|
||||
const currentIndex = index.value;
|
||||
const currentOffset = Math.abs(offsetX.value);
|
||||
|
||||
const nextIndex = v < 0 ? currentIndex + 1 : currentIndex - 1;
|
||||
|
||||
if (nextIndex < currentIndex && currentOffset > currentTranslate) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
if (nextIndex > currentIndex && currentOffset < currentTranslate) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
if (nextIndex > length.value - 1 || nextIndex < 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
};
|
||||
|
||||
return {
|
||||
...context,
|
||||
onPageStateChange,
|
||||
getCanSwipe,
|
||||
getNextIndex,
|
||||
};
|
||||
};
|
||||
129
app/screens/gallery/pager/gestures/useLightboxPanGesture.ts
Normal file
129
app/screens/gallery/pager/gestures/useLightboxPanGesture.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture, type GestureUpdateEvent, type PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
|
||||
import {runOnJS, useSharedValue, withSpring, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {pagerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {freezeOtherScreens} from '@utils/gallery';
|
||||
|
||||
import {usePagerSharedValues} from '../context';
|
||||
|
||||
export default function useLightboxPanGesture() {
|
||||
const {
|
||||
animationProgress,
|
||||
childrenOpacity,
|
||||
childTranslateY,
|
||||
imageOpacity,
|
||||
opacity,
|
||||
target,
|
||||
targetDimensions,
|
||||
isVisibleImage,
|
||||
onAnimationFinished,
|
||||
onSwipeActive,
|
||||
onSwipeFailure,
|
||||
} = useLightboxSharedValues();
|
||||
|
||||
const {isActive, isPagerInProgress} = usePagerSharedValues();
|
||||
const isGestureActive = useSharedValue(false);
|
||||
|
||||
const shouldAllowPanGesture = (evt: GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
|
||||
'worklet';
|
||||
|
||||
const shouldHandle = (
|
||||
evt.numberOfPointers <= 1 && isActive.value &&
|
||||
Math.abs(evt.velocityX) < Math.abs(evt.velocityY) &&
|
||||
Math.abs(evt.translationY) > Math.abs(evt.translationX) &&
|
||||
animationProgress.value === 1 && !isPagerInProgress.value
|
||||
);
|
||||
|
||||
if (shouldHandle) {
|
||||
runOnJS(freezeOtherScreens)(false);
|
||||
}
|
||||
return shouldHandle;
|
||||
};
|
||||
|
||||
return Gesture.Pan().
|
||||
onStart((evt) => {
|
||||
if (!shouldAllowPanGesture(evt)) {
|
||||
return;
|
||||
}
|
||||
imageOpacity.value = 1;
|
||||
childrenOpacity.value = 0;
|
||||
}).
|
||||
onUpdate((evt) => {
|
||||
if (!shouldAllowPanGesture(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGestureActive.value = true;
|
||||
childTranslateY.value = evt.translationY;
|
||||
|
||||
onSwipeActive(childTranslateY.value);
|
||||
}).
|
||||
onEnd((evt) => {
|
||||
if (!shouldAllowPanGesture(evt) && !isGestureActive.value) {
|
||||
return;
|
||||
}
|
||||
isGestureActive.value = false;
|
||||
const enoughVelocity = Math.abs(evt.velocityY) > 30;
|
||||
const rightDirection =
|
||||
(evt.translationY > 150 && evt.velocityY > 0) ||
|
||||
(evt.translationY < 150 && evt.velocityY < 0);
|
||||
|
||||
if (enoughVelocity && rightDirection) {
|
||||
const elementVisible = isVisibleImage();
|
||||
|
||||
if (elementVisible) {
|
||||
imageOpacity.value = 1;
|
||||
childrenOpacity.value = 0;
|
||||
animationProgress.value = withTiming(
|
||||
0,
|
||||
pagerTimingConfig,
|
||||
() => {
|
||||
'worklet';
|
||||
|
||||
opacity.value = 1;
|
||||
onAnimationFinished();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const maybeInvert = (v: number) => {
|
||||
const invert = evt.velocityY < 0;
|
||||
return invert ? -v : v;
|
||||
};
|
||||
|
||||
opacity.value = 1;
|
||||
|
||||
childTranslateY.value = withSpring(
|
||||
maybeInvert((target.height || targetDimensions.height) * 2),
|
||||
{
|
||||
stiffness: 50,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
overshootClamping: true,
|
||||
velocity: Math.abs(evt.velocityY) < 1200 ? maybeInvert(1200) : evt.velocityY,
|
||||
},
|
||||
() => {
|
||||
onAnimationFinished();
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
imageOpacity.value = 0;
|
||||
childrenOpacity.value = 1;
|
||||
|
||||
childTranslateY.value = withSpring(0, {
|
||||
stiffness: 1000,
|
||||
damping: 500,
|
||||
mass: 2,
|
||||
restDisplacementThreshold: 10,
|
||||
restSpeedThreshold: 10,
|
||||
velocity: evt.velocityY,
|
||||
});
|
||||
|
||||
onSwipeFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
94
app/screens/gallery/pager/gestures/usePagerPanGesture.ts
Normal file
94
app/screens/gallery/pager/gestures/usePagerPanGesture.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture, type GestureStateChangeEvent, type GestureUpdateEvent, type PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
|
||||
import {useSharedValue, withSpring} from 'react-native-reanimated';
|
||||
|
||||
import {MAX_VELOCITY, MIN_VELOCITY} from '@constants/gallery';
|
||||
import {pagerPanSpringConfig} from '@screens/gallery/animation_config/spring';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {clampVelocity, friction} from '@utils/gallery';
|
||||
|
||||
import {usePagerSharedValues} from '../context';
|
||||
|
||||
export default function usePagerPanGesture() {
|
||||
const {
|
||||
isActive,
|
||||
isPagerInProgress,
|
||||
velocity,
|
||||
getCanSwipe,
|
||||
getNextIndex,
|
||||
pagerX,
|
||||
sharedWidth,
|
||||
toValueAnimation,
|
||||
getPageTranslate,
|
||||
index,
|
||||
onIndexChange,
|
||||
} = usePagerSharedValues();
|
||||
const {allowsOtherGestures} = useLightboxSharedValues();
|
||||
|
||||
const offset = useSharedValue<number | null>(null);
|
||||
|
||||
const shouldHandleEvent = (evt: GestureStateChangeEvent<PanGestureHandlerEventPayload> | GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
|
||||
'worklet';
|
||||
return (
|
||||
allowsOtherGestures() &&
|
||||
evt.numberOfPointers === 1 &&
|
||||
isActive.value &&
|
||||
Math.abs(evt.velocityX) > Math.abs(evt.velocityY)
|
||||
) || isPagerInProgress.value;
|
||||
};
|
||||
|
||||
return Gesture.Pan().
|
||||
minDistance(5).
|
||||
minVelocityX(0.1).
|
||||
shouldCancelWhenOutside(false).
|
||||
onStart((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
offset.value = evt.translationX;
|
||||
}).
|
||||
onUpdate((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
velocity.value = clampVelocity(
|
||||
evt.velocityX,
|
||||
MIN_VELOCITY,
|
||||
MAX_VELOCITY,
|
||||
);
|
||||
|
||||
if (offset.value === null) {
|
||||
offset.value = evt.translationX < 0 ? evt.translationX : -evt.translationX;
|
||||
}
|
||||
|
||||
const val = evt.translationX - offset.value;
|
||||
const canSwipe = getCanSwipe(val);
|
||||
pagerX.value = canSwipe ? val : friction(val);
|
||||
}).
|
||||
onEnd((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
const val = evt.translationX - offset.value!;
|
||||
const nextIndex = getNextIndex(evt.velocityX);
|
||||
const vx = Math.abs(evt.velocityX);
|
||||
const canSwipe = getCanSwipe(val);
|
||||
const translation = Math.abs(val);
|
||||
const isHalf = sharedWidth.value / 3 < translation;
|
||||
const shouldMoveToNextPage = (vx > 400 || isHalf) && canSwipe;
|
||||
|
||||
const spring = pagerPanSpringConfig(evt);
|
||||
|
||||
pagerX.value = withSpring(0, spring);
|
||||
offset.value = null;
|
||||
|
||||
toValueAnimation.value = -(shouldMoveToNextPage ? -getPageTranslate(nextIndex) : -getPageTranslate(index.value));
|
||||
|
||||
if (shouldMoveToNextPage) {
|
||||
index.value = nextIndex;
|
||||
onIndexChange(nextIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
35
app/screens/gallery/pager/gestures/usePagerTapGesture.ts
Normal file
35
app/screens/gallery/pager/gestures/usePagerTapGesture.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture} from 'react-native-gesture-handler';
|
||||
import {cancelAnimation} from 'react-native-reanimated';
|
||||
|
||||
import {usePagerSharedValues} from '../context';
|
||||
|
||||
import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
|
||||
export default function useTransformerPanGesture(pages: GalleryItemType[], hideHeaderAndFooter: (hidden?: boolean) => void) {
|
||||
const {
|
||||
activeIndex,
|
||||
offsetX,
|
||||
toValueAnimation,
|
||||
index,
|
||||
getPageTranslate,
|
||||
} = usePagerSharedValues();
|
||||
|
||||
return Gesture.Tap().
|
||||
enabled(pages[activeIndex].type === 'image').
|
||||
maxDeltaX(10).
|
||||
maxDeltaY(10).
|
||||
onStart((evt) => {
|
||||
if (evt.numberOfPointers === 1) {
|
||||
cancelAnimation(offsetX);
|
||||
}
|
||||
}).
|
||||
onEnd((evt) => {
|
||||
if (evt.numberOfPointers === 1) {
|
||||
toValueAnimation.value = getPageTranslate(index.value);
|
||||
hideHeaderAndFooter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,74 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {PanGestureHandler, type PanGestureHandlerGestureEvent, TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated, {cancelAnimation, runOnJS, type SharedValue, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, type WithSpringConfig, useAnimatedReaction} from 'react-native-reanimated';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {runOnJS, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated';
|
||||
|
||||
import {useAnimatedGestureHandler} from '@hooks/gallery';
|
||||
import {clampVelocity, friction, getShouldRender, workletNoop, workletNoopTrue} from '@utils/gallery';
|
||||
import {pagerSpringVelocityConfig} from '../animation_config/spring';
|
||||
|
||||
import Page, {type PageRefs, type RenderPageProps} from './page';
|
||||
import {PagerProvider, type PagerSharedValues} from './context';
|
||||
import {PagerContent, type PagerContentProps} from './pager';
|
||||
|
||||
import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
|
||||
export interface PagerReusableProps {
|
||||
export interface PagerProps extends PagerContentProps {
|
||||
gutterWidth?: number;
|
||||
initialDiffValue?: number;
|
||||
numToRender?: number;
|
||||
onGesture?: (event: PanGestureHandlerGestureEvent['nativeEvent'], isActive: SharedValue<boolean>) => void;
|
||||
onEnabledGesture?: (event: PanGestureHandlerGestureEvent['nativeEvent']) => void;
|
||||
onIndexChange?: (nextIndex: number) => void;
|
||||
renderPage: (props: RenderPageProps, index: number) => JSX.Element | null;
|
||||
}
|
||||
|
||||
export interface PagerProps extends PagerReusableProps {
|
||||
initialIndex: number;
|
||||
keyExtractor: (item: GalleryItemType, index: number) => string;
|
||||
pages: GalleryItemType[];
|
||||
shouldHandleGestureEvent?: (event: PanGestureHandlerGestureEvent['nativeEvent']) => boolean;
|
||||
shouldRenderGutter?: boolean;
|
||||
totalCount: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onIndexChange?: (nextIndex: number) => void;
|
||||
}
|
||||
|
||||
const GUTTER_WIDTH = 10;
|
||||
const MIN_VELOCITY = 700;
|
||||
const MAX_VELOCITY = 3000;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pager: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
function getSpringConfig(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,
|
||||
};
|
||||
}
|
||||
|
||||
const Pager = ({
|
||||
gutterWidth = GUTTER_WIDTH, initialDiffValue = 0, initialIndex, keyExtractor,
|
||||
numToRender = 2, onEnabledGesture = workletNoop, onGesture = workletNoop, onIndexChange, pages, renderPage,
|
||||
shouldHandleGestureEvent = workletNoopTrue, shouldRenderGutter = true, totalCount, width, height,
|
||||
gutterWidth = GUTTER_WIDTH, initialIndex,
|
||||
numToRender = 2, onIndexChange, pages, renderPage,
|
||||
shouldRenderGutter = false, totalCount, width, height, hideHeaderAndFooter,
|
||||
}: PagerProps) => {
|
||||
const sharedWidth = useSharedValue(width);
|
||||
const gutterWidthToUse = shouldRenderGutter ? gutterWidth : 0;
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const sharedWidth = useSharedValue(width);
|
||||
const velocity = useSharedValue(0);
|
||||
const isActive = useSharedValue(true);
|
||||
const index = useSharedValue(initialIndex);
|
||||
const length = useSharedValue(totalCount);
|
||||
const pagerX = useSharedValue(0);
|
||||
const skipAnimation = useSharedValue(false);
|
||||
|
||||
const getPageTranslate = (i: number, w?: number) => {
|
||||
'worklet';
|
||||
|
|
@ -78,44 +40,16 @@ const Pager = ({
|
|||
return -(t + g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sharedWidth.value = width;
|
||||
}, [width]);
|
||||
const toValueAnimation = useSharedValue(getPageTranslate(initialIndex, width));
|
||||
|
||||
const pagerRef = useRef(null);
|
||||
const tapRef = useRef(null);
|
||||
|
||||
const isActive = useSharedValue(true);
|
||||
|
||||
function onPageStateChange(value: boolean) {
|
||||
'worklet';
|
||||
|
||||
isActive.value = value;
|
||||
}
|
||||
|
||||
const velocity = useSharedValue(0);
|
||||
const [diffValue, setDiffValue] = useState(initialDiffValue);
|
||||
|
||||
useEffect(() => {
|
||||
setDiffValue(numToRender);
|
||||
}, [numToRender]);
|
||||
|
||||
// S2: Pager Size & Others
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const activeIndexRef = useSharedValue(activeIndex);
|
||||
|
||||
const updateIndex = (nextIndex: number) => {
|
||||
setActiveIndex(nextIndex);
|
||||
activeIndexRef.value = nextIndex;
|
||||
};
|
||||
|
||||
const index = useSharedValue(initialIndex);
|
||||
const length = useSharedValue(totalCount);
|
||||
const pagerX = useSharedValue(0);
|
||||
const toValueAnimation = useSharedValue(getPageTranslate(initialIndex));
|
||||
const totalWidth = useDerivedValue(() => ((length.value * width) + ((gutterWidthToUse * length.value) - 2)), [width, gutterWidthToUse]);
|
||||
|
||||
const offsetX = useDerivedValue(() => {
|
||||
const config = getSpringConfig(velocity.value);
|
||||
if (skipAnimation.value) {
|
||||
return toValueAnimation.value;
|
||||
}
|
||||
|
||||
const config = pagerSpringVelocityConfig(velocity.value);
|
||||
return withSpring(
|
||||
toValueAnimation.value,
|
||||
config,
|
||||
|
|
@ -128,7 +62,14 @@ const Pager = ({
|
|||
},
|
||||
);
|
||||
}, []);
|
||||
const totalWidth = useDerivedValue(() => ((length.value * width) + ((gutterWidthToUse * length.value) - 2)), [width]);
|
||||
|
||||
const isPagerInProgress = useDerivedValue(() => {
|
||||
return Math.floor(Math.abs(getPageTranslate(index.value))) !== Math.floor(Math.abs(offsetX.value + pagerX.value));
|
||||
}, []);
|
||||
|
||||
const updateIndex = (nextIndex: number) => {
|
||||
setActiveIndex(nextIndex);
|
||||
};
|
||||
|
||||
const onIndexChangeCb = useCallback((nextIndex: number) => {
|
||||
'worklet';
|
||||
|
|
@ -140,250 +81,59 @@ const Pager = ({
|
|||
runOnJS(updateIndex)(nextIndex);
|
||||
}, []);
|
||||
|
||||
const sharedValues: PagerSharedValues = useMemo(() => ({
|
||||
sharedWidth,
|
||||
gutterWidthToUse,
|
||||
isActive,
|
||||
velocity,
|
||||
index,
|
||||
length,
|
||||
offsetX,
|
||||
pagerX,
|
||||
toValueAnimation,
|
||||
totalWidth,
|
||||
activeIndex,
|
||||
onIndexChange: onIndexChangeCb,
|
||||
getPageTranslate,
|
||||
isPagerInProgress,
|
||||
|
||||
// the rest of the values are shared values,
|
||||
// so they don't need to be included in the deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [gutterWidthToUse, activeIndex, onIndexChangeCb]);
|
||||
|
||||
useEffect(() => {
|
||||
skipAnimation.value = true;
|
||||
sharedWidth.value = width;
|
||||
const timer = setTimeout(() => {
|
||||
skipAnimation.value = false;
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
index.value = initialIndex;
|
||||
onIndexChangeCb(initialIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialIndex]);
|
||||
|
||||
useAnimatedReaction(() => sharedWidth.value, (current, previous) => {
|
||||
if (current !== previous) {
|
||||
toValueAnimation.value = (getPageTranslate(index.value));
|
||||
}
|
||||
});
|
||||
|
||||
// S3 Pager Interaction
|
||||
function getCanSwipe(currentTranslate = 0) {
|
||||
'worklet';
|
||||
|
||||
const nextTranslate = offsetX.value + currentTranslate;
|
||||
|
||||
if (nextTranslate > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalTranslate = (sharedWidth.value * (length.value - 1)) + (gutterWidthToUse * (length.value - 1));
|
||||
|
||||
if (Math.abs(nextTranslate) >= totalTranslate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const getNextIndex = (v: number) => {
|
||||
'worklet';
|
||||
|
||||
const currentTranslate = Math.abs(getPageTranslate(index.value));
|
||||
const currentIndex = index.value;
|
||||
const currentOffset = Math.abs(offsetX.value);
|
||||
|
||||
const nextIndex = v < 0 ? currentIndex + 1 : currentIndex - 1;
|
||||
|
||||
if (nextIndex < currentIndex && currentOffset > currentTranslate) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
if (nextIndex > currentIndex && currentOffset < currentTranslate) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
if (nextIndex > length.value - 1 || nextIndex < 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
};
|
||||
|
||||
const isPagerInProgress = useDerivedValue(() => {
|
||||
return Math.floor(Math.abs(getPageTranslate(index.value))) !== Math.floor(Math.abs(offsetX.value + pagerX.value));
|
||||
}, []);
|
||||
|
||||
const onPan = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, {pagerActive: boolean; offsetX: null | number}>({
|
||||
onGesture: (evt) => {
|
||||
onGesture(evt, isActive);
|
||||
|
||||
if (isActive.value && !isPagerInProgress.value) {
|
||||
onEnabledGesture(evt);
|
||||
}
|
||||
},
|
||||
|
||||
onInit: (_, ctx) => {
|
||||
ctx.offsetX = null;
|
||||
},
|
||||
|
||||
shouldHandleEvent: (evt) => {
|
||||
return (
|
||||
(evt.numberOfPointers === 1 &&
|
||||
isActive.value &&
|
||||
Math.abs(evt.velocityX) > Math.abs(evt.velocityY) &&
|
||||
shouldHandleGestureEvent(evt)) ||
|
||||
isPagerInProgress.value
|
||||
);
|
||||
},
|
||||
|
||||
onEvent: (evt) => {
|
||||
velocity.value = clampVelocity(
|
||||
evt.velocityX,
|
||||
MIN_VELOCITY,
|
||||
MAX_VELOCITY,
|
||||
);
|
||||
},
|
||||
|
||||
onStart: (_, ctx) => {
|
||||
ctx.offsetX = null;
|
||||
},
|
||||
|
||||
onActive: (evt, ctx) => {
|
||||
if (ctx.offsetX === null) {
|
||||
ctx.offsetX = evt.translationX < 0 ? evt.translationX : -evt.translationX;
|
||||
}
|
||||
|
||||
const val = evt.translationX - ctx.offsetX;
|
||||
|
||||
const canSwipe = getCanSwipe(val);
|
||||
pagerX.value = canSwipe ? val : friction(val);
|
||||
},
|
||||
|
||||
onEnd: (evt, ctx) => {
|
||||
const val = evt.translationX - ctx.offsetX!;
|
||||
const nextIndex = getNextIndex(evt.velocityX);
|
||||
const vx = Math.abs(evt.velocityX);
|
||||
const canSwipe = getCanSwipe(val);
|
||||
const translation = Math.abs(val);
|
||||
const isHalf = sharedWidth.value / 2 < translation;
|
||||
const shouldMoveToNextPage = (vx > 10 || isHalf) && canSwipe;
|
||||
|
||||
pagerX.value = 0;
|
||||
|
||||
// we invert the value since the translationY is left to right
|
||||
toValueAnimation.value = -(shouldMoveToNextPage ? -getPageTranslate(nextIndex) : -getPageTranslate(index.value));
|
||||
|
||||
if (shouldMoveToNextPage) {
|
||||
index.value = nextIndex;
|
||||
onIndexChangeCb(nextIndex);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onTap = useAnimatedGestureHandler({
|
||||
shouldHandleEvent: (evt) => {
|
||||
return evt.numberOfPointers === 1 && isActive.value;
|
||||
},
|
||||
|
||||
onStart: () => {
|
||||
cancelAnimation(offsetX);
|
||||
},
|
||||
|
||||
onEnd: () => {
|
||||
toValueAnimation.value = getPageTranslate(index.value);
|
||||
},
|
||||
});
|
||||
|
||||
const pagerStyles = useAnimatedStyle(() => {
|
||||
const translateX = pagerX.value + offsetX.value;
|
||||
|
||||
return {
|
||||
width: totalWidth.value,
|
||||
transform: [
|
||||
{
|
||||
translateX,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pagerRefs = useMemo<PageRefs>(() => [pagerRef, tapRef], []);
|
||||
|
||||
const pagesToRender = useMemo(() => {
|
||||
const temp = [];
|
||||
|
||||
for (let i = 0; i < totalCount; i += 1) {
|
||||
let itemToUse;
|
||||
|
||||
if (Array.isArray(pages)) {
|
||||
itemToUse = pages[i];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldRender = getShouldRender(i, activeIndex, diffValue);
|
||||
|
||||
if (shouldRender) {
|
||||
temp.push(
|
||||
<Page
|
||||
key={keyExtractor(itemToUse, i)}
|
||||
item={itemToUse}
|
||||
currentIndex={index}
|
||||
pagerRefs={pagerRefs}
|
||||
onPageStateChange={onPageStateChange}
|
||||
index={i}
|
||||
length={totalCount}
|
||||
gutterWidth={gutterWidthToUse}
|
||||
<PagerProvider sharedValues={sharedValues}>
|
||||
<PagerContent
|
||||
totalCount={totalCount}
|
||||
pages={pages}
|
||||
renderPage={renderPage}
|
||||
getPageTranslate={getPageTranslate}
|
||||
numToRender={numToRender}
|
||||
shouldRenderGutter={shouldRenderGutter}
|
||||
width={width}
|
||||
height={height}
|
||||
isPagerInProgress={isPagerInProgress}
|
||||
shouldRenderGutter={shouldRenderGutter}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
temp.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
return temp;
|
||||
}, [
|
||||
activeIndex,
|
||||
keyExtractor,
|
||||
totalCount,
|
||||
pages,
|
||||
getShouldRender,
|
||||
index,
|
||||
pagerRefs,
|
||||
onPageStateChange,
|
||||
gutterWidthToUse,
|
||||
renderPage,
|
||||
getPageTranslate,
|
||||
width,
|
||||
isPagerInProgress,
|
||||
shouldRenderGutter,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill]}>
|
||||
<PanGestureHandler
|
||||
ref={pagerRef}
|
||||
minVelocityX={0.1}
|
||||
activeOffsetX={[-4, 4]}
|
||||
activeOffsetY={[-4, 4]}
|
||||
simultaneousHandlers={[tapRef]}
|
||||
onGestureEvent={onPan}
|
||||
>
|
||||
<Animated.View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler
|
||||
enabled={pages[activeIndex].type === 'image'}
|
||||
ref={tapRef}
|
||||
maxDeltaX={10}
|
||||
maxDeltaY={10}
|
||||
simultaneousHandlers={pagerRef}
|
||||
onGestureEvent={onTap}
|
||||
>
|
||||
<Animated.View
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<Animated.View style={StyleSheet.absoluteFill}>
|
||||
<Animated.View style={[styles.pager, pagerStyles]}>
|
||||
{pagesToRender}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</Animated.View>
|
||||
</View>
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
/>
|
||||
</PagerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,39 +9,15 @@ import {typedMemo} from '@utils/gallery';
|
|||
|
||||
import Gutter from './gutter';
|
||||
|
||||
import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
import type {PanGestureHandler, TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import type {GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
export type PageRefs = [
|
||||
React.Ref<TapGestureHandler>,
|
||||
React.Ref<PanGestureHandler>,
|
||||
];
|
||||
|
||||
export interface RenderPageProps {
|
||||
index: number;
|
||||
pagerRefs: PageRefs;
|
||||
onPageStateChange: (value: boolean) => void;
|
||||
item: GalleryItemType;
|
||||
width: number;
|
||||
height: number;
|
||||
isPageActive: SharedValue<boolean>;
|
||||
isPagerInProgress: SharedValue<boolean>;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
item: GalleryItemType;
|
||||
pagerRefs: PageRefs;
|
||||
onPageStateChange: (value: boolean) => void;
|
||||
interface PageProps extends GalleryPagerItem {
|
||||
gutterWidth: number;
|
||||
index: number;
|
||||
length: number;
|
||||
renderPage: (props: RenderPageProps, index: number) => JSX.Element | null;
|
||||
renderPage: (props: GalleryPagerItem, index: number) => JSX.Element | null;
|
||||
shouldRenderGutter: boolean;
|
||||
getPageTranslate: (index: number, width?: number) => number;
|
||||
width: number;
|
||||
height: number;
|
||||
currentIndex: SharedValue<number>;
|
||||
isPagerInProgress: SharedValue<boolean>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -61,10 +37,9 @@ const styles = StyleSheet.create({
|
|||
const Page = typedMemo(
|
||||
({
|
||||
currentIndex, getPageTranslate, gutterWidth, index, isPagerInProgress, item, length,
|
||||
onPageStateChange, pagerRefs, renderPage, shouldRenderGutter, width, height,
|
||||
onPageStateChange, renderPage, shouldRenderGutter, width, height, pagerPanGesture, pagerTapGesture, lightboxPanGesture,
|
||||
}: PageProps) => {
|
||||
const isPageActive = useDerivedValue(() => (currentIndex.value === index), []);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, {left: -getPageTranslate(index, width)}]}>
|
||||
<View style={[styles.center, {width}]}>
|
||||
|
|
@ -76,8 +51,10 @@ const Page = typedMemo(
|
|||
width,
|
||||
isPageActive,
|
||||
isPagerInProgress,
|
||||
pagerRefs,
|
||||
height,
|
||||
pagerPanGesture,
|
||||
pagerTapGesture,
|
||||
lightboxPanGesture,
|
||||
},
|
||||
index,
|
||||
)}
|
||||
|
|
|
|||
137
app/screens/gallery/pager/pager.tsx
Normal file
137
app/screens/gallery/pager/pager.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||
import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated';
|
||||
|
||||
import {getShouldRender} from '@utils/gallery';
|
||||
|
||||
import {usePagerSharedValues} from './context';
|
||||
import useLightboxPanGesture from './gestures/useLightboxPanGesture';
|
||||
import usePagerPanGesture from './gestures/usePagerPanGesture';
|
||||
import usePagerTapGesture from './gestures/usePagerTapGesture';
|
||||
import Page from './page';
|
||||
|
||||
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
export type PagerContentProps = {
|
||||
totalCount: number;
|
||||
pages: GalleryItemType[];
|
||||
renderPage: (props: GalleryPagerItem, index: number) => JSX.Element | null;
|
||||
shouldRenderGutter: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
numToRender?: number;
|
||||
hideHeaderAndFooter: (hidden?: boolean) => void;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pager: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
export function PagerContent({
|
||||
totalCount, pages, renderPage, shouldRenderGutter,
|
||||
width, height, numToRender = 2, hideHeaderAndFooter,
|
||||
}: PagerContentProps) {
|
||||
const sharedValues = usePagerSharedValues();
|
||||
const [diffValue, setDiffValue] = useState(0);
|
||||
|
||||
const {
|
||||
pagerX, offsetX, sharedWidth,
|
||||
totalWidth, getPageTranslate, toValueAnimation,
|
||||
index, activeIndex, onPageStateChange,
|
||||
gutterWidthToUse, isPagerInProgress,
|
||||
} = sharedValues;
|
||||
|
||||
const panGesture = usePagerPanGesture();
|
||||
const lightboxPanGesture = useLightboxPanGesture();
|
||||
const tapGesture = usePagerTapGesture(pages, hideHeaderAndFooter);
|
||||
|
||||
useEffect(() => {
|
||||
setDiffValue(numToRender);
|
||||
}, [numToRender]);
|
||||
|
||||
useAnimatedReaction(() => sharedWidth.value, (current, previous) => {
|
||||
if (current !== previous) {
|
||||
toValueAnimation.value = (getPageTranslate(index.value));
|
||||
}
|
||||
});
|
||||
|
||||
const pagerStyles = useAnimatedStyle(() => {
|
||||
const translateX = pagerX.value + offsetX.value;
|
||||
|
||||
return {
|
||||
width: totalWidth.value,
|
||||
transform: [
|
||||
{
|
||||
translateX,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const pagesToRender = useMemo(() => {
|
||||
const temp = [];
|
||||
|
||||
for (let i = 0; i < totalCount; i += 1) {
|
||||
let itemToUse;
|
||||
|
||||
if (Array.isArray(pages)) {
|
||||
itemToUse = pages[i];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldRender = getShouldRender(i, activeIndex, diffValue);
|
||||
|
||||
if (shouldRender) {
|
||||
temp.push(
|
||||
<Page
|
||||
key={itemToUse.id}
|
||||
item={itemToUse}
|
||||
currentIndex={index}
|
||||
onPageStateChange={onPageStateChange}
|
||||
index={i}
|
||||
length={totalCount}
|
||||
gutterWidth={gutterWidthToUse}
|
||||
renderPage={renderPage}
|
||||
getPageTranslate={getPageTranslate}
|
||||
width={width}
|
||||
height={height}
|
||||
isPagerInProgress={isPagerInProgress}
|
||||
shouldRenderGutter={shouldRenderGutter}
|
||||
pagerPanGesture={panGesture}
|
||||
pagerTapGesture={tapGesture}
|
||||
lightboxPanGesture={lightboxPanGesture}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
temp.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
return temp;
|
||||
|
||||
// The missing dependencies are intentional as they are SharedValues or DerivedValues
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
totalCount, pages, activeIndex, diffValue, onPageStateChange,
|
||||
gutterWidthToUse, renderPage, getPageTranslate,
|
||||
width, height, shouldRenderGutter, panGesture, tapGesture, lightboxPanGesture,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<GestureDetector gesture={Gesture.Simultaneous(panGesture, lightboxPanGesture, tapGesture)}>
|
||||
<Animated.View style={[styles.pager, pagerStyles]}>
|
||||
{pagesToRender}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,26 +4,25 @@
|
|||
import React, {useCallback, useMemo, useState} from 'react';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native';
|
||||
import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler';
|
||||
import {RectButton, Pressable} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import {Events, Preferences} from '@constants';
|
||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {isDocument, isPdf} from '@utils/file';
|
||||
import {galleryItemToFileInfo} from '@utils/gallery';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import DownloadWithAction from '../footer/download_with_action';
|
||||
|
||||
import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
|
||||
|
||||
type Props = {
|
||||
canDownloadFiles: boolean;
|
||||
enableSecureFilePreview: boolean;
|
||||
item: GalleryItemType;
|
||||
onShouldHideControls: (hide: boolean) => void;
|
||||
hideHeaderAndFooter: (hide?: boolean) => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -65,10 +64,9 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onShouldHideControls}: Props) => {
|
||||
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hideHeaderAndFooter}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const file = useMemo(() => galleryItemToFileInfo(item), [item]);
|
||||
const [controls, setControls] = useState(true);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const isSupported = useMemo(() => isDocument(file), [file]);
|
||||
const canOpenFile = useMemo(() => {
|
||||
|
|
@ -91,11 +89,6 @@ const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onSh
|
|||
return formatMessage(messages.openFile);
|
||||
}, [enableSecureFilePreview, file, formatMessage, isSupported]);
|
||||
|
||||
const handleHideControls = useCallback(() => {
|
||||
onShouldHideControls(controls);
|
||||
setControls(!controls);
|
||||
}, [controls, onShouldHideControls]);
|
||||
|
||||
const setGalleryAction = useCallback((action: GalleryAction) => {
|
||||
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
|
||||
if (action === 'none') {
|
||||
|
|
@ -110,12 +103,15 @@ const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onSh
|
|||
const handlePdfPreview = useCallback(() => {
|
||||
if (enableSecureFilePreview && isPdf(file)) {
|
||||
DeviceEventEmitter.emit(Events.CLOSE_GALLERY);
|
||||
return;
|
||||
}
|
||||
}, [file, enableSecureFilePreview]);
|
||||
|
||||
hideHeaderAndFooter();
|
||||
}, [file, enableSecureFilePreview, hideHeaderAndFooter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableWithoutFeedback onPress={handleHideControls}>
|
||||
<Pressable onPress={handlePdfPreview}>
|
||||
<Animated.View style={styles.container}>
|
||||
<FileIcon
|
||||
backgroundColor='transparent'
|
||||
|
|
@ -146,7 +142,7 @@ const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onSh
|
|||
</View>
|
||||
}
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Pressable>
|
||||
{!enabled &&
|
||||
<DownloadWithAction
|
||||
action='opening'
|
||||
|
|
@ -5,7 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
|||
|
||||
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
|
||||
|
||||
import DocumentRenderer from './document_renderer';
|
||||
import DocumentRenderer from './document';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
284
app/screens/gallery/renderers/image/context/index.tsx
Normal file
284
app/screens/gallery/renderers/image/context/index.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useContext} from 'react';
|
||||
import {useDerivedValue, withDecay, withSpring, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {DOUBLE_TAP_SCALE, MAX_SCALE, MIN_SCALE} from '@constants/gallery';
|
||||
import {transformerSpringConfig} from '@screens/gallery/animation_config/spring';
|
||||
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
import {clamp} from '@utils/gallery';
|
||||
import * as vec from '@utils/gallery/vectors';
|
||||
|
||||
import type {WindowDimensions} from '@mattermost/rnutils';
|
||||
|
||||
export type TransformerSharedValues = {
|
||||
isPagerInProgress: SharedValue<boolean>;
|
||||
interactionsEnabled: SharedValue<boolean>;
|
||||
scale: SharedValue<number>;
|
||||
scaleOffset: SharedValue<number>;
|
||||
translation: vec.ShareVectorType<number>;
|
||||
panVelocity: vec.ShareVectorType<number>;
|
||||
scaleTranslation: vec.ShareVectorType<number>;
|
||||
offset: vec.ShareVectorType<number>;
|
||||
canvas: vec.Vector<number>;
|
||||
image: vec.Vector<number>;
|
||||
targetDimensions: WindowDimensions;
|
||||
targetHeight: number;
|
||||
};
|
||||
|
||||
const TransformerContext = React.createContext<TransformerSharedValues | null>(null);
|
||||
|
||||
export const TransfrormerProvider: React.FC<{sharedValues: TransformerSharedValues; children: React.ReactNode}> = ({children, sharedValues}) => {
|
||||
return (
|
||||
<TransformerContext.Provider value={sharedValues}>
|
||||
{children}
|
||||
</TransformerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTransformerSharedValues = () => {
|
||||
const context = useContext(TransformerContext);
|
||||
if (!context) {
|
||||
throw new Error('useTransformerSharedValues must be used within a TransformerProvider');
|
||||
}
|
||||
|
||||
const {
|
||||
canvas, image, offset, panVelocity, scale, scaleOffset, scaleTranslation,
|
||||
targetDimensions, targetHeight, translation,
|
||||
} = context;
|
||||
|
||||
// This derived value tells us if the image can be panned vertically (if the scaled image is taller than the viewport)
|
||||
const canPanVertically = useDerivedValue(() => {
|
||||
return targetDimensions.height < targetHeight * scale.value;
|
||||
}, []);
|
||||
|
||||
// Handles double-tap zoom logic, keeping the tapped point under the finger and preventing blank space
|
||||
function handleScaleTo(x: number, y: number) {
|
||||
'worklet';
|
||||
|
||||
const previousScale = scale.value;
|
||||
const nextScale = DOUBLE_TAP_SCALE;
|
||||
const scaleFactor = nextScale / previousScale;
|
||||
|
||||
scale.value = withTiming(nextScale, transformerTimingConfig);
|
||||
scaleOffset.value = nextScale;
|
||||
|
||||
// Calculate the new image size after scaling
|
||||
const newImageSize = vec.multiply(image, nextScale);
|
||||
|
||||
// Get the current pan offset
|
||||
const currentOffset = vec.create(offset.x.value, offset.y.value);
|
||||
|
||||
// Calculate the image's position in the canvas (centered)
|
||||
const imageLeft = ((canvas.x - image.x) / 2) + currentOffset.x;
|
||||
const imageTop = ((canvas.y - image.y) / 2) + currentOffset.y;
|
||||
const imageRight = imageLeft + image.x;
|
||||
const imageBottom = imageTop + image.y;
|
||||
|
||||
// Clamp the tap point to the image bounds so we never zoom to blank space
|
||||
const clampedX = Math.max(imageLeft, Math.min(x, imageRight));
|
||||
const clampedY = Math.max(imageTop, Math.min(y, imageBottom));
|
||||
|
||||
// Calculate how far the tap is from the image center
|
||||
const focalRelativeToCenter = vec.sub(
|
||||
vec.create(clampedX, clampedY),
|
||||
vec.create((canvas.x / 2) + currentOffset.x, (canvas.y / 2) + currentOffset.y),
|
||||
);
|
||||
|
||||
// Calculate how much that distance changes after scaling
|
||||
const scaledFocal = vec.multiply(focalRelativeToCenter, scaleFactor);
|
||||
|
||||
// Calculate the new offset so the tapped point stays under the finger
|
||||
const newOffset = vec.add(
|
||||
currentOffset,
|
||||
vec.sub(focalRelativeToCenter, scaledFocal),
|
||||
);
|
||||
|
||||
// For tall images, reduce vertical drift a bit
|
||||
if (newImageSize.y > canvas.y) {
|
||||
newOffset.y *= 0.85;
|
||||
}
|
||||
|
||||
// Calculate the offset needed to center the focal point after scaling
|
||||
let targetOffsetX = (canvas.x / 2) - (((clampedX - imageLeft) * scaleFactor) + ((canvas.x - newImageSize.x) / 2));
|
||||
let targetOffsetY = (canvas.y / 2) - (((clampedY - imageTop) * scaleFactor) + ((canvas.y - newImageSize.y) / 2));
|
||||
|
||||
// Don't allow the image to pan so far that blank space appears
|
||||
const maxOffsetX = Math.max(0, (newImageSize.x - canvas.x) / 2);
|
||||
const maxOffsetY = Math.max(0, (newImageSize.y - canvas.y) / 2);
|
||||
|
||||
targetOffsetX = clamp(targetOffsetX, -maxOffsetX, maxOffsetX);
|
||||
targetOffsetY = clamp(targetOffsetY, -maxOffsetY, maxOffsetY);
|
||||
|
||||
// Animate the image to the new offset
|
||||
offset.x.value = withTiming(targetOffsetX, transformerTimingConfig);
|
||||
offset.y.value = withTiming(targetOffsetY, transformerTimingConfig);
|
||||
|
||||
// The following block tries to ensure the focal point is visible after zooming.
|
||||
// It adjusts the offset if the focal point is outside the canvas after the initial calculation.
|
||||
const zoomedImageLeft = ((canvas.x - newImageSize.x) / 2) + newOffset.x;
|
||||
const zoomedImageTop = ((canvas.y - newImageSize.y) / 2) + newOffset.y;
|
||||
const focalXInCanvas = ((clampedX - imageLeft) * scaleFactor) + zoomedImageLeft;
|
||||
const focalYInCanvas = ((clampedY - imageTop) * scaleFactor) + zoomedImageTop;
|
||||
|
||||
let adjustX = 0;
|
||||
let adjustY = 0;
|
||||
if (focalXInCanvas < 0) {
|
||||
adjustX = -focalXInCanvas;
|
||||
} else if (focalXInCanvas > canvas.x) {
|
||||
adjustX = canvas.x - focalXInCanvas;
|
||||
}
|
||||
if (focalYInCanvas < 0) {
|
||||
adjustY = -focalYInCanvas;
|
||||
} else if (focalYInCanvas > canvas.y) {
|
||||
adjustY = canvas.y - focalYInCanvas;
|
||||
}
|
||||
|
||||
// If adjustment is needed, apply it and clamp again to keep the image within bounds
|
||||
if (adjustX !== 0 || adjustY !== 0) {
|
||||
newOffset.x = clamp(newOffset.x + adjustX, -maxOffsetX, maxOffsetX);
|
||||
newOffset.y = clamp(newOffset.y + adjustY, -maxOffsetY, maxOffsetY);
|
||||
|
||||
// Recalculate focal point position after adjustment and clamp again if needed
|
||||
const zoomedImageLeft2 = ((canvas.x - newImageSize.x) / 2) + newOffset.x;
|
||||
const zoomedImageTop2 = ((canvas.y - newImageSize.y) / 2) + newOffset.y;
|
||||
const focalXInCanvas2 = ((clampedX - imageLeft) * scaleFactor) + zoomedImageLeft2;
|
||||
const focalYInCanvas2 = ((clampedY - imageTop) * scaleFactor) + zoomedImageTop2;
|
||||
|
||||
if (focalXInCanvas2 < 0) {
|
||||
newOffset.x = clamp(newOffset.x - focalXInCanvas2, -maxOffsetX, maxOffsetX);
|
||||
} else if (focalXInCanvas2 > canvas.x) {
|
||||
newOffset.x = clamp(newOffset.x + (canvas.x - focalXInCanvas2), -maxOffsetX, maxOffsetX);
|
||||
}
|
||||
if (focalYInCanvas2 < 0) {
|
||||
newOffset.y = clamp(newOffset.y - focalYInCanvas2, -maxOffsetY, maxOffsetY);
|
||||
} else if (focalYInCanvas2 > canvas.y) {
|
||||
newOffset.y = clamp(newOffset.y + (canvas.y - focalYInCanvas2), -maxOffsetY, maxOffsetY);
|
||||
}
|
||||
|
||||
offset.x.value = withTiming(newOffset.x, transformerTimingConfig);
|
||||
offset.y.value = withTiming(newOffset.y, transformerTimingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// This function is called at the end of a pan or zoom gesture to "settle" the image in a natural way
|
||||
function maybeRunOnEnd() {
|
||||
'worklet';
|
||||
|
||||
// Target is the centered position (no offset)
|
||||
const target = vec.create(0, 0);
|
||||
|
||||
// Clamp the scale to allowed min/max
|
||||
const fixedScale = clamp(MIN_SCALE, scale.value, MAX_SCALE);
|
||||
|
||||
// Calculate the scaled image height
|
||||
const scaledImage = (targetHeight * fixedScale);
|
||||
|
||||
// Calculate how far the image can move horizontally
|
||||
const rightBoundary = (canvas.x / 2) * (fixedScale - 1);
|
||||
|
||||
let topBoundary = 0;
|
||||
|
||||
// If the image is taller than the canvas, calculate how far it can move vertically
|
||||
if (canvas.y < scaledImage) {
|
||||
topBoundary = Math.abs(scaledImage - canvas.y) / 2;
|
||||
}
|
||||
|
||||
// These vectors represent the max and min allowed pan positions
|
||||
const maxVector = vec.create(rightBoundary, topBoundary);
|
||||
const minVector = vec.invert(maxVector);
|
||||
|
||||
// If the image can't be panned vertically, animate it back to center vertically
|
||||
if (!canPanVertically.value) {
|
||||
offset.y.value = withSpring(target.y, transformerSpringConfig);
|
||||
}
|
||||
|
||||
// If everything is already centered and not zoomed, no need to animate
|
||||
if (
|
||||
vec.eq(offset, 0) &&
|
||||
vec.eq(translation, 0) &&
|
||||
vec.eq(scaleTranslation, 0) &&
|
||||
scale.value === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If zoomed out, animate everything back to center
|
||||
if (scale.value <= 1) {
|
||||
vec.set(offset, () => withTiming(0, transformerTimingConfig));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp the offset to the allowed pan range
|
||||
vec.set(target, vec.clamp(offset, minVector, maxVector));
|
||||
|
||||
const deceleration = 0.998;
|
||||
|
||||
// If already at the horizontal boundary, allow a decay animation for a natural feel
|
||||
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,
|
||||
velocityFactor: 1.5,
|
||||
clamp: [minVector.x, maxVector.x],
|
||||
deceleration,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
offset.x.value = withSpring(target.x, transformerSpringConfig);
|
||||
}
|
||||
|
||||
// If already at the vertical boundary, allow a decay animation for a natural feel
|
||||
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,
|
||||
velocityFactor: 1.5,
|
||||
clamp: [minVector.y, maxVector.y],
|
||||
deceleration,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
offset.y.value = withSpring(target.y, transformerSpringConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Resets all shared values to their initial state, optionally with animation
|
||||
function resetSharedState(animated?: boolean) {
|
||||
'worklet';
|
||||
|
||||
if (animated) {
|
||||
scale.value = withTiming(1, transformerTimingConfig);
|
||||
scaleOffset.value = 1;
|
||||
|
||||
vec.set(offset, () => withTiming(0, transformerTimingConfig));
|
||||
vec.set(translation, () => withTiming(0, transformerTimingConfig));
|
||||
vec.set(scaleTranslation, () => withTiming(0, transformerTimingConfig));
|
||||
} else {
|
||||
scale.value = 1;
|
||||
scaleOffset.value = 1;
|
||||
vec.set(translation, 0);
|
||||
vec.set(scaleTranslation, 0);
|
||||
vec.set(offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
handleScaleTo,
|
||||
maybeRunOnEnd,
|
||||
resetSharedState,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture, type GestureUpdateEvent, type TapGestureHandlerEventPayload} from 'react-native-gesture-handler';
|
||||
|
||||
import {useTransformerSharedValues} from '../context';
|
||||
|
||||
export default function useTransformerDoubleTap(enabled: boolean) {
|
||||
const {interactionsEnabled, isPagerInProgress, scale, resetSharedState, handleScaleTo} = useTransformerSharedValues();
|
||||
|
||||
const shouldHandleEvent = (evt: GestureUpdateEvent<TapGestureHandlerEventPayload>) => {
|
||||
'worklet';
|
||||
return evt.numberOfPointers === 1 && interactionsEnabled.value && !isPagerInProgress.value;
|
||||
};
|
||||
|
||||
return Gesture.Tap().
|
||||
enabled(enabled).
|
||||
numberOfTaps(2).
|
||||
maxDuration(500).
|
||||
maxDeltaX(16).
|
||||
maxDeltaY(16).
|
||||
onStart((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scale.value > 1) {
|
||||
resetSharedState(true);
|
||||
} else {
|
||||
handleScaleTo(evt.x, evt.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture} from 'react-native-gesture-handler';
|
||||
import {cancelAnimation} from 'react-native-reanimated';
|
||||
|
||||
import * as vec from '@utils/gallery/vectors';
|
||||
|
||||
import {useTransformerSharedValues} from '../context';
|
||||
|
||||
const panMultiplier = 1.3;
|
||||
|
||||
export default function useTransformerPanGesture(enabled: boolean) {
|
||||
const {
|
||||
interactionsEnabled,
|
||||
isPagerInProgress,
|
||||
offset,
|
||||
translation,
|
||||
scale,
|
||||
maybeRunOnEnd,
|
||||
panVelocity,
|
||||
} = useTransformerSharedValues();
|
||||
const panOffset = vec.useSharedVector(0, 0);
|
||||
|
||||
const shouldHandleEvent = () => {
|
||||
'worklet';
|
||||
return scale.value > 1 && interactionsEnabled.value && !isPagerInProgress.value;
|
||||
};
|
||||
|
||||
return Gesture.Pan().
|
||||
enabled(enabled).
|
||||
minDistance(4).
|
||||
onBegin(() => {
|
||||
if (!shouldHandleEvent()) {
|
||||
return;
|
||||
}
|
||||
cancelAnimation(offset.x);
|
||||
cancelAnimation(offset.y);
|
||||
vec.set(panOffset, 0);
|
||||
}).
|
||||
onUpdate((evt) => {
|
||||
if (!shouldHandleEvent()) {
|
||||
return;
|
||||
}
|
||||
const pan = vec.create(evt.translationX * panMultiplier, evt.translationY * panMultiplier);
|
||||
const velocity = vec.create(evt.velocityX, evt.velocityY);
|
||||
vec.set(panVelocity, velocity);
|
||||
|
||||
// Apply calculated translation with offset correction
|
||||
const nextTranslate = vec.add(pan, vec.invert(panOffset));
|
||||
translation.x.value = nextTranslate.x;
|
||||
translation.y.value = nextTranslate.y;
|
||||
}).
|
||||
onEnd((evt) => {
|
||||
if (!shouldHandleEvent()) {
|
||||
return;
|
||||
}
|
||||
vec.set(offset, vec.add(offset, translation));
|
||||
vec.set(translation, 0);
|
||||
vec.set(panOffset, 0);
|
||||
|
||||
maybeRunOnEnd();
|
||||
|
||||
vec.set(panVelocity, vec.create(evt.velocityX, evt.velocityY));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture, type GestureUpdateEvent, type PinchGestureHandlerEventPayload} from 'react-native-gesture-handler';
|
||||
import {cancelAnimation, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {MAX_SCALE, MIN_SCALE, OVER_SCALE} from '@constants/gallery';
|
||||
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
import {clamp} from '@utils/gallery';
|
||||
import * as vec from '@utils/gallery/vectors';
|
||||
|
||||
import {useTransformerSharedValues} from '../context';
|
||||
|
||||
export default function useTransformerPinchGesture(enabled: boolean) {
|
||||
// Get all the shared animated values and helpers from context
|
||||
const {
|
||||
interactionsEnabled,
|
||||
isPagerInProgress,
|
||||
offset,
|
||||
canvas,
|
||||
image,
|
||||
scale,
|
||||
scaleOffset,
|
||||
scaleTranslation,
|
||||
maybeRunOnEnd,
|
||||
resetSharedState,
|
||||
} = useTransformerSharedValues();
|
||||
|
||||
// These vectors and values help track the pinch gesture state
|
||||
const origin = vec.useSharedVector(0, 0); // Where the pinch started, relative to image center
|
||||
const adjustedFocal = vec.useSharedVector(0, 0); // The focal point, adjusted to image/canvas
|
||||
const pinchGestureScale = useSharedValue(1); // The current scale from the pinch gesture
|
||||
const pinchNextScale = useSharedValue(1); // The next scale value to apply
|
||||
|
||||
// Helper to decide if we should handle this pinch event
|
||||
const shouldHandleEvent = (evt: GestureUpdateEvent<PinchGestureHandlerEventPayload>) => {
|
||||
'worklet';
|
||||
|
||||
// Only handle if two fingers, interactions are enabled, and not paging
|
||||
return evt.numberOfPointers === 2 && interactionsEnabled.value && !isPagerInProgress.value;
|
||||
};
|
||||
|
||||
return Gesture.Pinch().
|
||||
enabled(enabled).
|
||||
onStart((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any ongoing pan/zoom animations
|
||||
cancelAnimation(offset.x);
|
||||
cancelAnimation(offset.y);
|
||||
|
||||
// Calculate the image's position in the canvas (centered)
|
||||
const currentOffset = vec.create(offset.x.value, offset.y.value);
|
||||
const imageLeft = ((canvas.x - image.x) / 2) + currentOffset.x;
|
||||
const imageTop = ((canvas.y - image.y) / 2) + currentOffset.y;
|
||||
const imageRight = imageLeft + image.x;
|
||||
const imageBottom = imageTop + image.y;
|
||||
|
||||
// Clamp the pinch focal point so it never goes outside the image
|
||||
// This prevents zooming on blank space from moving the image out of view
|
||||
const clampedFocalX = Math.max(imageLeft, Math.min(evt.focalX, imageRight));
|
||||
const clampedFocalY = Math.max(imageTop, Math.min(evt.focalY, imageBottom));
|
||||
|
||||
// Calculate the focal point relative to the image center
|
||||
const focal = vec.create(clampedFocalX, clampedFocalY);
|
||||
const center = vec.divide(canvas, 2);
|
||||
|
||||
// Set the adjusted focal and origin for the pinch gesture
|
||||
vec.set(adjustedFocal, vec.sub(focal, vec.add(center, offset)));
|
||||
vec.set(origin, adjustedFocal);
|
||||
}).
|
||||
onUpdate((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the next scale value, clamped to allowed min/max
|
||||
pinchNextScale.value = clamp(evt.scale * scaleOffset.value, MIN_SCALE, MAX_SCALE + OVER_SCALE);
|
||||
if (pinchNextScale.value > MIN_SCALE && pinchNextScale.value < MAX_SCALE + OVER_SCALE) {
|
||||
pinchGestureScale.value = evt.scale;
|
||||
}
|
||||
|
||||
// Calculate how much the image should translate as the pinch gesture moves
|
||||
const pinch = vec.sub(adjustedFocal, origin);
|
||||
const nextTranslation = vec.add(pinch, origin, vec.multiply(-1, pinchGestureScale.value, origin));
|
||||
|
||||
// Apply the translation and scale for the pinch
|
||||
vec.set(scaleTranslation, nextTranslation);
|
||||
scale.value = pinchNextScale.value;
|
||||
}).
|
||||
onEnd(() => {
|
||||
// When the pinch ends, update the scale and offset to their final values
|
||||
scaleOffset.value = scale.value;
|
||||
|
||||
// If the scale is less than or equal to 1, reset everything to the initial state
|
||||
if (scaleOffset.value <= 1) {
|
||||
scaleOffset.value = 1;
|
||||
scale.value = withTiming(1, transformerTimingConfig);
|
||||
vec.set(offset, 0);
|
||||
vec.set(scaleTranslation, 0);
|
||||
resetSharedState(true);
|
||||
} else if (scaleOffset.value > MAX_SCALE) {
|
||||
// If the scale is too large, clamp it to the max allowed
|
||||
scaleOffset.value = MAX_SCALE;
|
||||
scale.value = withTiming(MAX_SCALE, transformerTimingConfig);
|
||||
vec.set(offset, vec.add(offset, scaleTranslation));
|
||||
vec.set(scaleTranslation, 0);
|
||||
} else {
|
||||
// Otherwise, just apply the translation and clear it
|
||||
vec.set(offset, vec.add(offset, scaleTranslation));
|
||||
vec.set(scaleTranslation, 0);
|
||||
}
|
||||
|
||||
// Run the logic to "settle" the image in a natural way (e.g., spring back if needed)
|
||||
maybeRunOnEnd();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Gesture, type GestureUpdateEvent, type TapGestureHandlerEventPayload} from 'react-native-gesture-handler';
|
||||
import {cancelAnimation} from 'react-native-reanimated';
|
||||
|
||||
import {useTransformerSharedValues} from '../context';
|
||||
|
||||
export function useTransformerSingleTap(enabled: boolean) {
|
||||
const {interactionsEnabled, isPagerInProgress, offset, scale, maybeRunOnEnd} = useTransformerSharedValues();
|
||||
|
||||
const shouldHandleEvent = (evt: GestureUpdateEvent<TapGestureHandlerEventPayload>) => {
|
||||
'worklet';
|
||||
return evt.numberOfPointers === 1 && interactionsEnabled.value && scale.value === 1 && !isPagerInProgress.value;
|
||||
};
|
||||
|
||||
return Gesture.Tap().
|
||||
enabled(enabled).
|
||||
onStart((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
cancelAnimation(offset.x);
|
||||
cancelAnimation(offset.y);
|
||||
}).
|
||||
onEnd((evt) => {
|
||||
if (!shouldHandleEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
maybeRunOnEnd();
|
||||
});
|
||||
}
|
||||
83
app/screens/gallery/renderers/image/index.tsx
Normal file
83
app/screens/gallery/renderers/image/index.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {useWindowDimensions} from 'react-native';
|
||||
import {useSharedValue} from 'react-native-reanimated';
|
||||
|
||||
import * as vec from '@utils/gallery/vectors';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
|
||||
import {TransfrormerProvider, type TransformerSharedValues} from './context';
|
||||
import ImageTransformer from './transformer';
|
||||
|
||||
import type {GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
function ImageRenderer({
|
||||
height,
|
||||
isPageActive,
|
||||
isPagerInProgress,
|
||||
item,
|
||||
onPageStateChange,
|
||||
width,
|
||||
pagerPanGesture,
|
||||
pagerTapGesture,
|
||||
lightboxPanGesture,
|
||||
}: GalleryPagerItem) {
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const targetDimensions = useMemo(() => ({height, width}), [height, width]);
|
||||
const interactionsEnabled = useSharedValue(false);
|
||||
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(windowDimensions.width, windowDimensions.height);
|
||||
const {width: targetWidth, height: targetHeight} = useMemo(() => calculateDimensions(
|
||||
item.height,
|
||||
item.width,
|
||||
targetDimensions.width,
|
||||
targetDimensions.height,
|
||||
true,
|
||||
), [item.width, item.height, targetDimensions.width, targetDimensions.height]);
|
||||
const image = vec.create(targetWidth, targetHeight);
|
||||
|
||||
const sharedValues: TransformerSharedValues = useMemo(() => ({
|
||||
interactionsEnabled,
|
||||
isPagerInProgress,
|
||||
scale,
|
||||
scaleOffset,
|
||||
translation,
|
||||
panVelocity,
|
||||
offset,
|
||||
scaleTranslation,
|
||||
canvas,
|
||||
image,
|
||||
targetDimensions,
|
||||
targetHeight,
|
||||
|
||||
// the rest of the values are shared values,
|
||||
// so they don't need to be included in the deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [targetDimensions, targetHeight, canvas]);
|
||||
|
||||
return (
|
||||
<TransfrormerProvider sharedValues={sharedValues}>
|
||||
<ImageTransformer
|
||||
isPageActive={isPageActive}
|
||||
targetDimensions={targetDimensions}
|
||||
height={targetHeight}
|
||||
isSvg={item.extension === 'svg'}
|
||||
onPageStateChange={onPageStateChange}
|
||||
source={item.uri}
|
||||
width={targetWidth}
|
||||
pagerPanGesture={pagerPanGesture}
|
||||
pagerTapGesture={pagerTapGesture}
|
||||
lightboxPanGesture={lightboxPanGesture}
|
||||
/>
|
||||
</TransfrormerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageRenderer;
|
||||
159
app/screens/gallery/renderers/image/transformer.tsx
Normal file
159
app/screens/gallery/renderers/image/transformer.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// 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} from 'react';
|
||||
import {StyleSheet} from 'react-native';
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||
import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated';
|
||||
import {SvgUri} from 'react-native-svg';
|
||||
|
||||
import {useTransformerSharedValues} from './context';
|
||||
import useTransformerDoubleTap from './gestures/useTransformerDoubleTap';
|
||||
import useTransformerPanGesture from './gestures/useTransformerPanGesture';
|
||||
import useTransformerPinchGesture from './gestures/useTransformerPinchGesture';
|
||||
import {useTransformerSingleTap} from './gestures/useTransformerSingleTap';
|
||||
|
||||
import type {GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
wrapper: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
svg: {
|
||||
backgroundColor: '#FFF',
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
||||
interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item' | 'isPagerInProgress'> {
|
||||
enabled?: boolean;
|
||||
isSvg: boolean;
|
||||
source: ImageSource | string;
|
||||
targetDimensions: { width: number; height: number };
|
||||
}
|
||||
|
||||
const ImageTransformer = (
|
||||
{
|
||||
enabled = true, height, isPageActive,
|
||||
onPageStateChange, source, isSvg,
|
||||
targetDimensions, width, pagerPanGesture, pagerTapGesture, lightboxPanGesture,
|
||||
}: ImageTransformerProps) => {
|
||||
const imageSource = typeof source === 'string' ? {uri: source} : source;
|
||||
const {
|
||||
interactionsEnabled,
|
||||
scale,
|
||||
translation,
|
||||
scaleTranslation,
|
||||
offset,
|
||||
resetSharedState,
|
||||
} = useTransformerSharedValues();
|
||||
|
||||
const setInteractionsEnabled = useCallback((value: boolean) => {
|
||||
interactionsEnabled.value = value;
|
||||
}, []);
|
||||
|
||||
const onLoadImageSuccess = useCallback(() => {
|
||||
setInteractionsEnabled(true);
|
||||
}, []);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
if (typeof isPageActive === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isPageActive.value;
|
||||
},
|
||||
(currentActive) => {
|
||||
if (!currentActive) {
|
||||
resetSharedState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
onPageStateChange(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},
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pinchGesture = useTransformerPinchGesture(true);
|
||||
pinchGesture.simultaneousWithExternalGesture(pagerPanGesture, lightboxPanGesture);
|
||||
|
||||
const panGesture = useTransformerPanGesture(enabled);
|
||||
panGesture.simultaneousWithExternalGesture(pagerPanGesture, lightboxPanGesture);
|
||||
|
||||
const doubleTapGesture = useTransformerDoubleTap(enabled);
|
||||
doubleTapGesture.blocksExternalGesture(pagerTapGesture);
|
||||
|
||||
const tapGesture = useTransformerSingleTap(enabled);
|
||||
tapGesture.simultaneousWithExternalGesture(pagerTapGesture);
|
||||
|
||||
const composedGesture = Gesture.Exclusive(
|
||||
Gesture.Simultaneous(pinchGesture, panGesture),
|
||||
doubleTapGesture,
|
||||
tapGesture,
|
||||
);
|
||||
|
||||
let element;
|
||||
if (isSvg) {
|
||||
element = (
|
||||
<SvgUri
|
||||
uri={imageSource.uri!}
|
||||
style={styles.svg}
|
||||
width={Math.min(targetDimensions.width, targetDimensions.height)}
|
||||
height={Math.min(targetDimensions.width, targetDimensions.height)}
|
||||
onLayout={onLoadImageSuccess}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Image
|
||||
onLoad={onLoadImageSuccess}
|
||||
source={imageSource}
|
||||
style={{width, height}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.View style={[styles.container]}>
|
||||
<Animated.View style={[styles.wrapper, animatedStyles]}>
|
||||
{element}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ImageTransformer);
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import Button from '@components/button';
|
|||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Preferences} from '@constants';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
|
|
@ -50,15 +51,16 @@ type Props = {
|
|||
height: number;
|
||||
isDownloading: boolean;
|
||||
isRemote: boolean;
|
||||
onShouldHideControls: () => void;
|
||||
posterUri?: string;
|
||||
setDownloading: Dispatch<SetStateAction<boolean>>;
|
||||
width: number;
|
||||
hideHeaderAndFooter: (hide?: boolean) => void;
|
||||
}
|
||||
|
||||
const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, onShouldHideControls, posterUri, setDownloading, width}: Props) => {
|
||||
const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, hideHeaderAndFooter, posterUri, setDownloading, width}: Props) => {
|
||||
const [hasPoster, setHasPoster] = useState(false);
|
||||
const [loadPosterError, setLoadPosterError] = useState(false);
|
||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||
const dimensions = useWindowDimensions();
|
||||
const intl = useIntl();
|
||||
|
||||
|
|
@ -74,6 +76,13 @@ const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height
|
|||
setLoadPosterError(true);
|
||||
}, []);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
hideHeaderAndFooter(!headerAndFooterHidden.value);
|
||||
|
||||
// No need to add shared values to the dependency array here,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hideHeaderAndFooter]);
|
||||
|
||||
let poster;
|
||||
if (posterUri && !loadPosterError) {
|
||||
const imageDimensions = calculateDimensions(height, width, dimensions.width);
|
||||
|
|
@ -145,7 +154,7 @@ const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height
|
|||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onShouldHideControls}
|
||||
onPress={onPress}
|
||||
style={styles.container}
|
||||
>
|
||||
<Animated.View style={styles.container}>
|
||||
29
app/screens/gallery/renderers/video/hooks/index.ts
Normal file
29
app/screens/gallery/renderers/video/hooks/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
export function useStateFromSharedValue<T>(sharedValue: SharedValue<T> | undefined, defaultValue: T): T {
|
||||
const [state, setState] = useState(defaultValue);
|
||||
useAnimatedReaction(
|
||||
() => sharedValue?.value,
|
||||
(currentValue, previousValue) => {
|
||||
if (currentValue !== previousValue) {
|
||||
runOnJS(setState)(currentValue ?? defaultValue);
|
||||
}
|
||||
}, [defaultValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedValue) {
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
const currentValue = sharedValue.value;
|
||||
runOnJS(setState)(currentValue);
|
||||
})();
|
||||
}
|
||||
}, [sharedValue]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {StyleSheet, View, Text, Platform} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
|
||||
import {translateYConfig} from '@hooks/gallery';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {useStateFromSharedValue} from '../hooks';
|
||||
|
||||
import ProgressBar from './progress_bar';
|
||||
|
||||
import type {VideoControlAction} from './types';
|
||||
|
||||
interface BottomControlsProps extends VideoControlAction {
|
||||
currentTime: SharedValue<number>;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
paddingBottom: number;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomControls: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
},
|
||||
container: {
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
time: {
|
||||
color: 'white',
|
||||
...typography('Body', 75),
|
||||
},
|
||||
});
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const h = Math.max(Math.floor(seconds / 3600), 0);
|
||||
const m = Math.max(Math.floor((seconds % 3600) / 60), 0);
|
||||
const s = Math.max(Math.floor(seconds % 60), 0);
|
||||
|
||||
const hh = h > 0 ? `${h}:` : '';
|
||||
const mm = h > 0 ? `${m.toString().padStart(2, '0')}` : `${m}`;
|
||||
const ss = s.toString().padStart(2, '0');
|
||||
|
||||
return `${hh}${mm}:${ss}`;
|
||||
};
|
||||
|
||||
const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
duration,
|
||||
handleControlAction,
|
||||
onSeek,
|
||||
paddingBottom,
|
||||
}) => {
|
||||
const currentTimeValue = useStateFromSharedValue(currentTime, 0);
|
||||
const insets = useSafeAreaInsets();
|
||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||
|
||||
const progress = duration > 0 ? currentTimeValue / duration : 0;
|
||||
|
||||
const onSeekHandler = useCallback((time: number) => {
|
||||
handleControlAction('seek', () => {
|
||||
onSeek(time);
|
||||
});
|
||||
}, [handleControlAction, onSeek]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
marginBottom: withTiming(headerAndFooterHidden.value ? insets.bottom : GALLERY_FOOTER_HEIGHT, translateYConfig),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents='auto'
|
||||
style={[styles.bottomControls, animatedStyle]}
|
||||
>
|
||||
<View style={[styles.container, styles.row, {paddingBottom}]}>
|
||||
<Text style={styles.time}>
|
||||
{formatTime(currentTimeValue)}
|
||||
</Text>
|
||||
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
onSeek={onSeekHandler}
|
||||
/>
|
||||
|
||||
<Text style={styles.time}>
|
||||
{Platform.OS === 'ios' ? `-${formatTime(duration - currentTimeValue)}` : formatTime(duration)
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomControls;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {createContext, useState, type ReactNode} from 'react';
|
||||
|
||||
import type {View} from 'react-native';
|
||||
|
||||
export type ViewPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ref: React.RefObject<View>;
|
||||
};
|
||||
|
||||
type ViewPositionContextType = {
|
||||
viewPosition: ViewPosition | null;
|
||||
setViewPosition: (position: ViewPosition | null) => void;
|
||||
};
|
||||
|
||||
const ViewPositionContext = createContext<ViewPositionContextType | undefined>(undefined);
|
||||
|
||||
export const useViewPosition = (): ViewPositionContextType => {
|
||||
const context = React.useContext(ViewPositionContext);
|
||||
if (!context) {
|
||||
throw new Error('useViewPosition must be used within a ViewPositionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ViewPositionProvider: React.FC<Props> = ({children}) => {
|
||||
const [viewPosition, setViewPosition] = useState<ViewPosition | null>(null);
|
||||
|
||||
return (
|
||||
<ViewPositionContext.Provider value={{viewPosition, setViewPosition}}>
|
||||
{children}
|
||||
</ViewPositionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default as PlayIcon} from './play';
|
||||
export {default as PauseIcon} from './pause';
|
||||
export {default as SeekIcon, type SeekIconRef} from './seek';
|
||||
export {default as SpeedIcon} from './speed';
|
||||
export {default as SubtitlesIcon} from './subtitles';
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
import Svg, {Rect} from 'react-native-svg';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const PauseIcon: React.FC<Props> = ({size = 66, color = 'white'}) => {
|
||||
const platformProps = Platform.select({
|
||||
default: {},
|
||||
ios: {rx: 2}, // iOS specific corner radius
|
||||
});
|
||||
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
>
|
||||
<Rect
|
||||
x='6'
|
||||
y='4'
|
||||
width='4'
|
||||
height='16'
|
||||
fill={color}
|
||||
{...platformProps}
|
||||
/>
|
||||
<Rect
|
||||
x='14'
|
||||
y='4'
|
||||
width='4'
|
||||
height='16'
|
||||
fill={color}
|
||||
{...platformProps}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PauseIcon;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const PlayIcon: React.FC<Props> = ({size = 66, color = 'white'}) => {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
>
|
||||
<Path
|
||||
d='M8 5.14v13.72c0 .79.87 1.27 1.54.84l11.28-7.32a1 1 0 0 0 0-1.68L9.54 4.38A1 1 0 0 0 8 5.14z'
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayIcon;
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useImperativeHandle, forwardRef} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import Svg, {G, Path, Text as SvgText} from 'react-native-svg';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
seekSeconds: 0 | 10 | 30;
|
||||
type: 'fastForward' | 'rewind';
|
||||
}
|
||||
|
||||
export interface SeekIconRef {
|
||||
triggerSpin: () => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
absolute: {
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
|
||||
const SeekIcon = forwardRef<SeekIconRef, Props>(({
|
||||
size = 38,
|
||||
color = 'white',
|
||||
seekSeconds,
|
||||
type,
|
||||
}, ref) => {
|
||||
const rotation = useSharedValue(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerSpin: () => {
|
||||
rotation.value = 0; // Reset immediately
|
||||
// Spin in the correct direction based on type
|
||||
const targetRotation = type === 'fastForward' ? 360 : -360;
|
||||
rotation.value = withTiming(targetRotation, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
}, () => {
|
||||
rotation.value = 0; // Reset after animation
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
if (!seekSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strokeWidth = size * 0.09;
|
||||
const center = size / 2;
|
||||
const radius = center - (strokeWidth / 2);
|
||||
const gapDegrees = 35;
|
||||
|
||||
// Calculate start and end angles based on type
|
||||
const [startAngle, endAngle, baseRotation] = type === 'fastForward' ? [
|
||||
((360 - (gapDegrees / 2)) * (Math.PI / 180)),
|
||||
((gapDegrees / 2) * (Math.PI / 180)),
|
||||
45,
|
||||
] : [
|
||||
((gapDegrees / 2) * (Math.PI / 180)),
|
||||
((360 - (gapDegrees / 2)) * (Math.PI / 180)),
|
||||
-45,
|
||||
];
|
||||
|
||||
const startX = center + (radius * Math.cos(startAngle - (Math.PI / 2)));
|
||||
const startY = center + (radius * Math.sin(startAngle - (Math.PI / 2)));
|
||||
const endX = center + (radius * Math.cos(endAngle - (Math.PI / 2)));
|
||||
const endY = center + (radius * Math.sin(endAngle - (Math.PI / 2)));
|
||||
|
||||
const tangentDegrees = (startAngle * 180) / Math.PI;
|
||||
const arrowLength = size * 0.25;
|
||||
|
||||
const arcPath = type === 'fastForward'? `M ${startX} ${startY} A ${radius} ${radius} 0 1 0 ${endX} ${endY}`: `M ${endX} ${endY} A ${radius} ${radius} 0 1 0 ${startX} ${startY}`;
|
||||
|
||||
const arrowRotation = type === 'fastForward' ? tangentDegrees : tangentDegrees + 180;
|
||||
const arrowPointX = startX;
|
||||
const arrowPointY = startY;
|
||||
|
||||
// Animated style for the spinning arc container only
|
||||
const spinStyle = useAnimatedStyle(() => ({
|
||||
transform: [{rotate: `${rotation.value}deg`}],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={{width: size, height: size}}>
|
||||
{/* Spinning arc and arrow */}
|
||||
<Animated.View style={[styles.absolute, spinStyle]}>
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
<G transform={`rotate(${baseRotation}, ${center}, ${center})`}>
|
||||
<Path
|
||||
d={arcPath}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
|
||||
<G transform={`rotate(${arrowRotation}, ${arrowPointX}, ${arrowPointY})`}>
|
||||
<Path
|
||||
d={`M ${arrowPointX + (arrowLength - 5)} ${arrowPointY} l -${arrowLength} -${arrowLength / 2} l 0 ${arrowLength} Z`}
|
||||
strokeWidth={strokeWidth * 3}
|
||||
fill={color}
|
||||
/>
|
||||
</G>
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
|
||||
{/* Static text - no animation at all */}
|
||||
<View style={styles.absolute}>
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
<SvgText
|
||||
x={center}
|
||||
y={center + (size * 0.03)}
|
||||
fontSize={size * 0.45}
|
||||
fontWeight='bold'
|
||||
fill={color}
|
||||
textAnchor='middle'
|
||||
alignmentBaseline='middle'
|
||||
>
|
||||
{seekSeconds}
|
||||
</SvgText>
|
||||
</Svg>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
SeekIcon.displayName = 'SeekIcon';
|
||||
|
||||
export default SeekIcon;
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import Svg, {Circle, Path} from 'react-native-svg';
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const hourMarkers = [
|
||||
{cx: '12', cy: '4'}, // 12 o'clock
|
||||
{cx: '15.5', cy: '5.5'}, // 1 o'clock
|
||||
{cx: '18.5', cy: '8.5'}, // 2 o'clock
|
||||
{cx: '20', cy: '12'}, // 3 o'clock
|
||||
{cx: '18.5', cy: '15.5'}, // 4 o'clock
|
||||
{cx: '15.5', cy: '18.5'}, // 5 o'clock
|
||||
{cx: '5.5', cy: '15.5'}, // 8 o'clock
|
||||
{cx: '4', cy: '12'}, // 9 o'clock
|
||||
{cx: '5.5', cy: '8.5'}, // 10 o'clock
|
||||
{cx: '8.5', cy: '5.5'}, // 11 o'clock
|
||||
];
|
||||
|
||||
const PlaybackSpeedIcon: React.FC<IconProps> = ({size = 24, color = 'white'}) => {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
>
|
||||
{/* Outer circle (clock face) */}
|
||||
<Circle
|
||||
cx='12'
|
||||
cy='12'
|
||||
r='10'
|
||||
stroke={color}
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
/>
|
||||
|
||||
{hourMarkers.map((marker) => (
|
||||
<Circle
|
||||
key={`${marker.cx}-${marker.cy}`}
|
||||
cx={marker.cx}
|
||||
cy={marker.cy}
|
||||
r='1'
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Clock hand pointing to about 2 o'clock (indicating speed/fast) */}
|
||||
<Path
|
||||
d='M12 12L15 8.5'
|
||||
stroke={color}
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
|
||||
{/* Center dot */}
|
||||
<Circle
|
||||
cx='12'
|
||||
cy='12'
|
||||
r='1.5'
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackSpeedIcon;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// components/video-controls/icons/SubtitlesIcon.tsx
|
||||
import React from 'react';
|
||||
import Svg, {Rect, Path} from 'react-native-svg';
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const SubtitlesIcon: React.FC<IconProps> = ({size = 24, color = 'white', selected = false}) => {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
>
|
||||
{/* Speech bubble outline */}
|
||||
<Path
|
||||
d='M20 2H4C2.9 2 2 2.9 2 4V16C2 17.1 2.9 18 4 18H6L8 21L10 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z'
|
||||
stroke={color}
|
||||
strokeWidth='1.5'
|
||||
fill={selected ? color : 'none'}
|
||||
/>
|
||||
|
||||
{/* Text lines */}
|
||||
<Rect
|
||||
x='5'
|
||||
y='7'
|
||||
width='8'
|
||||
height='1.5'
|
||||
fill={selected ? 'black' : color}
|
||||
/>
|
||||
<Rect
|
||||
x='15'
|
||||
y='7'
|
||||
width='4'
|
||||
height='1.5'
|
||||
fill={selected ? 'black' : color}
|
||||
/>
|
||||
<Rect
|
||||
x='5'
|
||||
y='10'
|
||||
width='6'
|
||||
height='1.5'
|
||||
fill={selected ? 'black' : color}
|
||||
/>
|
||||
<Rect
|
||||
x='13'
|
||||
y='10'
|
||||
width='6'
|
||||
height='1.5'
|
||||
fill={selected ? 'black' : color}
|
||||
/>
|
||||
<Rect
|
||||
x='5'
|
||||
y='13'
|
||||
width='9'
|
||||
height='1.5'
|
||||
fill={selected ? 'black' : color}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitlesIcon;
|
||||
232
app/screens/gallery/renderers/video/video_controls/index.tsx
Normal file
232
app/screens/gallery/renderers/video/video_controls/index.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
|
||||
import BottomControls from './bottom_controls';
|
||||
import {ViewPositionProvider} from './context';
|
||||
import PlaybackControls from './playback_controls';
|
||||
import PlaybackSpeedMenu from './playback_speed_menu';
|
||||
import TopControls from './top_controls';
|
||||
|
||||
interface VideoControlsWithSeekProps {
|
||||
visible: boolean;
|
||||
paused: boolean;
|
||||
currentTime: SharedValue<number>;
|
||||
duration: number;
|
||||
speed: number;
|
||||
captionsEnabled?: boolean;
|
||||
hasCaptions?: boolean;
|
||||
isFullscreen: boolean;
|
||||
seekSeconds: 0 | 10 | 30;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onRewind: () => void;
|
||||
onForward: () => void;
|
||||
onSpeedChange: (rate: number) => void;
|
||||
onFullscreen: () => void;
|
||||
onCaptionsToggle?: () => void;
|
||||
setShowCustomControls: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
type Control = 'play' | 'pause' | 'seek' | 'rewind' | 'forward' | 'selectSpeed' | 'closeSpeedMenu' | 'speed' | 'fullscreen' | 'captions';
|
||||
|
||||
const persistentControls = new Set<Control>(['pause', 'selectSpeed']);
|
||||
|
||||
const SHOW_CONTROLS_TIMEOUT = 4000; // 4 seconds
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
},
|
||||
controlsBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
controlsArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const VideoControls: React.FC<VideoControlsWithSeekProps> = ({
|
||||
paused,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onRewind,
|
||||
onForward,
|
||||
onSpeedChange,
|
||||
onFullscreen,
|
||||
onCaptionsToggle,
|
||||
captionsEnabled,
|
||||
hasCaptions = false,
|
||||
isFullscreen,
|
||||
seekSeconds,
|
||||
visible,
|
||||
setShowCustomControls,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const headerHeight = useDefaultHeaderHeight();
|
||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
|
||||
const isInteractingWithControlsRef = useRef(false);
|
||||
const isInteractingWithControlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const hideControlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const cancelHideControls = useCallback(() => {
|
||||
if (hideControlsTimeoutRef.current) {
|
||||
clearTimeout(hideControlsTimeoutRef.current);
|
||||
hideControlsTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
if (isInteractingWithControlsTimeoutRef.current) {
|
||||
clearTimeout(isInteractingWithControlsTimeoutRef.current);
|
||||
isInteractingWithControlsTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleAutoHideControls = useCallback((control?: Control, delay: number = SHOW_CONTROLS_TIMEOUT) => {
|
||||
if (!paused || control === 'play') {
|
||||
hideControlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowCustomControls(false);
|
||||
setShowSpeedMenu(false);
|
||||
}, delay);
|
||||
}
|
||||
}, [paused, setShowCustomControls]);
|
||||
|
||||
const handleControlAction = useCallback((control: Control, action?: () => void) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelHideControls();
|
||||
isInteractingWithControlsRef.current = true;
|
||||
action?.();
|
||||
|
||||
isInteractingWithControlsTimeoutRef.current = setTimeout(() => {
|
||||
isInteractingWithControlsRef.current = false;
|
||||
}, 300);
|
||||
|
||||
if (persistentControls.has(control)) {
|
||||
// If the control is persistent, do not hide controls
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleAutoHideControls(control);
|
||||
}, [cancelHideControls, scheduleAutoHideControls, visible]);
|
||||
|
||||
const handleBackgroundPress = useCallback(() => {
|
||||
if (isInteractingWithControlsRef.current) {
|
||||
// If the user is interacting with the controls, do not hide them
|
||||
return;
|
||||
}
|
||||
|
||||
cancelHideControls();
|
||||
|
||||
if (visible) {
|
||||
setShowCustomControls(false);
|
||||
setShowSpeedMenu(false);
|
||||
} else {
|
||||
setShowCustomControls(true);
|
||||
scheduleAutoHideControls();
|
||||
}
|
||||
}, [cancelHideControls, scheduleAutoHideControls, setShowCustomControls, visible]);
|
||||
|
||||
const onShowSpeedMenu = useCallback((value?: boolean) => {
|
||||
setShowSpeedMenu(value || !showSpeedMenu);
|
||||
}, [showSpeedMenu]);
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
paddingTop: headerAndFooterHidden.value ? insets.top : headerHeight,
|
||||
}), [headerHeight, insets.left, insets.right]);
|
||||
|
||||
const controlsOpacityStyle = useAnimatedStyle(() => ({
|
||||
opacity: withTiming(visible ? 1 : 0, {duration: 300}),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelHideControls();
|
||||
};
|
||||
}, [cancelHideControls]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, containerStyle]}>
|
||||
<Animated.View style={[styles.container, controlsOpacityStyle]}>
|
||||
<ViewPositionProvider>
|
||||
<View
|
||||
onTouchEnd={handleBackgroundPress}
|
||||
style={[styles.controlsArea, styles.controlsBackground]}
|
||||
>
|
||||
|
||||
<TopControls
|
||||
captionsEnabled={captionsEnabled}
|
||||
handleControlAction={handleControlAction}
|
||||
hasCaptions={hasCaptions}
|
||||
isFullscreen={isFullscreen}
|
||||
onFullscreen={onFullscreen}
|
||||
onCaptionsToggle={onCaptionsToggle}
|
||||
onShowSpeedMenu={onShowSpeedMenu}
|
||||
/>
|
||||
|
||||
<PlaybackControls
|
||||
handleControlAction={handleControlAction}
|
||||
paused={paused}
|
||||
seekSeconds={seekSeconds}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onRewind={onRewind}
|
||||
onForward={onForward}
|
||||
/>
|
||||
|
||||
<BottomControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
handleControlAction={handleControlAction}
|
||||
onSeek={onSeek}
|
||||
paddingBottom={isFullscreen ? 0 : insets.bottom}
|
||||
/>
|
||||
|
||||
<PlaybackSpeedMenu
|
||||
visible={showSpeedMenu}
|
||||
currentSpeed={speed}
|
||||
handleControlAction={handleControlAction}
|
||||
isFullscreen={isFullscreen}
|
||||
onSpeedChange={onSpeedChange}
|
||||
setShowSpeedMenu={setShowSpeedMenu}
|
||||
/>
|
||||
</View>
|
||||
</ViewPositionProvider>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoControls;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {StyleSheet, View, Pressable} from 'react-native';
|
||||
|
||||
import {PlayIcon, PauseIcon, SeekIcon, type SeekIconRef} from './icons';
|
||||
|
||||
import type {VideoControlAction} from './types';
|
||||
|
||||
interface PlaybackControlsProps extends VideoControlAction {
|
||||
paused: boolean;
|
||||
seekSeconds: 0 | 10 | 30;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onRewind: () => void;
|
||||
onForward: () => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
playbackControls: {
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 60,
|
||||
},
|
||||
button: {
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const PlaybackControls: React.FC<PlaybackControlsProps> = ({
|
||||
handleControlAction,
|
||||
paused,
|
||||
seekSeconds,
|
||||
onPlay,
|
||||
onPause,
|
||||
onRewind,
|
||||
onForward,
|
||||
}) => {
|
||||
const rewindIconRef = useRef<SeekIconRef>(null);
|
||||
const forwardIconRef = useRef<SeekIconRef>(null);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
handleControlAction('play', onPlay);
|
||||
}, [onPlay, handleControlAction]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
handleControlAction('pause', onPause);
|
||||
}, [onPause, handleControlAction]);
|
||||
|
||||
const handleRewind = useCallback(() => {
|
||||
rewindIconRef.current?.triggerSpin();
|
||||
handleControlAction('rewind', onRewind);
|
||||
}, [onRewind, handleControlAction]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
forwardIconRef.current?.triggerSpin();
|
||||
handleControlAction('forward', onForward);
|
||||
}, [onForward, handleControlAction]);
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents='auto'
|
||||
style={styles.playbackControls}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{Boolean(seekSeconds) && (
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
onPress={handleRewind}
|
||||
>
|
||||
<SeekIcon
|
||||
type='rewind'
|
||||
seekSeconds={seekSeconds}
|
||||
ref={rewindIconRef}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
onPress={paused ? handlePlay : handlePause}
|
||||
>
|
||||
{paused ? (
|
||||
<PlayIcon/>
|
||||
) : (
|
||||
<PauseIcon/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{Boolean(seekSeconds) && (
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
onPress={handleForward}
|
||||
>
|
||||
<SeekIcon
|
||||
type='fastForward'
|
||||
seekSeconds={seekSeconds}
|
||||
ref={forwardIconRef}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackControls;
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {StyleSheet, View, Pressable, Text, Platform, type LayoutChangeEvent} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {useWindowDimensions} from '@hooks/device';
|
||||
import {measureViewInWindow} from '@utils/gallery';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {useViewPosition} from './context';
|
||||
import SpeedOption from './speed_option';
|
||||
|
||||
import type {VideoControlAction} from './types';
|
||||
|
||||
interface PlaybackSpeedMenuProps extends VideoControlAction {
|
||||
currentSpeed: number;
|
||||
isFullscreen: boolean;
|
||||
onSpeedChange: (rate: number) => void;
|
||||
setShowSpeedMenu: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iosMenu: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(28, 28, 30, 0.9)',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
iosHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginBottom: 8,
|
||||
gap: 8,
|
||||
},
|
||||
iosTitle: {
|
||||
color: 'white',
|
||||
...typography('Heading', 100, 'SemiBold'),
|
||||
},
|
||||
iosDone: {
|
||||
color: '#007AFF',
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
androidMenu: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
minWidth: 80,
|
||||
elevation: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const PlaybackRateMenu: React.FC<PlaybackSpeedMenuProps> = ({
|
||||
currentSpeed,
|
||||
handleControlAction,
|
||||
isFullscreen,
|
||||
onSpeedChange,
|
||||
setShowSpeedMenu,
|
||||
visible,
|
||||
}) => {
|
||||
const {viewPosition, setViewPosition} = useViewPosition();
|
||||
const [menuSize, setMenuSize] = useState<{width: number; height: number}>({width: 0, height: 0});
|
||||
const windowDims = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const speedOptions = [0.5, 1.0, 1.5, 2.0];
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
// Calculate adjusted positions
|
||||
let left = viewPosition?.x ?? 0;
|
||||
const top = (viewPosition?.y ?? 0) + ((viewPosition?.height ?? 0) / 2);
|
||||
|
||||
// Horizontal adjustment
|
||||
if (left + menuSize.width > windowDims.width) {
|
||||
left = windowDims.width - menuSize.width - (insets.right);
|
||||
}
|
||||
if (left < 0) {
|
||||
left = 10;
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: withSpring(visible ? 1 : 0),
|
||||
transform: [{
|
||||
scale: withSpring(visible ? 1 : 0.9),
|
||||
}],
|
||||
zIndex: 5,
|
||||
top,
|
||||
left,
|
||||
};
|
||||
});
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
setMenuSize({
|
||||
width: e.nativeEvent.layout.width,
|
||||
height: e.nativeEvent.layout.height,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSpeedChange = useCallback((rate: number) => {
|
||||
handleControlAction('speed', () => {
|
||||
onSpeedChange(rate);
|
||||
setShowSpeedMenu(false);
|
||||
});
|
||||
}, [handleControlAction, onSpeedChange, setShowSpeedMenu]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
handleControlAction('closeSpeedMenu', () => {
|
||||
setShowSpeedMenu(false);
|
||||
});
|
||||
}, [handleControlAction, setShowSpeedMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const measure = async () => {
|
||||
if (viewPosition) {
|
||||
const result = await measureViewInWindow(viewPosition.ref);
|
||||
setViewPosition({
|
||||
...viewPosition,
|
||||
x: result.x,
|
||||
y: result.y,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const measureTimeout = setTimeout(() => {
|
||||
measure();
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(measureTimeout);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFullscreen, visible, windowDims.width]);
|
||||
|
||||
if (!visible || !viewPosition?.width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let header;
|
||||
if (Platform.OS === 'ios') {
|
||||
header = (
|
||||
<View style={styles.iosHeader}>
|
||||
<Text style={styles.iosTitle}>
|
||||
<FormattedMessage
|
||||
id='video.playback_speed'
|
||||
defaultMessage='Playback Speed'
|
||||
/>
|
||||
</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text style={styles.iosDone}>
|
||||
<FormattedMessage
|
||||
id='video.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[Platform.select({android: styles.androidMenu, ios: styles.iosMenu}), animatedStyle]}
|
||||
>
|
||||
{header}
|
||||
{speedOptions.map((rate) => (
|
||||
<SpeedOption
|
||||
key={rate}
|
||||
rate={rate}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
isSelected={currentSpeed === rate}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackRateMenu;
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useRef} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
runOnJS,
|
||||
withTiming,
|
||||
interpolate,
|
||||
runOnUI,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
top: 4,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
},
|
||||
touchArea: {
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
track: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
bar: {
|
||||
backgroundColor: 'white',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
},
|
||||
thumb: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'white',
|
||||
top: '50%',
|
||||
marginTop: -8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
progress,
|
||||
duration,
|
||||
onSeek,
|
||||
}) => {
|
||||
const progressRef = useRef<View>(null);
|
||||
const progressWidth = useSharedValue(0);
|
||||
const dragPosition = useSharedValue(0);
|
||||
const trackHeight = useSharedValue(4);
|
||||
const thumbOpacity = useSharedValue(0);
|
||||
const isDraggingShared = useSharedValue(false);
|
||||
const localProgress = useSharedValue(progress);
|
||||
const seekTimestamp = useSharedValue(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
if (!isDraggingShared.value) {
|
||||
localProgress.value = progress;
|
||||
}
|
||||
})();
|
||||
|
||||
// no need to add shared values to the dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const startDragging = () => {
|
||||
'worklet';
|
||||
isDraggingShared.value = true;
|
||||
trackHeight.value = withTiming(8, {duration: 150}); // Double height
|
||||
thumbOpacity.value = withTiming(1, {duration: 150});
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
isDraggingShared.value = false;
|
||||
trackHeight.value = withTiming(4, {duration: 150}); // Back to normal
|
||||
thumbOpacity.value = withTiming(0, {duration: 150});
|
||||
localProgress.value = progress;
|
||||
};
|
||||
|
||||
const progressGesture = Gesture.Pan().
|
||||
minDistance(0).
|
||||
activeOffsetX([-5, 5]).
|
||||
onStart((event) => {
|
||||
startDragging();
|
||||
dragPosition.value = event.x;
|
||||
}).
|
||||
onUpdate((event) => {
|
||||
dragPosition.value = Math.max(0, Math.min(progressWidth.value, event.x));
|
||||
const newProgress = dragPosition.value / progressWidth.value;
|
||||
localProgress.value = newProgress;
|
||||
|
||||
// Throttle the actual seeking to avoid too many calls
|
||||
const now = Date.now();
|
||||
const lastSeekTime = seekTimestamp.value;
|
||||
if (now - lastSeekTime > 100) {
|
||||
seekTimestamp.value = now;
|
||||
const newTime = newProgress * duration;
|
||||
runOnJS(onSeek)(newTime);
|
||||
}
|
||||
}).
|
||||
onEnd(() => {
|
||||
// Always seek to final position
|
||||
const finalProgress = dragPosition.value / progressWidth.value;
|
||||
const finalTime = finalProgress * duration;
|
||||
runOnJS(onSeek)(finalTime);
|
||||
runOnJS(stopDragging)();
|
||||
}).
|
||||
onFinalize(() => {
|
||||
// Ensure we stop dragging even if gesture is cancelled
|
||||
seekTimestamp.value = 0; // Reset timestamp
|
||||
runOnJS(stopDragging)();
|
||||
});
|
||||
|
||||
const tapGesture = Gesture.Tap().
|
||||
onEnd((event) => {
|
||||
const newProgress = event.x / progressWidth.value;
|
||||
localProgress.value = newProgress;
|
||||
const newTime = newProgress * duration;
|
||||
runOnJS(onSeek)(newTime);
|
||||
});
|
||||
|
||||
const combinedGesture = Gesture.Race(progressGesture, tapGesture);
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => ({
|
||||
height: trackHeight.value,
|
||||
borderRadius: trackHeight.value / 2,
|
||||
}));
|
||||
|
||||
const progressBarStyle = useAnimatedStyle(() => ({
|
||||
width: `${localProgress.value * 100}%`,
|
||||
height: trackHeight.value,
|
||||
borderRadius: trackHeight.value / 2,
|
||||
}));
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => {
|
||||
const currentProgress = isDraggingShared.value ? (dragPosition.value / progressWidth.value) : progress;
|
||||
|
||||
return {
|
||||
opacity: thumbOpacity.value,
|
||||
transform: [{
|
||||
translateX: (currentProgress * progressWidth.value) - 8,
|
||||
}],
|
||||
|
||||
// Scale thumb based on track height
|
||||
width: interpolate(trackHeight.value, [4, 8], [16, 20]),
|
||||
height: interpolate(trackHeight.value, [4, 8], [16, 20]),
|
||||
borderRadius: interpolate(trackHeight.value, [4, 8], [8, 10]),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<GestureDetector gesture={combinedGesture}>
|
||||
<View
|
||||
ref={progressRef}
|
||||
style={styles.touchArea}
|
||||
onLayout={(event) => {
|
||||
progressWidth.value = event.nativeEvent.layout.width;
|
||||
}}
|
||||
>
|
||||
{/* Background track */}
|
||||
<Animated.View style={[styles.track, trackStyle]}/>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Animated.View style={[styles.bar, progressBarStyle]}/>
|
||||
|
||||
{/* Thumb */}
|
||||
<Animated.View style={[styles.thumb, thumbStyle]}/>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Platform, Pressable, StyleSheet, Text, type StyleProp, type ViewStyle} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
interface SpeedOptionProps {
|
||||
rate: number;
|
||||
onSpeedChange: (rate: number) => void;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iosOption: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
iosOptionText: {
|
||||
color: 'white',
|
||||
...typography('Body', 100),
|
||||
},
|
||||
iosCheck: {
|
||||
color: '#007AFF',
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
androidOption: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
androidActiveOption: {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.2)',
|
||||
},
|
||||
androidOptionText: {
|
||||
color: 'white',
|
||||
...typography('Body', 100),
|
||||
textAlign: 'center',
|
||||
},
|
||||
androidActiveText: {
|
||||
color: '#1976D2',
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
});
|
||||
|
||||
const SpeedOption: React.FC<SpeedOptionProps> = ({rate, onSpeedChange, isSelected}) => {
|
||||
const handleSpeedSelect = useCallback(() => {
|
||||
onSpeedChange(rate);
|
||||
}, [onSpeedChange, rate]);
|
||||
|
||||
let rateValue;
|
||||
let optionStyle: StyleProp<ViewStyle> = styles.iosOption;
|
||||
if (Platform.OS === 'android') {
|
||||
optionStyle = [styles.androidOption];
|
||||
if (isSelected) {
|
||||
optionStyle.push(styles.androidActiveOption);
|
||||
}
|
||||
rateValue = (
|
||||
<Text
|
||||
style={[
|
||||
styles.androidOptionText,
|
||||
isSelected && styles.androidActiveText,
|
||||
]}
|
||||
>
|
||||
{rate === 1 ? '1×' : `${rate}×`}
|
||||
</Text>
|
||||
);
|
||||
} else if (rate === 1) {
|
||||
rateValue = (
|
||||
<>
|
||||
<FormattedText
|
||||
id='video.normal'
|
||||
defaultMessage='Normal'
|
||||
style={styles.iosOptionText}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Text style={styles.iosCheck}>{'✓'}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
rateValue = (
|
||||
<>
|
||||
<Text style={styles.iosOptionText}>
|
||||
{`${rate}×`}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Text style={styles.iosCheck}>{'✓'}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={optionStyle}
|
||||
onPress={handleSpeedSelect}
|
||||
>
|
||||
{rateValue}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedOption;
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {StyleSheet, View, Pressable} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
import {SafeAreaView, useSafeAreaInsets, type Edge} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useWindowDimensions} from '@hooks/device';
|
||||
import {translateYConfig} from '@hooks/gallery';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {measureViewInWindow} from '@utils/gallery';
|
||||
|
||||
import {useViewPosition} from './context';
|
||||
import {SpeedIcon, SubtitlesIcon} from './icons';
|
||||
|
||||
import type {VideoControlAction} from './types';
|
||||
|
||||
interface TopControlsProps extends VideoControlAction {
|
||||
captionsEnabled?: boolean;
|
||||
hasCaptions?: boolean;
|
||||
isFullscreen: boolean;
|
||||
onCaptionsToggle?: () => void;
|
||||
onFullscreen: () => void;
|
||||
onShowSpeedMenu: (value?: boolean) => void;
|
||||
}
|
||||
|
||||
const rightControlsEdges: Edge[] = ['right'];
|
||||
|
||||
const getStyles = (isLandscape: boolean) => {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingLeft: isLandscape ? 25 : 0,
|
||||
paddingRight: 0,
|
||||
zIndex: 3,
|
||||
},
|
||||
leftControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
fullscreenIcon: {
|
||||
transform: [{rotate: '90deg'}],
|
||||
},
|
||||
button: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const TopControls: React.FC<TopControlsProps> = ({
|
||||
captionsEnabled,
|
||||
hasCaptions,
|
||||
handleControlAction,
|
||||
isFullscreen,
|
||||
onCaptionsToggle,
|
||||
onFullscreen,
|
||||
onShowSpeedMenu,
|
||||
}) => {
|
||||
const speedButtonRef = useRef<View>(null);
|
||||
const {setViewPosition} = useViewPosition();
|
||||
const insets = useSafeAreaInsets();
|
||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const headerHeight = useDefaultHeaderHeight();
|
||||
const styles = useMemo(() => getStyles(windowDimensions.width > windowDimensions.height), [windowDimensions]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
handleControlAction('fullscreen', onFullscreen);
|
||||
}, [handleControlAction, onFullscreen]);
|
||||
|
||||
const toggleCaptions = useCallback(() => {
|
||||
handleControlAction('captions', onCaptionsToggle);
|
||||
}, [handleControlAction, onCaptionsToggle]);
|
||||
|
||||
const toggleSpeedMenu = useCallback(() => {
|
||||
handleControlAction('speed', onShowSpeedMenu);
|
||||
}, [handleControlAction, onShowSpeedMenu]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
marginTop: withTiming(headerAndFooterHidden.value ? insets.top : headerHeight, translateYConfig),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const measure = async () => {
|
||||
const result = await measureViewInWindow(speedButtonRef);
|
||||
setViewPosition({
|
||||
ref: speedButtonRef,
|
||||
x: result.x,
|
||||
y: result.y,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
}, []);
|
||||
|
||||
let fullscreen;
|
||||
if (isFullscreen) {
|
||||
fullscreen = (
|
||||
<CompassIcon
|
||||
name='arrow-collapse'
|
||||
size={26}
|
||||
color='white'
|
||||
style={styles.fullscreenIcon}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
fullscreen = (
|
||||
<CompassIcon
|
||||
name='arrow-expand'
|
||||
size={26}
|
||||
color='white'
|
||||
style={styles.fullscreenIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, animatedStyle]}>
|
||||
<View style={styles.leftControls}>
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
onPress={toggleFullscreen}
|
||||
>
|
||||
{fullscreen}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<SafeAreaView edges={rightControlsEdges}>
|
||||
<View style={styles.rightControls}>
|
||||
{hasCaptions && (
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
onPress={toggleCaptions}
|
||||
>
|
||||
<SubtitlesIcon
|
||||
size={24}
|
||||
color='white'
|
||||
selected={captionsEnabled}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
onPress={toggleSpeedMenu}
|
||||
ref={speedButtonRef}
|
||||
>
|
||||
<SpeedIcon
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopControls;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type Control = 'play' | 'pause' | 'seek' | 'rewind' | 'forward' | 'selectSpeed' | 'closeSpeedMenu' | 'speed' | 'fullscreen' | 'captions';
|
||||
|
||||
export interface VideoControlAction {
|
||||
handleControlAction: (control: Control, action?: () => void) => void;
|
||||
}
|
||||
326
app/screens/gallery/renderers/video/video_renderer.tsx
Normal file
326
app/screens/gallery/renderers/video/video_renderer.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {DeviceEventEmitter, Platform, StatusBar, StyleSheet} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import Video, {SelectedTrackType, type OnPlaybackStateChangedData, type ReactVideoPoster, type ReactVideoSource, type VideoRef} from 'react-native-video';
|
||||
|
||||
import {updateLocalFilePath} from '@actions/local/file';
|
||||
import {getTranscriptionUri, hasCaptions} from '@calls/utils';
|
||||
import {Events} from '@constants';
|
||||
import {ANDROID_VIDEO_INSET, GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {toMilliseconds} from '@utils/datetime';
|
||||
|
||||
import VideoError from './error';
|
||||
import {useStateFromSharedValue} from './hooks';
|
||||
import VideoControls from './video_controls';
|
||||
|
||||
import type {GalleryAction, GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
type VideoRendererProps = GalleryPagerItem & {
|
||||
canDownloadFiles: boolean;
|
||||
enableSecureFilePreview: boolean;
|
||||
index: number;
|
||||
initialIndex: number;
|
||||
hideHeaderAndFooter: (hide?: boolean) => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
video: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index, initialIndex, item, isPageActive, hideHeaderAndFooter, width}: VideoRendererProps) => {
|
||||
const {headerAndFooterHidden} = useLightboxSharedValues();
|
||||
const {bottom} = useSafeAreaInsets();
|
||||
const serverUrl = useServerUrl();
|
||||
const videoRef = useRef<VideoRef>();
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState(true);
|
||||
const [paused, setPaused] = useState(!(initialIndex === index));
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [videoUri, setVideoUri] = useState(item.uri);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
// Custom controls state
|
||||
const [showCustomControls, setShowCustomControls] = useState(true);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const currentTime = useSharedValue(0);
|
||||
|
||||
const hideControlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const progressDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const playbackStateDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isPageActiveValue = useStateFromSharedValue(isPageActive, false);
|
||||
|
||||
const headerAndFooterHiddenValue = useStateFromSharedValue(headerAndFooterHidden, false);
|
||||
|
||||
const {tracks, selected} = useMemo(() => getTranscriptionUri(serverUrl, item.postProps), [serverUrl, item.postProps]);
|
||||
const source: ReactVideoSource = useMemo(() => ({uri: videoUri, textTracks: tracks}), [videoUri, tracks]);
|
||||
const poster: ReactVideoPoster = useMemo(() => ({
|
||||
source: {uri: item.posterUri},
|
||||
resizeMode: 'contain',
|
||||
}), [item.posterUri]);
|
||||
const dimensionsStyle = useMemo(() => {
|
||||
const w = width;
|
||||
const extra = VIDEO_INSET + GALLERY_FOOTER_HEIGHT + Platform.select({default: 0, android: ANDROID_VIDEO_INSET});
|
||||
const insets = headerAndFooterHiddenValue && Platform.OS === 'ios' ? 0 : extra;
|
||||
const h = height - (insets + bottom);
|
||||
return {width: w, height: h};
|
||||
}, [width, height, bottom, headerAndFooterHiddenValue]);
|
||||
const seekSeconds = useMemo(() => {
|
||||
const durationMs = toMilliseconds({seconds: duration});
|
||||
if (durationMs < toMilliseconds({seconds: 10})) {
|
||||
return 0;
|
||||
} else if (durationMs < toMilliseconds({minutes: 15})) {
|
||||
return 10;
|
||||
}
|
||||
return 30;
|
||||
}, [duration]);
|
||||
const videoHasCaptions = useMemo(() => hasCaptions(item.postProps), [item.postProps]);
|
||||
|
||||
const onDownloadSuccess = useCallback((path: string) => {
|
||||
setVideoUri(path);
|
||||
setHasError(false);
|
||||
updateLocalFilePath(serverUrl, item.id, path);
|
||||
}, [serverUrl, item.id]);
|
||||
|
||||
const onEnd = useCallback(() => {
|
||||
hideHeaderAndFooter(false);
|
||||
setPaused(true);
|
||||
setShowCustomControls(true);
|
||||
}, [hideHeaderAndFooter]);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setHasError(true);
|
||||
}, []);
|
||||
|
||||
const onProgress = useCallback((data: {currentTime: number}) => {
|
||||
if (progressDebounceTimeoutRef.current) {
|
||||
clearTimeout(progressDebounceTimeoutRef.current);
|
||||
}
|
||||
progressDebounceTimeoutRef.current = setTimeout(() => {
|
||||
currentTime.value = data.currentTime;
|
||||
}, 100);
|
||||
}, [currentTime]);
|
||||
|
||||
const onLoad = useCallback((data: {duration: number}) => {
|
||||
setDuration(data.duration);
|
||||
}, []);
|
||||
|
||||
const onPlaybackStateChanged = useCallback(({isPlaying}: OnPlaybackStateChangedData) => {
|
||||
if (playbackStateDebounceTimeoutRef.current) {
|
||||
clearTimeout(playbackStateDebounceTimeoutRef.current);
|
||||
}
|
||||
playbackStateDebounceTimeoutRef.current = setTimeout(() => {
|
||||
if (isPageActiveValue) {
|
||||
setPaused(!isPlaying);
|
||||
}
|
||||
}, 200);
|
||||
}, [isPageActiveValue]);
|
||||
|
||||
const onReadyForDisplay = useCallback(() => {
|
||||
setVideoReady(true);
|
||||
setHasError(false);
|
||||
}, []);
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
if (Math.floor(currentTime.value) === Math.floor(duration)) {
|
||||
// If the video has ended, seek to the beginning
|
||||
videoRef.current?.seek(0.0);
|
||||
}
|
||||
setPaused(false);
|
||||
|
||||
// No need for shared values
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [duration]);
|
||||
|
||||
const onPause = useCallback(() => {
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const onSeek = useCallback((time: number) => {
|
||||
videoRef.current?.seek(time);
|
||||
}, []);
|
||||
|
||||
const onRewind = useCallback(() => {
|
||||
const newTime = Math.max(0, currentTime.value - seekSeconds);
|
||||
videoRef.current?.seek(newTime);
|
||||
|
||||
// No need for shared values
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seekSeconds]);
|
||||
|
||||
const onForward = useCallback(() => {
|
||||
const newTime = Math.min(duration, currentTime.value + seekSeconds);
|
||||
videoRef.current?.seek(newTime);
|
||||
|
||||
// No need for shared values
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [duration, seekSeconds]);
|
||||
|
||||
const onRateChange = useCallback((rate: number) => {
|
||||
setPlaybackRate(rate);
|
||||
}, []);
|
||||
|
||||
const onFullscreenToggle = useCallback(() => {
|
||||
hideHeaderAndFooter(!headerAndFooterHiddenValue);
|
||||
StatusBar.setHidden(!headerAndFooterHiddenValue, 'slide');
|
||||
}, [headerAndFooterHiddenValue, hideHeaderAndFooter]);
|
||||
|
||||
const onCaptionsPress = useCallback(() => {
|
||||
setCaptionsEnabled((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const setGalleryAction = useCallback((action: GalleryAction) => {
|
||||
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
|
||||
if (action === 'none') {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
width: withTiming(dimensionsStyle.width, transformerTimingConfig),
|
||||
height: '100%',
|
||||
};
|
||||
}, [dimensionsStyle]);
|
||||
|
||||
const subtitleStyle = useMemo(() => ({
|
||||
subtitlesFollowVideo: true,
|
||||
fontSize: width > height ? 14 : 12,
|
||||
paddingBottom: ANDROID_VIDEO_INSET + (width > height ? VIDEO_INSET : 0),
|
||||
paddingRight: 4,
|
||||
paddingLeft: 4,
|
||||
}), [width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialIndex === index && videoReady) {
|
||||
setPaused(false);
|
||||
hideControlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowCustomControls(false);
|
||||
}, 1000);
|
||||
} else if (videoReady) {
|
||||
videoRef.current?.seek(0.4);
|
||||
}
|
||||
|
||||
}, [index, initialIndex, videoReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActiveValue && !paused) {
|
||||
setShowCustomControls(true);
|
||||
setPaused(true);
|
||||
}
|
||||
}, [isPageActiveValue, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const hideControlsTimeout = hideControlsTimeoutRef.current;
|
||||
const playbackStateDebounceTimeout = playbackStateDebounceTimeoutRef.current;
|
||||
const progressDebounceTimeout = progressDebounceTimeoutRef.current;
|
||||
|
||||
if (hideControlsTimeout) {
|
||||
clearTimeout(hideControlsTimeout);
|
||||
}
|
||||
|
||||
if (playbackStateDebounceTimeout) {
|
||||
clearTimeout(playbackStateDebounceTimeout);
|
||||
}
|
||||
|
||||
if (progressDebounceTimeout) {
|
||||
clearTimeout(progressDebounceTimeout);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
{!hasError &&
|
||||
<>
|
||||
<Video
|
||||
|
||||
//@ts-expect-error legacy ref
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
paused={paused}
|
||||
poster={poster}
|
||||
onError={onError}
|
||||
style={styles.video}
|
||||
controls={false}
|
||||
rate={playbackRate}
|
||||
onProgress={onProgress}
|
||||
onLoad={onLoad}
|
||||
onPlaybackStateChanged={onPlaybackStateChanged}
|
||||
onReadyForDisplay={onReadyForDisplay}
|
||||
onEnd={onEnd}
|
||||
resizeMode='none'
|
||||
selectedTextTrack={captionsEnabled ? selected : {type: SelectedTrackType.DISABLED, value: ''}}
|
||||
subtitleStyle={subtitleStyle}
|
||||
playWhenInactive={true}
|
||||
/>
|
||||
<VideoControls
|
||||
visible={showCustomControls}
|
||||
paused={paused}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
speed={playbackRate}
|
||||
isFullscreen={headerAndFooterHiddenValue}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onSeek={onSeek}
|
||||
onRewind={onRewind}
|
||||
onForward={onForward}
|
||||
onSpeedChange={onRateChange}
|
||||
onFullscreen={onFullscreenToggle}
|
||||
onCaptionsToggle={onCaptionsPress}
|
||||
hasCaptions={videoHasCaptions}
|
||||
captionsEnabled={selected.type !== SelectedTrackType.DISABLED && captionsEnabled}
|
||||
seekSeconds={seekSeconds}
|
||||
setShowCustomControls={setShowCustomControls}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{hasError &&
|
||||
<VideoError
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
enableSecureFilePreview={enableSecureFilePreview}
|
||||
filename={item.name}
|
||||
isDownloading={downloading}
|
||||
isRemote={videoUri.startsWith('http')}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
posterUri={item.posterUri}
|
||||
setDownloading={setDownloading}
|
||||
height={item.height}
|
||||
width={item.width}
|
||||
/>
|
||||
}
|
||||
{downloading &&
|
||||
<DownloadWithAction
|
||||
action='external'
|
||||
setAction={setGalleryAction}
|
||||
onDownloadSuccess={onDownloadSuccess}
|
||||
enableSecureFilePreview={enableSecureFilePreview}
|
||||
item={item}
|
||||
/>
|
||||
}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoRenderer;
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {DeviceEventEmitter, StyleSheet} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
type WithTimingConfig,
|
||||
} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import Video, {SelectedTrackType, type OnPlaybackRateChangeData, type ReactVideoPoster, type ReactVideoSource, type VideoRef} from 'react-native-video';
|
||||
|
||||
import {updateLocalFilePath} from '@actions/local/file';
|
||||
import {CaptionsEnabledContext} from '@calls/context';
|
||||
import {getTranscriptionUri} from '@calls/utils';
|
||||
import {Events} from '@constants';
|
||||
import {GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
|
||||
import DownloadWithAction from '../footer/download_with_action';
|
||||
|
||||
import VideoError from './error';
|
||||
|
||||
import type {ImageRendererProps} from '../image_renderer';
|
||||
import type {GalleryAction} from '@typings/screens/gallery';
|
||||
|
||||
export interface VideoRendererProps extends ImageRendererProps {
|
||||
canDownloadFiles: boolean;
|
||||
enableSecureFilePreview: boolean;
|
||||
index: number;
|
||||
initialIndex: number;
|
||||
onShouldHideControls: (hide: boolean) => void;
|
||||
}
|
||||
|
||||
const timingConfig: WithTimingConfig = {
|
||||
duration: 250,
|
||||
easing: Easing.bezier(0.33, 0.01, 0, 1),
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
video: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
|
||||
const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => {
|
||||
const fullscreen = useSharedValue(false);
|
||||
const {bottom} = useSafeAreaInsets();
|
||||
const serverUrl = useServerUrl();
|
||||
const videoRef = useRef<VideoRef>();
|
||||
const showControls = useRef(!(initialIndex === index));
|
||||
const captionsEnabled = useContext(CaptionsEnabledContext);
|
||||
const [paused, setPaused] = useState(!(initialIndex === index));
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [videoUri, setVideoUri] = useState(item.uri);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const {tracks, selected} = useMemo(() => getTranscriptionUri(serverUrl, item.postProps), [serverUrl, item.postProps]);
|
||||
const source: ReactVideoSource = useMemo(() => ({uri: videoUri, textTracks: tracks}), [videoUri, tracks]);
|
||||
const poster: ReactVideoPoster = useMemo(() => ({
|
||||
source: {uri: item.posterUri},
|
||||
resizeMode: 'center',
|
||||
}), [item.posterUri]);
|
||||
|
||||
const setFullscreen = useCallback((value: boolean) => {
|
||||
fullscreen.value = value;
|
||||
}, []);
|
||||
|
||||
const onDownloadSuccess = (path: string) => {
|
||||
setVideoUri(path);
|
||||
setHasError(false);
|
||||
updateLocalFilePath(serverUrl, item.id, path);
|
||||
};
|
||||
|
||||
const onEnd = useCallback(() => {
|
||||
setFullscreen(false);
|
||||
onShouldHideControls(true);
|
||||
showControls.current = true;
|
||||
setPaused(true);
|
||||
videoRef.current?.dismissFullscreenPlayer();
|
||||
}, [onShouldHideControls, setFullscreen]);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setHasError(true);
|
||||
}, []);
|
||||
|
||||
const onFullscreenPlayerWillDismiss = useCallback(() => {
|
||||
setFullscreen(false);
|
||||
showControls.current = !paused;
|
||||
onShouldHideControls(showControls.current);
|
||||
}, [setFullscreen, paused, onShouldHideControls]);
|
||||
|
||||
const onFullscreenPlayerWillPresent = useCallback(() => {
|
||||
setFullscreen(true);
|
||||
onShouldHideControls(true);
|
||||
showControls.current = true;
|
||||
}, [onShouldHideControls, setFullscreen]);
|
||||
|
||||
const onPlaybackRateChange = useCallback(({playbackRate}: OnPlaybackRateChangeData) => {
|
||||
if (isPageActive.value) {
|
||||
const isPlaying = Boolean(playbackRate);
|
||||
showControls.current = isPlaying;
|
||||
onShouldHideControls(isPlaying);
|
||||
}
|
||||
}, [isPageActive.value, onShouldHideControls]);
|
||||
|
||||
const onReadyForDisplay = useCallback(() => {
|
||||
setVideoReady(true);
|
||||
setHasError(false);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
showControls.current = !showControls.current;
|
||||
onShouldHideControls(showControls.current);
|
||||
}, [onShouldHideControls]);
|
||||
|
||||
const setGalleryAction = useCallback((action: GalleryAction) => {
|
||||
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
|
||||
if (action === 'none') {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dimensionsStyle = useMemo(() => {
|
||||
const w = width;
|
||||
const h = height - (VIDEO_INSET + GALLERY_FOOTER_HEIGHT + bottom);
|
||||
|
||||
return {width: w, height: h};
|
||||
}, [width, height, bottom]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
width: withTiming(dimensionsStyle.width, timingConfig),
|
||||
height: withTiming(dimensionsStyle.height, timingConfig),
|
||||
};
|
||||
}, [dimensionsStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialIndex === index && videoReady) {
|
||||
setPaused(false);
|
||||
} else if (videoReady) {
|
||||
videoRef.current?.seek(0.4);
|
||||
}
|
||||
}, [index, initialIndex, videoReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive.value && !paused) {
|
||||
setPaused(true);
|
||||
videoRef.current?.dismissFullscreenPlayer();
|
||||
}
|
||||
}, [isPageActive.value, paused]);
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Video
|
||||
|
||||
//@ts-expect-error legacy ref
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
paused={paused}
|
||||
poster={poster}
|
||||
onError={onError}
|
||||
style={[styles.video, dimensionsStyle]}
|
||||
controls={isPageActive.value}
|
||||
onPlaybackRateChange={onPlaybackRateChange}
|
||||
onFullscreenPlayerWillDismiss={onFullscreenPlayerWillDismiss}
|
||||
onFullscreenPlayerWillPresent={onFullscreenPlayerWillPresent}
|
||||
onReadyForDisplay={onReadyForDisplay}
|
||||
onEnd={onEnd}
|
||||
onTouchStart={handleTouchStart}
|
||||
resizeMode='none'
|
||||
selectedTextTrack={captionsEnabled[index] ? selected : {type: SelectedTrackType.DISABLED, value: ''}}
|
||||
/>
|
||||
{hasError &&
|
||||
<VideoError
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
enableSecureFilePreview={enableSecureFilePreview}
|
||||
filename={item.name}
|
||||
isDownloading={downloading}
|
||||
isRemote={videoUri.startsWith('http')}
|
||||
onShouldHideControls={handleTouchStart}
|
||||
posterUri={item.posterUri}
|
||||
setDownloading={setDownloading}
|
||||
height={item.height}
|
||||
width={item.width}
|
||||
/>
|
||||
}
|
||||
{downloading &&
|
||||
<DownloadWithAction
|
||||
action='external'
|
||||
enableSecureFilePreview={enableSecureFilePreview}
|
||||
setAction={setGalleryAction}
|
||||
onDownloadSuccess={onDownloadSuccess}
|
||||
item={item}
|
||||
/>
|
||||
}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoRenderer;
|
||||
|
|
@ -4,45 +4,33 @@
|
|||
import React, {useCallback, useRef} from 'react';
|
||||
import {runOnJS} from 'react-native-reanimated';
|
||||
|
||||
import ImageRenderer, {type Handlers, type ImageRendererProps} from '../image_renderer';
|
||||
import Pager from '../pager';
|
||||
import ImageRenderer from '../renderers/image';
|
||||
|
||||
import type {InteractionType} from '../image_renderer/transformer';
|
||||
import type {RenderPageProps} from '../pager/page';
|
||||
import type {GalleryItemType} from '@typings/screens/gallery';
|
||||
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
export interface GalleryViewerProps extends Handlers {
|
||||
export interface GalleryViewerProps {
|
||||
gutterWidth?: number;
|
||||
height: number;
|
||||
initialIndex?: number;
|
||||
items: GalleryItemType[];
|
||||
keyExtractor?: (item: GalleryItemType, index: number) => string;
|
||||
numToRender?: number;
|
||||
onIndexChange?: (nextIndex: number) => void;
|
||||
renderPage?: (props: ImageRendererProps, index: number) => JSX.Element | null;
|
||||
renderPage?: (props: GalleryPagerItem, index: number) => JSX.Element | null;
|
||||
width: number;
|
||||
hideHeaderAndFooter: (hide: boolean) => void;
|
||||
}
|
||||
|
||||
const GalleryViewer = ({
|
||||
gutterWidth, height, initialIndex, items, keyExtractor, numToRender,
|
||||
onDoubleTap, onGesture, onIndexChange, onInteraction, onPagerEnabledGesture,
|
||||
onShouldHideControls, onTap, renderPage, shouldPagerHandleGestureEvent, width,
|
||||
gutterWidth, height, initialIndex, items, numToRender,
|
||||
onIndexChange, renderPage, width, hideHeaderAndFooter,
|
||||
}: GalleryViewerProps) => {
|
||||
const controlsHidden = useRef(false);
|
||||
const tempIndex = useRef<number>(initialIndex || 0);
|
||||
|
||||
const setTempIndex = (nextIndex: number) => {
|
||||
tempIndex.current = nextIndex;
|
||||
};
|
||||
|
||||
const extractKey = useCallback((item: GalleryItemType, index: number) => {
|
||||
if (typeof keyExtractor === 'function') {
|
||||
return keyExtractor(item, index);
|
||||
}
|
||||
|
||||
return item.id;
|
||||
}, [items]);
|
||||
|
||||
const onIndexChangeWorklet = useCallback((nextIndex: number) => {
|
||||
'worklet';
|
||||
|
||||
|
|
@ -51,92 +39,39 @@ const GalleryViewer = ({
|
|||
if (onIndexChange) {
|
||||
onIndexChange(nextIndex);
|
||||
}
|
||||
}, []);
|
||||
}, [onIndexChange]);
|
||||
|
||||
const pageToRender = useCallback((pagerProps: RenderPageProps, index: number) => {
|
||||
const shouldHideControls = (isScaled?: boolean | InteractionType) => {
|
||||
let shouldHide = true;
|
||||
|
||||
if (typeof isScaled === 'boolean') {
|
||||
shouldHide = !isScaled;
|
||||
} else if (typeof isScaled === 'string') {
|
||||
shouldHide = true;
|
||||
} else {
|
||||
shouldHide = !controlsHidden.current;
|
||||
}
|
||||
|
||||
controlsHidden.current = shouldHide;
|
||||
|
||||
if (onShouldHideControls) {
|
||||
onShouldHideControls(shouldHide);
|
||||
}
|
||||
};
|
||||
|
||||
const doubleTap = (isScaled: boolean) => {
|
||||
'worklet';
|
||||
|
||||
if (onDoubleTap) {
|
||||
onDoubleTap(isScaled);
|
||||
}
|
||||
|
||||
runOnJS(shouldHideControls)(isScaled);
|
||||
};
|
||||
|
||||
const tap = (isScaled: boolean) => {
|
||||
'worklet';
|
||||
|
||||
if (onTap) {
|
||||
onTap(isScaled);
|
||||
}
|
||||
|
||||
runOnJS(shouldHideControls)();
|
||||
};
|
||||
|
||||
const interaction = (type: InteractionType) => {
|
||||
'worklet';
|
||||
|
||||
if (onInteraction) {
|
||||
onInteraction(type);
|
||||
}
|
||||
|
||||
runOnJS(shouldHideControls)(type);
|
||||
};
|
||||
|
||||
const props: ImageRendererProps = {
|
||||
const pageToRender = useCallback((pagerProps: GalleryPagerItem, index: number) => {
|
||||
const props: GalleryPagerItem = {
|
||||
...pagerProps,
|
||||
width,
|
||||
height,
|
||||
onDoubleTap: doubleTap,
|
||||
onTap: tap,
|
||||
onInteraction: interaction,
|
||||
};
|
||||
|
||||
if (
|
||||
props.item.type !== 'image' &&
|
||||
props.item.type !== 'avatar' &&
|
||||
pagerProps.item.type !== 'image' &&
|
||||
pagerProps.item.type !== 'avatar' &&
|
||||
typeof renderPage === 'function'
|
||||
) {
|
||||
return renderPage(props, index);
|
||||
}
|
||||
|
||||
return (<ImageRenderer {...props}/>);
|
||||
}, [items, width, height]);
|
||||
}, [width, height, renderPage]);
|
||||
|
||||
return (
|
||||
<Pager
|
||||
totalCount={items.length}
|
||||
keyExtractor={extractKey}
|
||||
initialIndex={tempIndex.current}
|
||||
pages={items}
|
||||
width={width}
|
||||
height={height}
|
||||
gutterWidth={gutterWidth}
|
||||
onIndexChange={onIndexChangeWorklet}
|
||||
shouldHandleGestureEvent={shouldPagerHandleGestureEvent}
|
||||
onGesture={onGesture}
|
||||
onEnabledGesture={onPagerEnabledGesture}
|
||||
renderPage={pageToRender}
|
||||
numToRender={numToRender}
|
||||
hideHeaderAndFooter={hideHeaderAndFooter}
|
||||
shouldRenderGutter={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I
|
|||
}, [animate, insets.top]);
|
||||
|
||||
const message = notification.payload?.body || notification.payload?.message;
|
||||
// eslint-disable-next-line new-cap
|
||||
const gesture = Gesture.Pan().activeOffsetY(-20).onStart(() => runOnJS(animateDismissOverlay)());
|
||||
|
||||
const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database;
|
||||
|
|
|
|||
|
|
@ -211,9 +211,7 @@ const SnackBar = ({
|
|||
}
|
||||
};
|
||||
|
||||
const gesture = Gesture.
|
||||
// eslint-disable-next-line new-cap
|
||||
Pan().
|
||||
const gesture = Gesture.Pan().
|
||||
activeOffsetY(20).
|
||||
onStart(() => {
|
||||
isPanned.value = true;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DeviceEventEmitter, Image, Keyboard} from 'react-native';
|
||||
import {DeviceEventEmitter, Image, Keyboard, View} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import {measure, type AnimatedRef} from 'react-native-reanimated';
|
||||
|
||||
import {waitFor} from '@test/intl-test-helper';
|
||||
|
||||
import {clamp, clampVelocity, fileToGalleryItem, freezeOtherScreens, friction, galleryItemToFileInfo, getImageSize, getShouldRender, measureItem, openGalleryAtIndex, typedMemo, workletNoop, workletNoopTrue} from '.';
|
||||
import {clamp, clampVelocity, fileToGalleryItem, freezeOtherScreens, friction, galleryItemToFileInfo, getImageSize, getShouldRender, measureItem, measureViewInWindow, openGalleryAtIndex, typedMemo} from '.';
|
||||
|
||||
import type {GalleryItemType, GalleryManagerSharedValues} from '@typings/screens/gallery';
|
||||
|
||||
|
|
@ -217,18 +217,6 @@ describe('Gallery utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('workletNoop', () => {
|
||||
it('should execute without doing anything', () => {
|
||||
expect(workletNoop()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workletNoopTrue', () => {
|
||||
it('should always return true', () => {
|
||||
expect(workletNoopTrue()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageSize', () => {
|
||||
it('should resolve with image size', async () => {
|
||||
jest.spyOn(Image, 'getSize').mockImplementationOnce((uri, success) => {
|
||||
|
|
@ -248,4 +236,25 @@ describe('Gallery utils', () => {
|
|||
await expect(getImageSize('test-uri')).rejects.toThrow('Failed to get size');
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureViewInWindow', () => {
|
||||
it('should resolve with measured values when ref.current exists', async () => {
|
||||
const measureMock = jest.fn((cb) => {
|
||||
// x, y, width, height, pageX, pageY
|
||||
cb(0, 0, 120, 80, 50, 60);
|
||||
});
|
||||
const ref = {current: {measure: measureMock}} as unknown as React.RefObject<View>;
|
||||
|
||||
const result = await measureViewInWindow(ref);
|
||||
expect(result).toEqual({x: 50, y: 60, width: 120, height: 80});
|
||||
expect(measureMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve with zeros when ref.current does not exist', async () => {
|
||||
const ref = {current: null} as unknown as React.RefObject<View>;
|
||||
|
||||
const result = await measureViewInWindow(ref);
|
||||
expect(result).toEqual({x: 0, y: 0, width: 0, height: 0});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import RNUtils from '@mattermost/rnutils';
|
||||
import React from 'react';
|
||||
import {DeviceEventEmitter, Image, Keyboard, Platform} from 'react-native';
|
||||
import React, {type RefObject} from 'react';
|
||||
import {DeviceEventEmitter, Image, Keyboard, Platform, View} from 'react-native';
|
||||
import {Navigation, type Options, type OptionsLayout} from 'react-native-navigation';
|
||||
import {measure, type AnimatedRef} from 'react-native-reanimated';
|
||||
|
||||
|
|
@ -120,6 +120,28 @@ export function measureItem(ref: AnimatedRef<any>, sharedValues: GalleryManagerS
|
|||
}
|
||||
}
|
||||
|
||||
export function measureViewInWindow(ref: RefObject<View>): Promise<{x: number; y: number; width: number; height: number}> {
|
||||
return new Promise((resolve) => {
|
||||
if (ref.current) {
|
||||
ref.current.measure((x, y, width, height, pageX, pageY) => {
|
||||
resolve({
|
||||
x: pageX,
|
||||
y: pageY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: number, items: GalleryItemType[], hideActions = false) {
|
||||
Keyboard.dismiss();
|
||||
const props = {
|
||||
|
|
@ -168,16 +190,6 @@ export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: numb
|
|||
|
||||
export const typedMemo: <T>(c: T) => T = React.memo;
|
||||
|
||||
export const workletNoop = () => {
|
||||
'worklet';
|
||||
};
|
||||
|
||||
export const workletNoopTrue = () => {
|
||||
'worklet';
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getImageSize = (uri: string) => {
|
||||
return new Promise<{width: number; height: number}>((resolve, reject) => {
|
||||
Image.getSize(uri, (width, height) => resolve({width, height}), reject);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Animated, {useSharedValue} from 'react-native-reanimated';
|
||||
import Animated, {useSharedValue, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
type SharedValueType = number;
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ const isSharedValue = (
|
|||
return typeof value.value !== 'undefined';
|
||||
};
|
||||
|
||||
const get = <T extends Animated.SharedValue<SharedValueType> | SharedValueType>(
|
||||
const get = <T extends SharedValue<SharedValueType> | SharedValueType>(
|
||||
value: T,
|
||||
) => {
|
||||
'worklet';
|
||||
|
|
@ -89,14 +89,19 @@ const reduce = (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const useSharedVector = <T>(x: T, y = x) => {
|
||||
export type ShareVectorType<T> = {
|
||||
x: SharedValue<T>;
|
||||
y: SharedValue<T>;
|
||||
}
|
||||
|
||||
export const useSharedVector = <T>(x: T, y = x): ShareVectorType<T> => {
|
||||
return {
|
||||
x: useSharedValue(x),
|
||||
y: useSharedValue(y),
|
||||
};
|
||||
};
|
||||
|
||||
export const create = <T extends SharedValueType>(x: T, y: T) => {
|
||||
export const create = <T extends SharedValueType>(x: T, y: T): Vector<T> => {
|
||||
'worklet';
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import RNUtils from '@mattermost/rnutils';
|
||||
|
||||
import {IMAGE_MAX_HEIGHT} from '@constants/image';
|
||||
import {IMAGE_MAX_HEIGHT, IMAGE_MIN_DIMENSION} from '@constants/image';
|
||||
|
||||
import {
|
||||
calculateDimensions,
|
||||
|
|
@ -94,6 +94,109 @@ describe('calculateDimensions', () => {
|
|||
width: 900 * (900 / 1700),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative image dimensions gracefully', () => {
|
||||
const result = calculateDimensions(-100, -200, 300);
|
||||
expect(result.height).toBeGreaterThanOrEqual(0);
|
||||
expect(result.width).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should handle zero viewport width', () => {
|
||||
const result = calculateDimensions(100, 200, 0);
|
||||
expect(result).toEqual({height: 0, width: 0});
|
||||
});
|
||||
|
||||
it('should handle square images', () => {
|
||||
const result = calculateDimensions(100, 100, 50);
|
||||
expect(result).toEqual({height: 50, width: 50});
|
||||
});
|
||||
|
||||
it('should handle extremely large images', () => {
|
||||
const result = calculateDimensions(10000, 5000, 1000, 800);
|
||||
expect(result.height).toBeLessThanOrEqual(800);
|
||||
expect(result.width).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('should handle extremely small images', () => {
|
||||
const result = calculateDimensions(1, 1, 100);
|
||||
expect(result.height).toBeGreaterThanOrEqual(IMAGE_MIN_DIMENSION);
|
||||
expect(result.width).toBeGreaterThanOrEqual(IMAGE_MIN_DIMENSION);
|
||||
});
|
||||
|
||||
it('should handle when only width is provided', () => {
|
||||
const result = calculateDimensions(undefined, 200, 300);
|
||||
expect(result).toEqual({height: 0, width: 0});
|
||||
});
|
||||
|
||||
it('should handle when only height is provided', () => {
|
||||
const result = calculateDimensions(200, undefined, 300);
|
||||
expect(result).toEqual({height: 0, width: 0});
|
||||
});
|
||||
|
||||
it('should handle when all parameters are undefined', () => {
|
||||
const result = calculateDimensions(undefined, undefined, undefined, undefined);
|
||||
expect(result).toEqual({height: 0, width: 0});
|
||||
});
|
||||
|
||||
it('should fit portrait image to viewport height when matchViewPort is true', () => {
|
||||
const result = calculateDimensions(800, 400, 600, 900, true);
|
||||
expect(result.height).toBe(900);
|
||||
expect(result.width).toBe(450);
|
||||
});
|
||||
|
||||
it('should fit landscape image to viewport width when matchViewPort is true', () => {
|
||||
const result = calculateDimensions(400, 800, 600, 900, true);
|
||||
expect(result.width).toBe(600);
|
||||
expect(result.height).toBe(300);
|
||||
});
|
||||
|
||||
it('should fit square image to viewport width when matchViewPort is true', () => {
|
||||
const result = calculateDimensions(500, 500, 300, 700, true);
|
||||
expect(result.width).toBe(300);
|
||||
expect(result.height).toBe(300);
|
||||
});
|
||||
|
||||
it('should not fit to viewport if matchViewPort is false', () => {
|
||||
const result = calculateDimensions(800, 400, 600, 900, false);
|
||||
expect(result.height).not.toBe(900);
|
||||
expect(result.width).not.toBe(450);
|
||||
});
|
||||
|
||||
it('should handle when viewPortHeight is 0', () => {
|
||||
const result = calculateDimensions(800, 400, 600, 0, true);
|
||||
expect(result.height).not.toBe(0);
|
||||
expect(result.width).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should handle when matchViewPort is true and image is smaller than viewport', () => {
|
||||
const result = calculateDimensions(100, 80, 300, 300, true);
|
||||
expect(result.width).toBe(240);
|
||||
expect(result.height).toBe(300);
|
||||
});
|
||||
|
||||
it('should handle when matchViewPort is true and image is square', () => {
|
||||
const result = calculateDimensions(100, 100, 200, 300, true);
|
||||
expect(result.width).toBe(200);
|
||||
expect(result.height).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle when matchViewPort is true and image is wider than tall', () => {
|
||||
const result = calculateDimensions(100, 200, 150, 300, true);
|
||||
expect(result.width).toBe(150);
|
||||
expect(result.height).toBe(75);
|
||||
});
|
||||
|
||||
it('should handle when matchViewPort is true and image is taller than wide', () => {
|
||||
const result = calculateDimensions(200, 100, 150, 300, true);
|
||||
expect(result.height).toBe(300);
|
||||
expect(result.width).toBe(150);
|
||||
});
|
||||
|
||||
it('should handle when matchViewPort is true and image is exactly viewport size', () => {
|
||||
const result = calculateDimensions(300, 300, 300, 300, true);
|
||||
expect(result.width).toBe(300);
|
||||
expect(result.height).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewPortWidth', () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
VIEWPORT_IMAGE_REPLY_OFFSET,
|
||||
} from '@constants/image';
|
||||
|
||||
export const calculateDimensions = (height?: number, width?: number, viewPortWidth = 0, viewPortHeight = 0) => {
|
||||
export const calculateDimensions = (height?: number, width?: number, viewPortWidth = 0, viewPortHeight = 0, matchViewPort?: boolean) => {
|
||||
'worklet';
|
||||
|
||||
if (!height || !width) {
|
||||
|
|
@ -48,6 +48,22 @@ export const calculateDimensions = (height?: number, width?: number, viewPortWid
|
|||
imageWidth = imageHeight * heightRatio;
|
||||
}
|
||||
|
||||
if (
|
||||
matchViewPort &&
|
||||
width < viewPortWidth &&
|
||||
height < viewPortHeight
|
||||
) {
|
||||
if (height > width) {
|
||||
// Portrait: fit to viewport height
|
||||
imageHeight = viewPortHeight;
|
||||
imageWidth = imageHeight * heightRatio;
|
||||
} else {
|
||||
// Landscape or square: fit to viewport width
|
||||
imageWidth = viewPortWidth;
|
||||
imageHeight = imageWidth * ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
|
|
|
|||
|
|
@ -1350,8 +1350,11 @@
|
|||
"user.settings.general.username": "Username",
|
||||
"user.settings.notifications.email_threads.description": "Notify me about all replies to threads I'm following",
|
||||
"user.tutorial.long_press": "Long-press on an item to view a user's profile",
|
||||
"video.done": "Done",
|
||||
"video.download": "Download video",
|
||||
"video.download_description": "This video must be downloaded to play it.",
|
||||
"video.failed_description": "An error occurred while trying to play the video.",
|
||||
"video.normal": "Normal",
|
||||
"video.playback_speed": "Playback Speed",
|
||||
"your.servers": "Your servers"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,22 @@ export default defineConfig([
|
|||
"global-require": "off",
|
||||
"no-undefined": "off",
|
||||
"no-shadow": "off",
|
||||
"new-cap": [
|
||||
"error",
|
||||
{
|
||||
"capIsNewExceptions": [
|
||||
"Gesture.Pan",
|
||||
"Gesture.Tap",
|
||||
"Gesture.LongPress",
|
||||
"Gesture.Pinch",
|
||||
"Gesture.Rotate",
|
||||
"Gesture.ForceTouch",
|
||||
"Gesture.Exclusive",
|
||||
"Gesture.Simultaneous",
|
||||
"Gesture.Race",
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/display-name": [2, { "ignoreTranspilerName": false }],
|
||||
"react/jsx-filename-extension": "off",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
|
|
|
|||
505
eslint/eslint-mattermost.json
Normal file
505
eslint/eslint-mattermost.json
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
{
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 8,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
"impliedStrict": true,
|
||||
"modules": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"header"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"jquery": true,
|
||||
"es6": true
|
||||
},
|
||||
"rules": {
|
||||
"array-bracket-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"array-callback-return": 2,
|
||||
"arrow-body-style": 0,
|
||||
"arrow-parens": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"arrow-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": true,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"block-scoped-var": 2,
|
||||
"brace-style": [
|
||||
2,
|
||||
"1tbs",
|
||||
{
|
||||
"allowSingleLine": false
|
||||
}
|
||||
],
|
||||
"camelcase": [
|
||||
2,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"capitalized-comments": 0,
|
||||
"class-methods-use-this": 0,
|
||||
"comma-dangle": [
|
||||
2,
|
||||
"always-multiline"
|
||||
],
|
||||
"comma-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"comma-style": [
|
||||
2,
|
||||
"last"
|
||||
],
|
||||
"complexity": [
|
||||
0,
|
||||
10
|
||||
],
|
||||
"computed-property-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"consistent-return": 2,
|
||||
"consistent-this": [
|
||||
2,
|
||||
"self"
|
||||
],
|
||||
"constructor-super": 2,
|
||||
"curly": [
|
||||
2,
|
||||
"all"
|
||||
],
|
||||
"dot-location": [
|
||||
2,
|
||||
"object"
|
||||
],
|
||||
"dot-notation": 2,
|
||||
"eqeqeq": [
|
||||
2,
|
||||
"smart"
|
||||
],
|
||||
"func-call-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"func-name-matching": 0,
|
||||
"func-names": 2,
|
||||
"func-style": [
|
||||
2,
|
||||
"declaration",
|
||||
{
|
||||
"allowArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"generator-star-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"global-require": 2,
|
||||
"guard-for-in": 2,
|
||||
"header/header": [
|
||||
2,
|
||||
"line",
|
||||
" Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.\n See LICENSE.txt for license information."
|
||||
],
|
||||
"id-blacklist": 0,
|
||||
"indent": [
|
||||
2,
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 0
|
||||
}
|
||||
],
|
||||
"jsx-quotes": [
|
||||
2,
|
||||
"prefer-single"
|
||||
],
|
||||
"key-spacing": [
|
||||
2,
|
||||
{
|
||||
"beforeColon": false,
|
||||
"afterColon": true,
|
||||
"mode": "strict"
|
||||
}
|
||||
],
|
||||
"keyword-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": true,
|
||||
"after": true,
|
||||
"overrides": {}
|
||||
}
|
||||
],
|
||||
"line-comment-position": 0,
|
||||
"linebreak-style": 2,
|
||||
"lines-around-comment": [
|
||||
2,
|
||||
{
|
||||
"beforeBlockComment": true,
|
||||
"beforeLineComment": true,
|
||||
"allowBlockStart": true,
|
||||
"allowBlockEnd": true
|
||||
}
|
||||
],
|
||||
"max-lines": [
|
||||
1,
|
||||
{
|
||||
"max": 550,
|
||||
"skipBlankLines": true,
|
||||
"skipComments": true
|
||||
}
|
||||
],
|
||||
"max-nested-callbacks": [
|
||||
2,
|
||||
{
|
||||
"max": 2
|
||||
}
|
||||
],
|
||||
"max-statements-per-line": [
|
||||
2,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"multiline-ternary": [
|
||||
1,
|
||||
"never"
|
||||
],
|
||||
"new-cap": 2,
|
||||
"new-parens": 2,
|
||||
"newline-before-return": 0,
|
||||
"newline-per-chained-call": 0,
|
||||
"no-alert": 2,
|
||||
"no-array-constructor": 2,
|
||||
"no-await-in-loop": 2,
|
||||
"no-caller": 2,
|
||||
"no-case-declarations": 2,
|
||||
"no-class-assign": 2,
|
||||
"no-compare-neg-zero": 2,
|
||||
"no-cond-assign": [
|
||||
2,
|
||||
"except-parens"
|
||||
],
|
||||
"no-confusing-arrow": 2,
|
||||
"no-console": 2,
|
||||
"no-const-assign": 2,
|
||||
"no-constant-condition": 2,
|
||||
"no-debugger": 2,
|
||||
"no-div-regex": 2,
|
||||
"no-dupe-args": 2,
|
||||
"no-dupe-class-members": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-duplicate-imports": [
|
||||
2,
|
||||
{
|
||||
"includeExports": true
|
||||
}
|
||||
],
|
||||
"no-else-return": 2,
|
||||
"no-empty": 2,
|
||||
"no-empty-function": 2,
|
||||
"no-empty-pattern": 2,
|
||||
"no-eval": 2,
|
||||
"no-ex-assign": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-bind": 2,
|
||||
"no-extra-label": 2,
|
||||
"no-extra-parens": 0,
|
||||
"no-extra-semi": 2,
|
||||
"no-fallthrough": 2,
|
||||
"no-floating-decimal": 2,
|
||||
"no-func-assign": 2,
|
||||
"no-global-assign": 2,
|
||||
"no-implicit-coercion": 2,
|
||||
"no-implicit-globals": 0,
|
||||
"no-implied-eval": 2,
|
||||
"no-inner-declarations": 0,
|
||||
"no-invalid-regexp": 2,
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-iterator": 2,
|
||||
"no-labels": 2,
|
||||
"no-lone-blocks": 2,
|
||||
"no-lonely-if": 2,
|
||||
"no-loop-func": 2,
|
||||
"no-magic-numbers": 0,
|
||||
"no-mixed-operators": [
|
||||
2,
|
||||
{
|
||||
"allowSamePrecedence": false
|
||||
}
|
||||
],
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-multi-assign": 2,
|
||||
"no-multi-spaces": [
|
||||
2,
|
||||
{
|
||||
"exceptions": {
|
||||
"Property": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"no-multi-str": 0,
|
||||
"no-multiple-empty-lines": [
|
||||
2,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"no-native-reassign": 2,
|
||||
"no-negated-condition": 2,
|
||||
"no-nested-ternary": 2,
|
||||
"no-new": 2,
|
||||
"no-new-func": 2,
|
||||
"no-new-object": 2,
|
||||
"no-new-symbol": 2,
|
||||
"no-new-wrappers": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-param-reassign": 2,
|
||||
"no-process-env": 2,
|
||||
"no-process-exit": 2,
|
||||
"no-proto": 2,
|
||||
"no-prototype-builtins": 1,
|
||||
"no-redeclare": 2,
|
||||
"no-return-assign": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"no-return-await": 2,
|
||||
"no-script-url": 2,
|
||||
"no-self-assign": [
|
||||
2,
|
||||
{
|
||||
"props": true
|
||||
}
|
||||
],
|
||||
"no-self-compare": 2,
|
||||
"no-sequences": 2,
|
||||
"no-shadow": [
|
||||
2,
|
||||
{
|
||||
"hoist": "functions"
|
||||
}
|
||||
],
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-spaced-func": 2,
|
||||
"no-tabs": 0,
|
||||
"no-template-curly-in-string": 2,
|
||||
"no-ternary": 0,
|
||||
"no-this-before-super": 2,
|
||||
"no-throw-literal": 2,
|
||||
"no-trailing-spaces": [
|
||||
2,
|
||||
{
|
||||
"skipBlankLines": false
|
||||
}
|
||||
],
|
||||
"no-undef-init": 2,
|
||||
"no-undefined": 2,
|
||||
"no-underscore-dangle": 2,
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unmodified-loop-condition": 2,
|
||||
"no-unneeded-ternary": [
|
||||
2,
|
||||
{
|
||||
"defaultAssignment": false
|
||||
}
|
||||
],
|
||||
"no-unreachable": 2,
|
||||
"no-unsafe-finally": 2,
|
||||
"no-unsafe-negation": 2,
|
||||
"no-unused-expressions": 2,
|
||||
"no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "after-used"
|
||||
}
|
||||
],
|
||||
"no-use-before-define": [
|
||||
2,
|
||||
{
|
||||
"classes": false,
|
||||
"functions": false,
|
||||
"variables": false
|
||||
}
|
||||
],
|
||||
"no-useless-computed-key": 2,
|
||||
"no-useless-concat": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-useless-escape": 2,
|
||||
"no-useless-rename": 2,
|
||||
"no-useless-return": 2,
|
||||
"no-var": 0,
|
||||
"no-void": 2,
|
||||
"no-warning-comments": 1,
|
||||
"no-whitespace-before-property": 2,
|
||||
"no-with": 2,
|
||||
"object-curly-newline": 0,
|
||||
"object-curly-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"object-property-newline": [
|
||||
2,
|
||||
{
|
||||
"allowMultiplePropertiesPerLine": true
|
||||
}
|
||||
],
|
||||
"object-shorthand": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"one-var": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"one-var-declaration-per-line": 0,
|
||||
"operator-assignment": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"operator-linebreak": [
|
||||
2,
|
||||
"after"
|
||||
],
|
||||
"padded-blocks": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"prefer-arrow-callback": 2,
|
||||
"prefer-const": 2,
|
||||
"prefer-destructuring": 0,
|
||||
"prefer-numeric-literals": 2,
|
||||
"prefer-promise-reject-errors": 2,
|
||||
"prefer-rest-params": 2,
|
||||
"prefer-spread": 2,
|
||||
"prefer-template": 0,
|
||||
"quote-props": [
|
||||
2,
|
||||
"as-needed"
|
||||
],
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
"avoid-escape"
|
||||
],
|
||||
"radix": 2,
|
||||
"require-yield": 2,
|
||||
"rest-spread-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"semi": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"semi-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"sort-imports": 0,
|
||||
"sort-keys": 0,
|
||||
"space-before-blocks": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"space-before-function-paren": [
|
||||
2,
|
||||
{
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}
|
||||
],
|
||||
"space-in-parens": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"space-infix-ops": 2,
|
||||
"space-unary-ops": [
|
||||
2,
|
||||
{
|
||||
"words": true,
|
||||
"nonwords": false
|
||||
}
|
||||
],
|
||||
"symbol-description": 2,
|
||||
"template-curly-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"valid-typeof": [
|
||||
2,
|
||||
{
|
||||
"requireStringLiterals": false
|
||||
}
|
||||
],
|
||||
"vars-on-top": 0,
|
||||
"wrap-iife": [
|
||||
2,
|
||||
"outside"
|
||||
],
|
||||
"wrap-regex": 2,
|
||||
"yoda": [
|
||||
2,
|
||||
"never",
|
||||
{
|
||||
"exceptRange": false,
|
||||
"onlyEquality": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/array-type": [2, {"default": "array-simple"}],
|
||||
"@typescript-eslint/member-delimiter-style": 2,
|
||||
"@typescript-eslint/type-annotation-spacing": 2
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.test.js", "*.test.jsx", "*.test.ts", "*.test.tsx", "tests/**"],
|
||||
"globals": {
|
||||
"after": true,
|
||||
"afterAll": true,
|
||||
"afterEach": true,
|
||||
"before": true,
|
||||
"beforeAll": true,
|
||||
"beforeEach": true,
|
||||
"describe": true,
|
||||
"expect": true,
|
||||
"it": true,
|
||||
"jest": true,
|
||||
"test": true
|
||||
},
|
||||
"rules": {
|
||||
"no-empty-function": 0,
|
||||
"no-console": 0,
|
||||
"max-nested-callbacks": 0,
|
||||
"no-undefined": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2191,7 +2191,7 @@ PODS:
|
|||
- SDWebImage/Core (5.19.7)
|
||||
- SDWebImageSVGCoder (1.7.0):
|
||||
- SDWebImage/Core (~> 5.6)
|
||||
- secure-pdf-viewer (0.0.0):
|
||||
- secure-pdf-viewer (1.0.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
|
|
@ -2717,7 +2717,7 @@ SPEC CHECKSUMS:
|
|||
RNVectorIcons: 4330d8f8f8f4184f436e0c08ae9950431ffe466e
|
||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
secure-pdf-viewer: 63aa9df5ba9d7df506614f5228469f38c98f1c50
|
||||
secure-pdf-viewer: 6c4e21017c1de3c53164dbb2cebd96d925b74564
|
||||
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
||||
simdjson: 7bb9e33d87737cec966e7b427773c67baa4458fe
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class FoldableObserver(activity: Activity) {
|
|||
if (disposable?.isDisposed == true) {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
disposable = observable.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { layoutInfo ->
|
||||
setIsDeviceFolded(layoutInfo)
|
||||
|
|
@ -82,7 +83,7 @@ class FoldableObserver(activity: Activity) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleWindowLayoutInfo() {
|
||||
fun handleWindowLayoutInfo() {
|
||||
val bounds = getWindowSize()
|
||||
|
||||
if (bounds?.width() != windowBounds?.width()) {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import React
|
|||
@objc public class RNUtilsWrapper: NSObject {
|
||||
@objc public weak var delegate: RNUtilsDelegate? = nil
|
||||
@objc private var hasRegisteredLoad = false
|
||||
private var debounceWorkItem: DispatchWorkItem?
|
||||
|
||||
deinit {
|
||||
DispatchQueue.main.sync {
|
||||
guard let w = UIApplication.shared.delegate?.window, let window = w else { return }
|
||||
window.removeObserver(self, forKeyPath: "frame")
|
||||
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,23 +44,56 @@ import React
|
|||
return windowWidth >= screenWidth * (2.0 / 3.0)
|
||||
}
|
||||
|
||||
private func sendDimensionsChangedDebounced() {
|
||||
debounceWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let (screen, bounds) = getWindowSize()
|
||||
guard let screen = screen, let bounds = bounds else {return}
|
||||
|
||||
// Determine which dimensions to use
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
// On iPad, use window bounds for Split View/Stage Manager
|
||||
width = bounds.width
|
||||
height = bounds.height
|
||||
} else {
|
||||
// On iPhone, use screen bounds for reliability
|
||||
width = screen.width
|
||||
height = screen.height
|
||||
}
|
||||
|
||||
isSplitView(screen: screen, bounds: bounds)
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return
|
||||
}
|
||||
delegate?.sendEvent(name: "DimensionsChanged", result: [
|
||||
"width": width,
|
||||
"height": height
|
||||
])
|
||||
}
|
||||
debounceWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
|
||||
}
|
||||
|
||||
@objc public func captureEvents() {
|
||||
DispatchQueue.main.async {
|
||||
guard let w = UIApplication.shared.delegate?.window, let window = w else { return }
|
||||
window.addObserver(self, forKeyPath: "frame", options: .new, context: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(self.orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func orientationChanged() {
|
||||
sendDimensionsChangedDebounced()
|
||||
}
|
||||
|
||||
override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if keyPath == "frame" {
|
||||
let (screen, bounds) = getWindowSize()
|
||||
guard let screen = screen, let bounds = bounds else {return}
|
||||
isSplitView(screen: screen, bounds: bounds)
|
||||
|
||||
delegate?.sendEvent(name: "DimensionsChanged", result: [
|
||||
"width": bounds.width,
|
||||
"height": bounds.height
|
||||
])
|
||||
sendDimensionsChangedDebounced()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +300,7 @@ import React
|
|||
UIDevice.current.setValue(UIInterfaceOrientation.unknown.rawValue, forKey: "orientation")
|
||||
UINavigationController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
self.sendDimensionsChangedDebounced()
|
||||
}
|
||||
|
||||
@objc public func lockOrientation() {
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -6,7 +6,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "mattermost-mobile",
|
||||
"version": "2.29.0",
|
||||
"version": "2.31.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -190,7 +190,7 @@
|
|||
"license": "Apache 2.0"
|
||||
},
|
||||
"libraries/@mattermost/secure-pdf-viewer": {
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
|
|
|
|||
22
patches/react-native-video+6.12.0.patch
Normal file
22
patches/react-native-video+6.12.0.patch
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
index ee24982..8f952db 100644
|
||||
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
@@ -217,6 +217,17 @@ class ExoPlayerView(private val context: Context) :
|
||||
}
|
||||
}
|
||||
|
||||
+ override fun onDetachedFromWindow() {
|
||||
+ super.onDetachedFromWindow()
|
||||
+ player?.let {
|
||||
+ it.playWhenReady = false
|
||||
+ it.stop()
|
||||
+ it.clearVideoSurface()
|
||||
+ it.release()
|
||||
+ }
|
||||
+ player = null
|
||||
+ }
|
||||
+
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
post(measureAndLayout)
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import type {Caption} from '@mattermost/calls/lib/types';
|
||||
import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler';
|
||||
import type {PanGesture, TapGesture} from 'react-native-gesture-handler';
|
||||
import type {SharedValue} from 'react-native-reanimated';
|
||||
|
||||
export type GalleryManagerSharedValues = {
|
||||
|
|
@ -16,50 +14,9 @@ export type GalleryManagerSharedValues = {
|
|||
activeIndex: SharedValue<number>;
|
||||
targetWidth: SharedValue<number>;
|
||||
targetHeight: SharedValue<number>;
|
||||
scale: SharedValue<number>;
|
||||
}
|
||||
|
||||
export type Context = { [key: string]: any };
|
||||
|
||||
export type Handler<T, TContext extends Context> = (
|
||||
event: T,
|
||||
context: TContext,
|
||||
) => void;
|
||||
|
||||
export type onEndHandler<T, TContext extends Context> = (
|
||||
event: T,
|
||||
context: TContext,
|
||||
isCanceled: boolean,
|
||||
) => void;
|
||||
|
||||
export type ReturnHandler<T, TContext extends Context, R> = (
|
||||
event: T,
|
||||
context: TContext,
|
||||
) => R;
|
||||
|
||||
export interface GestureHandlers<T, TContext extends Context> {
|
||||
onInit?: Handler<T, TContext>;
|
||||
onEvent?: Handler<T, TContext>;
|
||||
shouldHandleEvent?: ReturnHandler<T, TContext, boolean>;
|
||||
shouldCancel?: ReturnHandler<T, TContext, boolean>;
|
||||
onGesture?: Handler<T, TContext>;
|
||||
beforeEach?: Handler<T, TContext>;
|
||||
afterEach?: Handler<T, TContext>;
|
||||
onStart?: Handler<T, TContext>;
|
||||
onActive?: Handler<T, TContext>;
|
||||
onEnd?: onEndHandler<T, TContext>;
|
||||
onFail?: Handler<T, TContext>;
|
||||
onCancel?: Handler<T, TContext>;
|
||||
onFinish?: (
|
||||
event: T,
|
||||
context: TContext,
|
||||
isCanceledOrFailed: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type OnGestureEvent<T extends GestureHandlerGestureEvent> = (
|
||||
event: T,
|
||||
) => void;
|
||||
|
||||
export type GalleryFileType = 'image' | 'video' | 'file' | 'avatar';
|
||||
|
||||
export type GalleryItemType = {
|
||||
|
|
@ -80,3 +37,16 @@ export type GalleryItemType = {
|
|||
};
|
||||
|
||||
export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';
|
||||
|
||||
export type GalleryPagerItem = {
|
||||
index: number;
|
||||
onPageStateChange: (value: boolean) => void;
|
||||
item: GalleryItemType;
|
||||
width: number;
|
||||
height: number;
|
||||
isPageActive?: SharedValue<boolean>;
|
||||
isPagerInProgress: SharedValue<boolean>;
|
||||
pagerPanGesture: PanGesture;
|
||||
pagerTapGesture: TapGesture;
|
||||
lightboxPanGesture: PanGesture;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue