diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js
index 4b5109344..f1bd20a27 100644
--- a/native/data/sqlite-data-handler.js
+++ b/native/data/sqlite-data-handler.js
@@ -1,196 +1,204 @@
// @flow
import * as React from 'react';
import { Alert } from 'react-native';
import { useDispatch } from 'react-redux';
import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js';
+import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import {
logInActionSources,
type LogInActionSource,
} from 'lib/types/account-types.js';
import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils.js';
import { getMessageForException } from 'lib/utils/errors.js';
import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils.js';
+import { filesystemMediaCache } from '../media/media-cache.js';
import { commCoreModule } from '../native-modules.js';
import { setStoreLoadedActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { StaffContext } from '../staff/staff-context.js';
import { isTaskCancelledError } from '../utils/error-handling.js';
import { useStaffCanSee } from '../utils/staff-utils.js';
function SQLiteDataHandler(): React.Node {
const storeLoaded = useSelector(state => state.storeLoaded);
const dispatch = useDispatch();
const rehydrateConcluded = useSelector(
state => !!(state._persist && state._persist.rehydrated),
);
const cookie = useSelector(state => state.cookie);
const urlPrefix = useSelector(state => state.urlPrefix);
const staffCanSee = useStaffCanSee();
const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext);
const loggedIn = useSelector(isLoggedIn);
const currentLoggedInUserID = useSelector(state =>
state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id,
);
+ const mediaCacheContext = React.useContext(MediaCacheContext);
const callFetchNewCookieFromNativeCredentials = React.useCallback(
async (source: LogInActionSource) => {
try {
await fetchNewCookieFromNativeCredentials(
dispatch,
cookie,
urlPrefix,
source,
);
dispatch({ type: setStoreLoadedActionType });
} catch (fetchCookieException) {
if (staffCanSee) {
Alert.alert(
`Error fetching new cookie from native credentials: ${
getMessageForException(fetchCookieException) ??
'{no exception message}'
}. Please kill the app.`,
);
} else {
commCoreModule.terminate();
}
}
},
[cookie, dispatch, staffCanSee, urlPrefix],
);
const callClearSensitiveData = React.useCallback(
async (triggeredBy: string) => {
if (staffCanSee || staffUserHasBeenLoggedIn) {
Alert.alert('Starting SQLite database deletion process');
}
await commCoreModule.clearSensitiveData();
+ await filesystemMediaCache.clearCache();
if (staffCanSee || staffUserHasBeenLoggedIn) {
Alert.alert(
'SQLite database successfully deleted',
`SQLite database deletion was triggered by ${triggeredBy}`,
);
}
},
[staffCanSee, staffUserHasBeenLoggedIn],
);
const handleSensitiveData = React.useCallback(async () => {
try {
const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID();
if (
databaseCurrentUserInfoID &&
databaseCurrentUserInfoID !== currentLoggedInUserID
) {
await callClearSensitiveData('change in logged-in user credentials');
}
if (currentLoggedInUserID) {
await commCoreModule.setCurrentUserID(currentLoggedInUserID);
}
const databaseDeviceID = await commCoreModule.getDeviceID();
if (!databaseDeviceID) {
await commCoreModule.setDeviceID('MOBILE');
}
} catch (e) {
if (isTaskCancelledError(e)) {
return;
}
if (__DEV__) {
throw e;
} else {
console.log(e);
commCoreModule.terminate();
}
}
}, [callClearSensitiveData, currentLoggedInUserID]);
React.useEffect(() => {
if (!rehydrateConcluded) {
return;
}
const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion();
if (databaseNeedsDeletion) {
(async () => {
try {
await callClearSensitiveData('detecting corrupted database');
} catch (e) {
if (__DEV__) {
throw e;
} else {
console.log(e);
commCoreModule.terminate();
}
}
await callFetchNewCookieFromNativeCredentials(
logInActionSources.corruptedDatabaseDeletion,
);
})();
return;
}
const sensitiveDataHandled = handleSensitiveData();
if (storeLoaded) {
return;
}
if (!loggedIn) {
dispatch({ type: setStoreLoadedActionType });
return;
}
(async () => {
- await sensitiveDataHandled;
+ await Promise.all([
+ sensitiveDataHandled,
+ mediaCacheContext?.evictCache(),
+ ]);
try {
const { threads, messages, drafts } =
await commCoreModule.getClientDBStore();
const threadInfosFromDB =
convertClientDBThreadInfosToRawThreadInfos(threads);
dispatch({
type: setClientDBStoreActionType,
payload: {
drafts,
messages,
threadStore: { threadInfos: threadInfosFromDB },
currentUserID: currentLoggedInUserID,
},
});
} catch (setStoreException) {
if (isTaskCancelledError(setStoreException)) {
dispatch({ type: setStoreLoadedActionType });
return;
}
if (staffCanSee) {
Alert.alert(
`Error setting threadStore or messageStore: ${
getMessageForException(setStoreException) ??
'{no exception message}'
}`,
);
}
await callFetchNewCookieFromNativeCredentials(
logInActionSources.sqliteLoadFailure,
);
}
})();
}, [
currentLoggedInUserID,
handleSensitiveData,
loggedIn,
cookie,
dispatch,
rehydrateConcluded,
staffCanSee,
storeLoaded,
urlPrefix,
staffUserHasBeenLoggedIn,
callFetchNewCookieFromNativeCredentials,
callClearSensitiveData,
+ mediaCacheContext,
]);
return null;
}
export { SQLiteDataHandler };
diff --git a/native/media/encrypted-image.react.js b/native/media/encrypted-image.react.js
index cb1d9b042..e71e51402 100644
--- a/native/media/encrypted-image.react.js
+++ b/native/media/encrypted-image.react.js
@@ -1,77 +1,87 @@
// @flow
import * as React from 'react';
+import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js';
+
import { decryptMedia } from './encryption-utils.js';
import LoadableImage from './loadable-image.react.js';
import { useSelector } from '../redux/redux-utils.js';
import type { ImageStyle } from '../types/styles.js';
type BaseProps = {
+holder: string,
+encryptionKey: string,
+onLoad: (uri: string) => void,
+spinnerColor: string,
+style: ImageStyle,
+invisibleLoad: boolean,
};
type Props = {
...BaseProps,
};
function EncryptedImage(props: Props): React.Node {
const { holder, encryptionKey, onLoad: onLoadProp } = props;
+ const mediaCache = React.useContext(MediaCacheContext);
const [source, setSource] = React.useState(null);
const connectionStatus = useSelector(state => state.connection.status);
const prevConnectionStatusRef = React.useRef(connectionStatus);
const [attempt, setAttempt] = React.useState(0);
if (prevConnectionStatusRef.current !== connectionStatus) {
if (!source && connectionStatus === 'connected') {
setAttempt(attempt + 1);
}
prevConnectionStatusRef.current = connectionStatus;
}
React.useEffect(() => {
let isMounted = true;
setSource(null);
const loadDecrypted = async () => {
+ const cached = await mediaCache?.get(holder);
+ if (cached && isMounted) {
+ setSource({ uri: cached });
+ return;
+ }
+
const { result } = await decryptMedia(holder, encryptionKey, {
destination: 'data_uri',
});
// TODO: decide what to do if decryption fails
if (result.success && isMounted) {
+ mediaCache?.set(holder, result.uri);
setSource({ uri: result.uri });
}
};
loadDecrypted();
return () => {
isMounted = false;
};
- }, [attempt, holder, encryptionKey]);
+ }, [attempt, holder, encryptionKey, mediaCache]);
const onLoad = React.useCallback(() => {
onLoadProp && onLoadProp(holder);
}, [holder, onLoadProp]);
const { style, spinnerColor, invisibleLoad } = props;
return (
);
}
export default EncryptedImage;
diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js
index c93e67d0d..b465a86cc 100644
--- a/native/media/video-playback-modal.react.js
+++ b/native/media/video-playback-modal.react.js
@@ -1,799 +1,817 @@
// @flow
import Icon from '@expo/vector-icons/MaterialCommunityIcons.js';
import invariant from 'invariant';
import * as React from 'react';
import { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import filesystem from 'react-native-fs';
import { TapGestureHandler } from 'react-native-gesture-handler';
import * as Progress from 'react-native-progress';
import Animated from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import Video from 'react-native-video';
+import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js';
import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils.js';
import type { MediaInfo } from 'lib/types/media-types.js';
import { decryptMedia } from './encryption-utils.js';
import { formatDuration } from './video-utils.js';
import ConnectedStatusBar from '../connected-status-bar.react.js';
import type { AppNavigationProp } from '../navigation/app-navigator.react.js';
import { OverlayContext } from '../navigation/overlay-context.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js';
import { useStyles } from '../themes/colors.js';
import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js';
import type {
VerticalBounds,
LayoutCoordinates,
} from '../types/layout-types.js';
import type { NativeMethods } from '../types/react-native.js';
import { gestureJustEnded, animateTowards } from '../utils/animation-utils.js';
type TouchableOpacityInstance = React.AbstractComponent<
React.ElementConfig,
NativeMethods,
>;
/* eslint-disable import/no-named-as-default-member */
const {
Extrapolate,
and,
or,
block,
cond,
eq,
ceil,
call,
set,
add,
sub,
multiply,
divide,
not,
max,
min,
lessThan,
greaterThan,
abs,
interpolateNode,
useValue,
event,
} = Animated;
export type VideoPlaybackModalParams = {
+presentedFrom: string,
+mediaInfo: MediaInfo,
+initialCoordinates: LayoutCoordinates,
+verticalBounds: VerticalBounds,
+item: ChatMultimediaMessageInfoItem,
};
type Props = {
+navigation: AppNavigationProp<'VideoPlaybackModal'>,
+route: NavigationRoute<'VideoPlaybackModal'>,
};
function VideoPlaybackModal(props: Props): React.Node {
const { mediaInfo } = props.route.params;
- const { uri, holder, encryptionKey } = mediaInfo;
+ const { uri: videoUri, holder, encryptionKey } = mediaInfo;
const [videoSource, setVideoSource] = React.useState(
- uri ? { uri } : undefined,
+ videoUri ? { uri: videoUri } : undefined,
);
+ const mediaCache = React.useContext(MediaCacheContext);
+
React.useEffect(() => {
// skip for unencrypted videos
if (!holder || !encryptionKey) {
return;
}
let isMounted = true;
let uriToDispose;
setVideoSource(undefined);
const loadDecrypted = async () => {
+ const cached = await mediaCache?.get(holder);
+ if (cached && isMounted) {
+ setVideoSource({ uri: cached });
+ return;
+ }
+
const { result } = await decryptMedia(holder, encryptionKey, {
destination: 'file',
});
- if (result.success && isMounted) {
- uriToDispose = result.uri;
- setVideoSource({ uri: result.uri });
+ if (result.success) {
+ const { uri } = result;
+ const cacheSetPromise = mediaCache?.set(holder, uri);
+ if (isMounted) {
+ uriToDispose = uri;
+ setVideoSource({ uri });
+ } else {
+ // dispose of the temporary file immediately when unmounted
+ // but wait for the cache to be set
+ await cacheSetPromise;
+ filesystem.unlink(uri);
+ }
}
};
loadDecrypted();
return () => {
isMounted = false;
if (uriToDispose) {
// remove the temporary file created by decryptMedia
filesystem.unlink(uriToDispose);
}
};
- }, [holder, encryptionKey]);
+ }, [holder, encryptionKey, mediaCache]);
const closeButtonX = useValue(-1);
const closeButtonY = useValue(-1);
const closeButtonWidth = useValue(-1);
const closeButtonHeight = useValue(-1);
const closeButtonRef =
React.useRef>();
const closeButton = closeButtonRef.current;
const onCloseButtonLayoutCalledRef = React.useRef(false);
const onCloseButtonLayout = React.useCallback(() => {
onCloseButtonLayoutCalledRef.current = true;
}, []);
const onCloseButtonLayoutCalled = onCloseButtonLayoutCalledRef.current;
React.useEffect(() => {
if (!closeButton || !onCloseButtonLayoutCalled) {
return;
}
closeButton.measure((x, y, width, height, pageX, pageY) => {
closeButtonX.setValue(pageX);
closeButtonY.setValue(pageY);
closeButtonWidth.setValue(width);
closeButtonHeight.setValue(height);
});
}, [
closeButton,
onCloseButtonLayoutCalled,
closeButtonX,
closeButtonY,
closeButtonWidth,
closeButtonHeight,
]);
const footerX = useValue(-1);
const footerY = useValue(-1);
const footerWidth = useValue(-1);
const footerHeight = useValue(-1);
const footerRef = React.useRef();
const footer = footerRef.current;
const onFooterLayoutCalledRef = React.useRef(false);
const onFooterLayout = React.useCallback(() => {
onFooterLayoutCalledRef.current = true;
}, []);
const onFooterLayoutCalled = onFooterLayoutCalledRef.current;
React.useEffect(() => {
if (!footer || !onFooterLayoutCalled) {
return;
}
footer.measure((x, y, width, height, pageX, pageY) => {
footerX.setValue(pageX);
footerY.setValue(pageY);
footerWidth.setValue(width);
footerHeight.setValue(height);
});
}, [
footer,
onFooterLayoutCalled,
footerX,
footerY,
footerWidth,
footerHeight,
]);
const controlsShowing = useValue(1);
const outsideButtons = React.useCallback(
(x, y) =>
and(
or(
eq(controlsShowing, 0),
lessThan(x, closeButtonX),
greaterThan(x, add(closeButtonX, closeButtonWidth)),
lessThan(y, closeButtonY),
greaterThan(y, add(closeButtonY, closeButtonHeight)),
),
or(
eq(controlsShowing, 0),
lessThan(x, footerX),
greaterThan(x, add(footerX, footerWidth)),
lessThan(y, footerY),
greaterThan(y, add(footerY, footerHeight)),
),
),
[
controlsShowing,
closeButtonX,
closeButtonY,
closeButtonWidth,
closeButtonHeight,
footerX,
footerY,
footerWidth,
footerHeight,
],
);
/* ===== START FADE CONTROL ANIMATION ===== */
const singleTapState = useValue(-1);
const singleTapX = useValue(0);
const singleTapY = useValue(0);
const singleTapEvent = React.useMemo(
() =>
event([
{
nativeEvent: {
state: singleTapState,
x: singleTapX,
y: singleTapY,
},
},
]),
[singleTapState, singleTapX, singleTapY],
);
const lastTapX = useValue(-1);
const lastTapY = useValue(-1);
const activeControlsOpacity = React.useMemo(
() =>
animateTowards(
block([
cond(
and(
gestureJustEnded(singleTapState),
outsideButtons(lastTapX, lastTapY),
),
set(controlsShowing, not(controlsShowing)),
),
set(lastTapX, singleTapX),
set(lastTapY, singleTapY),
controlsShowing,
]),
150,
),
[
singleTapState,
controlsShowing,
outsideButtons,
lastTapX,
lastTapY,
singleTapX,
singleTapY,
],
);
const [controlsEnabled, setControlsEnabled] = React.useState(true);
const enableControls = React.useCallback(() => setControlsEnabled(true), []);
const disableControls = React.useCallback(
() => setControlsEnabled(false),
[],
);
const previousOpacityCeiling = useValue(-1);
const opacityCeiling = React.useMemo(
() => ceil(activeControlsOpacity),
[activeControlsOpacity],
);
const opacityJustChanged = React.useMemo(
() =>
cond(eq(previousOpacityCeiling, opacityCeiling), 0, [
set(previousOpacityCeiling, opacityCeiling),
1,
]),
[previousOpacityCeiling, opacityCeiling],
);
const toggleControls = React.useMemo(
() => [
cond(
and(eq(opacityJustChanged, 1), eq(opacityCeiling, 0)),
call([], disableControls),
),
cond(
and(eq(opacityJustChanged, 1), eq(opacityCeiling, 1)),
call([], enableControls),
),
],
[opacityJustChanged, opacityCeiling, disableControls, enableControls],
);
/* ===== END FADE CONTROL ANIMATION ===== */
const mediaDimensions = mediaInfo.dimensions;
const screenDimensions = useSelector(derivedDimensionsInfoSelector);
const frame = React.useMemo(
() => ({
width: screenDimensions.width,
height: screenDimensions.safeAreaHeight,
}),
[screenDimensions],
);
const mediaDisplayDimensions = React.useMemo(() => {
let { height: maxHeight, width: maxWidth } = frame;
if (maxHeight > maxWidth) {
maxHeight -= 100;
} else {
maxWidth -= 100;
}
if (
mediaDimensions.height < maxHeight &&
mediaDimensions.width < maxWidth
) {
return mediaDimensions;
}
const heightRatio = maxHeight / mediaDimensions.height;
const widthRatio = maxWidth / mediaDimensions.width;
if (heightRatio < widthRatio) {
return {
height: maxHeight,
width: mediaDimensions.width * heightRatio,
};
} else {
return {
width: maxWidth,
height: mediaDimensions.height * widthRatio,
};
}
}, [frame, mediaDimensions]);
const centerX = useValue(frame.width / 2);
const centerY = useValue(frame.height / 2 + screenDimensions.topInset);
const frameWidth = useValue(frame.width);
const frameHeight = useValue(frame.height);
const imageWidth = useValue(mediaDisplayDimensions.width);
const imageHeight = useValue(mediaDisplayDimensions.height);
React.useEffect(() => {
const { width: frameW, height: frameH } = frame;
const { topInset } = screenDimensions;
frameWidth.setValue(frameW);
frameHeight.setValue(frameH);
centerX.setValue(frameW / 2);
centerY.setValue(frameH / 2 + topInset);
const { width, height } = mediaDisplayDimensions;
imageWidth.setValue(width);
imageHeight.setValue(height);
}, [
screenDimensions,
frame,
mediaDisplayDimensions,
frameWidth,
frameHeight,
centerX,
centerY,
imageWidth,
imageHeight,
]);
const left = React.useMemo(
() => sub(centerX, divide(imageWidth, 2)),
[centerX, imageWidth],
);
const top = React.useMemo(
() => sub(centerY, divide(imageHeight, 2)),
[centerY, imageHeight],
);
const { initialCoordinates } = props.route.params;
const initialScale = React.useMemo(
() => divide(initialCoordinates.width, imageWidth),
[initialCoordinates, imageWidth],
);
const initialTranslateX = React.useMemo(
() =>
sub(
initialCoordinates.x + initialCoordinates.width / 2,
add(left, divide(imageWidth, 2)),
),
[initialCoordinates, left, imageWidth],
);
const initialTranslateY = React.useMemo(
() =>
sub(
initialCoordinates.y + initialCoordinates.height / 2,
add(top, divide(imageHeight, 2)),
),
[initialCoordinates, top, imageHeight],
);
// The all-important outputs
const curScale = useValue(1);
const curX = useValue(0);
const curY = useValue(0);
const curBackdropOpacity = useValue(1);
const progressiveOpacity = React.useMemo(
() =>
max(
min(
sub(1, abs(divide(curX, frameWidth))),
sub(1, abs(divide(curY, frameHeight))),
),
0,
),
[curX, curY, frameWidth, frameHeight],
);
const updates = React.useMemo(
() => [toggleControls, set(curBackdropOpacity, progressiveOpacity)],
[curBackdropOpacity, progressiveOpacity, toggleControls],
);
const updatedScale = React.useMemo(
() => [updates, curScale],
[updates, curScale],
);
const updatedCurX = React.useMemo(() => [updates, curX], [updates, curX]);
const updatedCurY = React.useMemo(() => [updates, curY], [updates, curY]);
const updatedBackdropOpacity = React.useMemo(
() => [updates, curBackdropOpacity],
[updates, curBackdropOpacity],
);
const updatedActiveControlsOpacity = React.useMemo(
() => block([updates, activeControlsOpacity]),
[updates, activeControlsOpacity],
);
const overlayContext = React.useContext(OverlayContext);
invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext');
const navigationProgress = overlayContext.position;
const reverseNavigationProgress = React.useMemo(
() => sub(1, navigationProgress),
[navigationProgress],
);
const dismissalButtonOpacity = interpolateNode(updatedBackdropOpacity, {
inputRange: [0.95, 1],
outputRange: [0, 1],
extrapolate: Extrapolate.CLAMP,
});
const controlsOpacity = multiply(
navigationProgress,
dismissalButtonOpacity,
updatedActiveControlsOpacity,
);
const scale = React.useMemo(
() =>
add(
multiply(reverseNavigationProgress, initialScale),
multiply(navigationProgress, updatedScale),
),
[reverseNavigationProgress, initialScale, navigationProgress, updatedScale],
);
const x = React.useMemo(
() =>
add(
multiply(reverseNavigationProgress, initialTranslateX),
multiply(navigationProgress, updatedCurX),
),
[
reverseNavigationProgress,
initialTranslateX,
navigationProgress,
updatedCurX,
],
);
const y = React.useMemo(
() =>
add(
multiply(reverseNavigationProgress, initialTranslateY),
multiply(navigationProgress, updatedCurY),
),
[
reverseNavigationProgress,
initialTranslateY,
navigationProgress,
updatedCurY,
],
);
const backdropOpacity = React.useMemo(
() => multiply(navigationProgress, updatedBackdropOpacity),
[navigationProgress, updatedBackdropOpacity],
);
const imageContainerOpacity = React.useMemo(
() =>
interpolateNode(navigationProgress, {
inputRange: [0, 0.1],
outputRange: [0, 1],
extrapolate: Extrapolate.CLAMP,
}),
[navigationProgress],
);
const { verticalBounds } = props.route.params;
const videoContainerStyle = React.useMemo(() => {
const { height, width } = mediaDisplayDimensions;
const { height: frameH, width: frameW } = frame;
return {
height,
width,
marginTop:
(frameH - height) / 2 + screenDimensions.topInset - verticalBounds.y,
marginLeft: (frameW - width) / 2,
opacity: imageContainerOpacity,
transform: [{ translateX: x }, { translateY: y }, { scale: scale }],
};
}, [
mediaDisplayDimensions,
frame,
screenDimensions.topInset,
verticalBounds.y,
imageContainerOpacity,
x,
y,
scale,
]);
const styles = useStyles(unboundStyles);
const [paused, setPaused] = useState(false);
const [percentElapsed, setPercentElapsed] = useState(0);
const [spinnerVisible, setSpinnerVisible] = useState(true);
const [timeElapsed, setTimeElapsed] = useState('0:00');
const [totalDuration, setTotalDuration] = useState('0:00');
const videoRef = React.useRef();
const backgroundedOrInactive = useIsAppBackgroundedOrInactive();
React.useEffect(() => {
if (backgroundedOrInactive) {
setPaused(true);
controlsShowing.setValue(1);
}
}, [backgroundedOrInactive, controlsShowing]);
const { navigation } = props;
const togglePlayback = React.useCallback(() => {
setPaused(!paused);
}, [paused]);
const resetVideo = React.useCallback(() => {
invariant(videoRef.current, 'videoRef.current should be set in resetVideo');
videoRef.current.seek(0);
}, []);
const progressCallback = React.useCallback(res => {
setTimeElapsed(formatDuration(res.currentTime));
setTotalDuration(formatDuration(res.seekableDuration));
setPercentElapsed(
Math.ceil((res.currentTime / res.seekableDuration) * 100),
);
}, []);
const readyForDisplayCallback = React.useCallback(() => {
setSpinnerVisible(false);
}, []);
const statusBar = overlayContext.isDismissing ? null : (
);
const backdropStyle = React.useMemo(
() => ({ opacity: backdropOpacity }),
[backdropOpacity],
);
const contentContainerStyle = React.useMemo(() => {
const fullScreenHeight = screenDimensions.height;
const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height;
// margin will clip, but padding won't
const verticalStyle = overlayContext.isDismissing
? { marginTop: verticalBounds.y, marginBottom: bottom }
: { paddingTop: verticalBounds.y, paddingBottom: bottom };
return [styles.contentContainer, verticalStyle];
}, [
screenDimensions.height,
verticalBounds.y,
verticalBounds.height,
overlayContext.isDismissing,
styles.contentContainer,
]);
let controls;
if (videoSource) {
controls = (
{timeElapsed} / {totalDuration}
);
}
let spinner;
if (spinnerVisible) {
spinner = (
);
}
let videoPlayer;
if (videoSource) {
videoPlayer = (
);
}
return (
{statusBar}
{spinner}
{videoPlayer}
{controls}
);
}
const unboundStyles = {
expand: {
flex: 1,
},
backgroundVideo: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(52,52,52,0.6)',
height: 76,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 8,
},
header: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
playPauseButton: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
paddingTop: 10,
paddingRight: 20,
justifyContent: 'flex-end',
alignItems: 'center',
flexDirection: 'row',
height: 100,
},
progressBar: {
flex: 1,
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
paddingRight: 10,
display: 'flex',
flexDirection: 'row',
},
progressCircle: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
iconButton: {
marginHorizontal: 10,
color: 'white',
},
durationText: {
color: 'white',
fontSize: 11,
width: 70,
},
backdrop: {
backgroundColor: 'black',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
top: 0,
},
contentContainer: {
flex: 1,
overflow: 'hidden',
},
fill: {
flex: 1,
},
};
export default VideoPlaybackModal;
diff --git a/native/root.react.js b/native/root.react.js
index 2277445fc..ec007cd86 100644
--- a/native/root.react.js
+++ b/native/root.react.js
@@ -1,300 +1,304 @@
// @flow
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useReduxDevToolsExtension } from '@react-navigation/devtools';
import { NavigationContainer } from '@react-navigation/native';
import type { PossiblyStaleNavigationState } from '@react-navigation/native';
import * as SplashScreen from 'expo-splash-screen';
import invariant from 'invariant';
import * as React from 'react';
import { Platform, UIManager, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import Orientation from 'react-native-orientation-locker';
import {
SafeAreaProvider,
initialWindowMetrics,
} from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js';
import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js';
+import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js';
import { actionLogger } from 'lib/utils/action-logger.js';
import ChatContextProvider from './chat/chat-context-provider.react.js';
import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js';
import PersistedStateGate from './components/persisted-state-gate.js';
import ConnectedStatusBar from './connected-status-bar.react.js';
import { SQLiteDataHandler } from './data/sqlite-data-handler.js';
import ErrorBoundary from './error-boundary.react.js';
import InputStateContainer from './input/input-state-container.react.js';
import LifecycleHandler from './lifecycle/lifecycle-handler.react.js';
import MarkdownContextProvider from './markdown/markdown-context-provider.react.js';
+import { filesystemMediaCache } from './media/media-cache.js';
import { defaultNavigationState } from './navigation/default-state.js';
import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react.js';
import { setGlobalNavContext } from './navigation/icky-global.js';
import { NavContext } from './navigation/navigation-context.js';
import NavigationHandler from './navigation/navigation-handler.react.js';
import { validNavState } from './navigation/navigation-utils.js';
import OrientationHandler from './navigation/orientation-handler.react.js';
import { navStateAsyncStorageKey } from './navigation/persistance.js';
import RootNavigator from './navigation/root-navigator.react.js';
import ConnectivityUpdater from './redux/connectivity-updater.react.js';
import { DimensionsUpdater } from './redux/dimensions-updater.react.js';
import { getPersistor } from './redux/persist.js';
import { store } from './redux/redux-setup.js';
import { useSelector } from './redux/redux-utils.js';
import { RootContext } from './root-context.js';
import Socket from './socket.react.js';
import { StaffContextProvider } from './staff/staff-context.provider.react.js';
import { useLoadCommFonts } from './themes/fonts.js';
import { DarkTheme, LightTheme } from './themes/navigation.js';
import ThemeHandler from './themes/theme-handler.react.js';
import { provider } from './utils/ethers-utils.js';
if (Platform.OS === 'android') {
UIManager.setLayoutAnimationEnabledExperimental &&
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const navInitAction = Object.freeze({ type: 'NAV/@@INIT' });
const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' });
SplashScreen.preventAutoHideAsync().catch(console.log);
function Root() {
const navStateRef = React.useRef();
const navDispatchRef = React.useRef();
const navStateInitializedRef = React.useRef(false);
// We call this here to start the loading process
// We gate the UI on the fonts loading in AppNavigator
useLoadCommFonts();
const [navContext, setNavContext] = React.useState(null);
const updateNavContext = React.useCallback(() => {
if (
!navStateRef.current ||
!navDispatchRef.current ||
!navStateInitializedRef.current
) {
return;
}
const updatedNavContext = {
state: navStateRef.current,
dispatch: navDispatchRef.current,
};
setNavContext(updatedNavContext);
setGlobalNavContext(updatedNavContext);
}, []);
const [initialState, setInitialState] = React.useState(
__DEV__ ? undefined : defaultNavigationState,
);
React.useEffect(() => {
Orientation.lockToPortrait();
(async () => {
let loadedState = initialState;
if (__DEV__) {
try {
const navStateString = await AsyncStorage.getItem(
navStateAsyncStorageKey,
);
if (navStateString) {
const savedState = JSON.parse(navStateString);
if (validNavState(savedState)) {
loadedState = savedState;
}
}
} catch {}
}
if (!loadedState) {
loadedState = defaultNavigationState;
}
if (loadedState !== initialState) {
setInitialState(loadedState);
}
navStateRef.current = loadedState;
updateNavContext();
actionLogger.addOtherAction('navState', navInitAction, null, loadedState);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateNavContext]);
const setNavStateInitialized = React.useCallback(() => {
navStateInitializedRef.current = true;
updateNavContext();
}, [updateNavContext]);
const [rootContext, setRootContext] = React.useState(() => ({
setNavStateInitialized,
}));
const detectUnsupervisedBackgroundRef = React.useCallback(
(detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => {
setRootContext(prevRootContext => ({
...prevRootContext,
detectUnsupervisedBackground,
}));
},
[],
);
const frozen = useSelector(state => state.frozen);
const queuedActionsRef = React.useRef([]);
const onNavigationStateChange = React.useCallback(
(state: ?PossiblyStaleNavigationState) => {
invariant(state, 'nav state should be non-null');
const prevState = navStateRef.current;
navStateRef.current = state;
updateNavContext();
const queuedActions = queuedActionsRef.current;
queuedActionsRef.current = [];
if (queuedActions.length === 0) {
queuedActions.push(navUnknownAction);
}
for (const action of queuedActions) {
actionLogger.addOtherAction('navState', action, prevState, state);
}
if (!__DEV__ || frozen) {
return;
}
(async () => {
try {
await AsyncStorage.setItem(
navStateAsyncStorageKey,
JSON.stringify(state),
);
} catch (e) {
console.log('AsyncStorage threw while trying to persist navState', e);
}
})();
},
[updateNavContext, frozen],
);
const navContainerRef = React.useRef();
const containerRef = React.useCallback(
(navContainer: ?React.ElementRef) => {
navContainerRef.current = navContainer;
if (navContainer && !navDispatchRef.current) {
navDispatchRef.current = navContainer.dispatch;
updateNavContext();
}
},
[updateNavContext],
);
useReduxDevToolsExtension(navContainerRef);
const navContainer = navContainerRef.current;
React.useEffect(() => {
if (!navContainer) {
return;
}
return navContainer.addListener('__unsafe_action__', event => {
const { action, noop } = event.data;
const navState = navStateRef.current;
if (noop) {
actionLogger.addOtherAction('navState', action, navState, navState);
return;
}
queuedActionsRef.current.push({
...action,
type: `NAV/${action.type}`,
});
});
}, [navContainer]);
const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme);
const theme = (() => {
if (activeTheme === 'light') {
return LightTheme;
} else if (activeTheme === 'dark') {
return DarkTheme;
}
return undefined;
})();
const gated: React.Node = (
<>
>
);
let navigation;
if (initialState) {
navigation = (
);
}
return (
-
-
-
-
-
- {gated}
-
-
-
-
- {navigation}
-
-
+
+
+
+
+
+
+ {gated}
+
+
+
+
+ {navigation}
+
+
+
);
}
const styles = StyleSheet.create({
app: {
flex: 1,
},
});
function AppRoot(): React.Node {
return (
);
}
export default AppRoot;