Refactor gallery gestures (#8752)

This commit is contained in:
Elias Nahum 2025-07-22 14:00:57 +08:00 committed by GitHub
parent 19a258c755
commit 5eb3a1ab41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 4641 additions and 2291 deletions

View file

@ -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) {

View file

@ -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}
/>
);

View 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>
);
}

View file

@ -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(() => {

View file

@ -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;

View file

@ -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>();

View file

@ -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();

View file

@ -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,
};
}

View file

@ -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[]>([]);

View 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,
};

View 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),
};

View file

@ -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

View file

@ -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;

View file

@ -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>
}

View file

@ -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>
);
};

View file

@ -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>
);
});

View file

@ -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'

View file

@ -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;

View file

@ -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);

View file

@ -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>
);
};

View file

@ -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',
),
};
}, []);

View 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;
};

View file

@ -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>
);
});

View 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>
);
}

View 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,
};
};

View 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();
}
});
}

View 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);
}
});
}

View 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();
}
});
}

View file

@ -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>
);
};

View file

@ -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,
)}

View 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>
);
}

View file

@ -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'

View file

@ -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';

View 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,
};
};

View file

@ -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);
}
});
}

View file

@ -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));
});
}

View file

@ -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();
});
}

View file

@ -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();
});
}

View 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;

View 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);

View file

@ -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}>

View 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;
}

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View 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;

View file

@ -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;

View file

@ -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}
/>
);
};

View file

@ -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;

View file

@ -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;

View file

@ -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});
});
});
});

View file

@ -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);

View file

@ -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 {

View file

@ -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', () => {

View file

@ -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,

View file

@ -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"
}

View file

@ -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",

View 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
}
}
]
}

View file

@ -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

View file

@ -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()) {

View file

@ -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
View file

@ -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": {

View 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)

View file

@ -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;
};