mattermost-mobile-upstream/app/screens/gallery/gallery.tsx
Elias Nahum b6fc85385e
Migrate from RNN to Expo router (#9402)
* Expo-Router partial migration

* Full migration to ExpoRouter

* i18n

* add tests

* Merge branch 'main' into expo-router

* align package-lock.json

* i18n

* feedback review

* feedback review

* android 16kb patch

* Refactored emoji picker keyboard using a state machine

* set android launch_screen in drawables instead of layout

* no conditional hooks

* remove duplicate call in test

* address feedback

* fix tests and address more feedback

* small corrections

* small corrections

* small corrections

* fix draft test

* fix draft test 2

* remove navigateToScreen in draff test

* fix nit in draft test

* remove doc file

* Feedback review

* fix snapshots

* improve test coverage

* fix i18n

* Upgrade t o RN 0.83, other dependencies and new architecture (#9737)

* library hardware-keyboard android new arch support fix

* library rnshare android new arch support fix

* library rnutils android new arch support fix

* library secure-pdf-viewer add new arch support

* Enable new arch on iOS and Android

* update emm and network client with new-arch

* update intune to use new-arch

* performance improvements

* fix tests

* fixes post input on new-arch

* fix android emoji picker

* remove doc

* run tests in parallel

* upgrade to React Native 0.83.9, Expo 55, React 19, and New Architecture

Bumps core dependencies to their latest versions:
- React Native 0.77.3 → 0.83.9 (New Architecture enabled)
- React 18.3.1 → 19.2.5
- Expo SDK 52 → 55
- TypeScript 5.8.3 → 6.0.3
- Node.js 22.16.0 → 24.15.0
- react-native-reanimated 3.17.3 → 4.3.0
- Various other dependency upgrades

Migrates iOS AppDelegate from Objective-C to Swift, updates Android
build tooling and Gradle wrapper, refreshes all patches and font
assets, and adapts app code, tests, and CI workflows throughout for
API changes introduced by these upgrades.

* remove unecessary dismissKeyboard in notifications

* feedback review

* update intune to use new-arch

* update @mattermost deps

* missing i18n string

* additional fixes

* update fastlane
2026-05-13 17:44:55 +08:00

232 lines
7.2 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {BackHandler} from 'react-native';
import {useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets';
import {buildFileUrl} from '@actions/remote/file';
import {ExpoImageAnimated} from '@components/expo_image';
import {useGallery} from '@context/gallery';
import {useServerUrl} from '@context/server';
import {isGif} from '@utils/file';
import {galleryItemToFileInfo, measureItem} from '@utils/gallery';
import LightboxSwipeout, {type LightboxSwipeoutRef, type RenderItemInfo} from './lightbox_swipeout';
import Backdrop, {type BackdropProps} from './lightbox_swipeout/backdrop';
import DocumentRenderer from './renderers/document';
import VideoRenderer from './renderers/video';
import GalleryViewer from './viewer';
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
interface GalleryProps {
headerAndFooterHidden: SharedValue<boolean>;
galleryIdentifier: string;
initialIndex: number;
items: GalleryItemType[];
onIndexChange?: (index: number) => void;
onHide: () => void;
targetDimensions: { width: number; height: number };
hideHeaderAndFooter: (hide: boolean) => void;
}
export interface GalleryRef {
close: () => void;
}
const Gallery = forwardRef<GalleryRef, GalleryProps>(({
headerAndFooterHidden,
galleryIdentifier,
initialIndex,
items,
onHide,
targetDimensions,
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();
};
const onLocalIndex = useCallback((index: number) => {
setLocalIndex(index);
onIndexChange?.(index);
}, [onIndexChange]);
useEffect(() => {
scheduleOnUI(() => {
'worklet';
const tw = targetDimensions.width;
sharedValues.targetWidth.value = tw;
const scaleFactor = item.width / targetDimensions.width;
const th = item.height / scaleFactor;
sharedValues.targetHeight.value = th;
});
// sharedValues do not trigger re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item, targetDimensions.width]);
useEffect(() => {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
lightboxRef.current?.closeLightbox();
return true;
});
return () => listener.remove();
}, []);
useImperativeHandle(ref, () => ({
close,
}));
useAnimatedReaction(
() => {
return sharedValues.activeIndex.value;
},
(index) => {
const galleryItems = refsByIndexSV.value;
if (index > -1 && galleryItems[index] && items[index].type !== 'file') {
measureItem(galleryItems[index].ref, sharedValues);
}
},
);
const onIndexChangeWorklet = useCallback((nextIndex: number) => {
'worklet';
scheduleOnRN(onLocalIndex, nextIndex);
sharedValues.activeIndex.value = nextIndex;
}, [onLocalIndex, sharedValues]);
const renderBackdropComponent = useCallback(
({animatedStyles, translateY}: BackdropProps) => {
return (
<Backdrop
animatedStyles={animatedStyles}
translateY={translateY}
/>
);
},
[],
);
function onSwipeActive(translateY: number) {
'worklet';
if (Math.abs(translateY) > 8) {
hideHeaderAndFooter(true);
}
}
function onSwipeFailure() {
'worklet';
hideHeaderAndFooter(false);
}
const hideLightboxItem = useCallback(() => {
'worklet';
sharedValues.width.value = 0;
sharedValues.height.value = 0;
sharedValues.opacity.value = 1;
sharedValues.activeIndex.value = -1;
sharedValues.x.value = 0;
sharedValues.y.value = 0;
scheduleOnRN(onHide);
}, [onHide, sharedValues]);
const onRenderItem = useCallback((info: RenderItemInfo) => {
const currentItem = items[localIndex];
if (currentItem.type === 'video' && currentItem.posterUri) {
return (
<ExpoImageAnimated
id={currentItem.cacheKey}
source={{uri: currentItem.posterUri}}
style={info.itemStyles}
placeholderContentFit='cover'
/>
);
}
return null;
}, [items, localIndex]);
const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => {
switch (props.item.type) {
case 'video':
return (
<VideoRenderer
{...props}
index={idx}
initialIndex={initialIndex}
hideHeaderAndFooter={hideHeaderAndFooter}
/>
);
case 'file':
return (
<DocumentRenderer
item={props.item}
hideHeaderAndFooter={hideHeaderAndFooter}
width={props.width}
height={props.height}
/>
);
default:
return null;
}
}, [hideHeaderAndFooter, initialIndex]);
const source = useMemo(() => {
if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) {
return buildFileUrl(serverUrl, fileInfo.id, fileInfo.update_at);
}
return fileInfo.localPath || fileInfo.uri || item.uri;
}, [fileInfo, item.uri, serverUrl]);
return (
<LightboxSwipeout
headerAndFooterHidden={headerAndFooterHidden}
ref={lightboxRef}
target={item}
onAnimationFinished={hideLightboxItem}
sharedValues={sharedValues}
source={source}
onSwipeActive={onSwipeActive}
onSwipeFailure={onSwipeFailure}
renderBackdropComponent={renderBackdropComponent}
targetDimensions={targetDimensions}
renderItem={onRenderItem}
>
<GalleryViewer
items={items}
onIndexChange={onIndexChangeWorklet}
hideHeaderAndFooter={hideHeaderAndFooter}
height={targetDimensions.height}
width={targetDimensions.width}
initialIndex={initialIndex}
numToRender={1}
renderPage={onRenderPage}
/>
</LightboxSwipeout>
);
});
Gallery.displayName = 'Gallery';
export default Gallery;