[Gekidou] Gallery (#6008)

* Gallery screen (ground work)

* Open the gallery from posts

* Open the gallery from post draft

* feedback review

* Feedback review 2

* do not remove dm channel names and localization fix

* update to the latest network-client

* do not override file width, height and imageThumbail if received file does not have it set

* bring back ScrollView wrapper for message component

* Remove Text wrapper for markdown paragraph

* Fix YouTube play icon placeholder

* Make video file play button container round

* Add gif image placeholder

* Save images & videos to camera roll

* Feedback review 3

* load video thumbnail when post is in viewport

* simplify prefix
This commit is contained in:
Elias Nahum
2022-03-01 13:55:44 -03:00
committed by GitHub
parent efd2fd0c02
commit 5de54471b7
115 changed files with 6458 additions and 1323 deletions

View File

@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mattermost.rnbeta">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
@@ -10,6 +9,13 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
</queries>
<application
android:name=".MainApplication"
android:allowBackup="false"

View File

@@ -1,27 +1,86 @@
package com.mattermost.rnbeta;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.BaseActivityEventListener;
import com.facebook.react.bridge.GuardedResultAsyncTask;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.mattermost.helpers.RealPathUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.channels.FileChannel;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
private static final String SAVE_EVENT = "MattermostManagedSaveFile";
private static final Integer SAVE_REQUEST = 38641;
private static MattermostManagedModule instance;
private ReactApplicationContext reactContext;
private Promise mPickerPromise;
private String fileContent;
private static final String TAG = MattermostManagedModule.class.getSimpleName();
private MattermostManagedModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
// Let the document provider know you're done by closing the stream.
ActivityEventListener mActivityEventListener = new BaseActivityEventListener() {
@Override
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent intent) {
if (requestCode == SAVE_REQUEST) {
if (mPickerPromise != null) {
if (resultCode == Activity.RESULT_CANCELED) {
mPickerPromise.reject(SAVE_EVENT, "Save operation cancelled");
} else if (resultCode == Activity.RESULT_OK) {
Uri uri = intent.getData();
if (uri == null) {
mPickerPromise.reject(SAVE_EVENT, "No data found");
} else {
try {
new SaveDataTask(reactContext, fileContent, uri).execute();
mPickerPromise.resolve(uri.toString());
} catch (Exception e) {
mPickerPromise.reject(SAVE_EVENT, e.getMessage());
}
}
}
mPickerPromise = null;
} else if (resultCode == Activity.RESULT_OK) {
try {
Uri uri = intent.getData();
if (uri != null)
new SaveDataTask(reactContext, fileContent, uri).execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
};
reactContext.addActivityEventListener(mActivityEventListener);
}
public static MattermostManagedModule getInstance(ReactApplicationContext reactContext) {
@@ -63,4 +122,123 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
promise.resolve(result);
}
@ReactMethod
public void saveFile(String path, final Promise promise) {
Uri contentUri;
String filename = "";
if(path.startsWith("content://")) {
contentUri = Uri.parse(path);
} else {
File newFile = new File(path);
filename = newFile.getName();
Activity currentActivity = getCurrentActivity();
if(currentActivity == null) {
promise.reject(SAVE_EVENT, "Activity doesn't exist");
return;
}
try {
final String packageName = currentActivity.getPackageName();
final String authority = new StringBuilder(packageName).append(".provider").toString();
contentUri = FileProvider.getUriForFile(currentActivity, authority, newFile);
}
catch(IllegalArgumentException e) {
promise.reject(SAVE_EVENT, e.getMessage());
return;
}
}
if(contentUri == null) {
promise.reject(SAVE_EVENT, "Invalid file");
return;
}
String extension = MimeTypeMap.getFileExtensionFromUrl(path).toLowerCase();
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mimeType == null) {
mimeType = RealPathUtil.getMimeType(path);
}
Intent intent = new Intent();
intent.setAction(Intent.ACTION_CREATE_DOCUMENT);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, filename);
PackageManager pm = getCurrentActivity().getPackageManager();
if (intent.resolveActivity(pm) != null) {
try {
getCurrentActivity().startActivityForResult(intent, SAVE_REQUEST);
mPickerPromise = promise;
fileContent = path;
}
catch(Exception e) {
promise.reject(SAVE_EVENT, e.getMessage());
}
} else {
try {
if(mimeType == null) {
throw new Exception("It wasn't possible to detect the type of the file");
}
throw new Exception("No app associated with this mime type");
}
catch(Exception e) {
promise.reject(SAVE_EVENT, e.getMessage());
}
}
}
private static class SaveDataTask extends GuardedResultAsyncTask<Object> {
private final WeakReference<Context> weakContext;
private final String fromFile;
private final Uri toFile;
protected SaveDataTask(ReactApplicationContext reactContext, String path, Uri destination) {
super(reactContext.getExceptionHandler());
weakContext = new WeakReference<>(reactContext.getApplicationContext());
fromFile = path;
toFile = destination;
}
@Override
protected Object doInBackgroundGuarded() {
FileChannel source = null;
FileChannel dest = null;
try {
File input = new File(this.fromFile);
FileInputStream fileInputStream = new FileInputStream(input);
ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w");
FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor());
source = fileInputStream.getChannel();
dest = fileOutputStream.getChannel();
dest.transferFrom(source, 0, source.size());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (source != null) {
try {
source.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (dest != null) {
try {
dest.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
@Override
protected void onPostExecuteGuarded(Object o) {
}
}
}

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="files" path="." />
<external-files-path name="external_files" path="." />
<external-path name="external" path="." />
<cache-path name="cache" path="." />
<root-path name="root" path="." />
</paths>

View File

@@ -4,10 +4,11 @@ project(':lottie-react-native').projectDir = new File(rootProject.projectDir, '.
include ':reactnativenotifications'
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/lib/android/app')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app'
include ':react-native-video'
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android-exoplayer')
include ':watermelondb-jsi'
project(':watermelondb-jsi').projectDir =
new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi')
project(':watermelondb-jsi').projectDir = new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi')
include ':app'
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute().text.trim(), "../scripts/autolinking.gradle")
useExpoModules()

13
app/actions/local/file.ts Normal file
View File

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
export const updateLocalFile = async (serverUrl: string, file: FileInfo) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
return operator.handleFiles({files: [file], prepareRecordsOnly: false});
};

View File

@@ -5,8 +5,16 @@ import {ClientResponse, ClientResponseError} from '@mattermost/react-native-netw
import {Client} from '@client/rest';
import ClientError from '@client/rest/error';
import {DOWNLOAD_TIMEOUT} from '@constants/network';
import NetworkManager from '@init/network_manager';
import {forceLogoutIfNecessary} from './session';
export const downloadFile = (serverUrl: string, fileId: string, desitnation: string) => { // Let it throw and handle it accordingly
const client = NetworkManager.getClient(serverUrl);
return client.apiClient.download(client.getFileRoute(fileId), desitnation.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT});
};
export const uploadFile = (
serverUrl: string,
file: FileInfo,
@@ -24,3 +32,64 @@ export const uploadFile = (
}
return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)};
};
export const fetchPublicLink = async (serverUrl: string, fileId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientError};
}
try {
const publicLink = await client!.getFilePublicLink(fileId);
return publicLink;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const buildFileUrl = (serverUrl: string, fileId: string, timestamp = 0) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return '';
}
return client.getFileUrl(fileId, timestamp);
};
export const buildAbsoluteUrl = (serverUrl: string, relativePath: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return '';
}
return client.getAbsoluteUrl(relativePath);
};
export const buildFilePreviewUrl = (serverUrl: string, fileId: string, timestamp = 0) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return '';
}
return client.getFilePreviewUrl(fileId, timestamp);
};
export const buildFileThumbnailUrl = (serverUrl: string, fileId: string, timestamp = 0) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return '';
}
return client.getFileThumbnailUrl(fileId, timestamp);
};

View File

@@ -561,3 +561,14 @@ export const uploadUserProfileImage = async (serverUrl: string, localPath: strin
}
return {error: undefined};
};
export const buildProfileImageUrl = (serverUrl: string, userId: string, timestamp = 0) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return '';
}
return client.getProfilePictureUrl(userId, timestamp);
};

View File

@@ -7,7 +7,7 @@ export interface ClientFilesMix {
getFileUrl: (fileId: string, timestamp: number) => string;
getFileThumbnailUrl: (fileId: string, timestamp: number) => string;
getFilePreviewUrl: (fileId: string, timestamp: number) => string;
getFilePublicLink: (fileId: string) => Promise<any>;
getFilePublicLink: (fileId: string) => Promise<{link: string}>;
uploadPostAttachment: (
file: FileInfo,
channelId: string,

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Freeze} from 'react-freeze';
import {View} from 'react-native';
import useFreeze from '@hooks/freeze';
type FreezePlaceholderProps = {
backgroundColor: string;
};
type FreezeScreenProps = {
children: React.ReactNode;
}
const FreezePlaceholder = ({backgroundColor}: FreezePlaceholderProps) => {
return <View style={{flex: 1, backgroundColor}}/>;
};
const FreezeScreen = ({children}: FreezeScreenProps) => {
const {freeze, backgroundColor} = useFreeze();
const placeholder = (<FreezePlaceholder backgroundColor={backgroundColor}/>);
return (
<Freeze
freeze={freeze}
placeholder={placeholder}
>
{children}
</Freeze>
);
};
export default FreezeScreen;

View File

@@ -9,6 +9,7 @@ import Clipboard from '@react-native-community/clipboard';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
@@ -172,7 +173,7 @@ const AtMention = ({
let isMention = false;
let mention;
let onLongPress;
let onPress;
let onPress: (e?: GestureResponderEvent) => void;
let suffix;
let suffixElement;
let styleText;
@@ -204,7 +205,7 @@ const AtMention = ({
if (canPress) {
onLongPress = handleLongPress;
onPress = isSearchResult ? onPostPress : goToUserProfile;
onPress = (isSearchResult ? onPostPress : goToUserProfile) as (e?: GestureResponderEvent) => void;
}
if (suffix) {
@@ -225,16 +226,17 @@ const AtMention = ({
}
return (
<Text
style={styleText}
onPress={onPress}
<TouchableOpacity
onPress={onPress!}
onLongPress={onLongPress}
>
<Text style={mentionTextStyle}>
{'@' + mention}
<Text style={styleText}>
<Text style={mentionTextStyle}>
{'@' + mention}
</Text>
{suffixElement}
</Text>
{suffixElement}
</Text>
</TouchableOpacity>
);
};

View File

@@ -7,6 +7,7 @@ import withObservables from '@nozbe/with-observables';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleProp, Text, TextStyle} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {map, switchMap} from 'rxjs/operators';
import {joinChannel, switchToChannelById} from '@actions/remote/channel';
@@ -111,15 +112,14 @@ const ChannelMention = ({
}
return (
<Text style={textStyle}>
<Text
onPress={handlePress}
style={linkStyle}
>
{`~${channel.display_name}`}
<TouchableOpacity onPress={handlePress}>
<Text style={textStyle}>
<Text style={linkStyle}>
{`~${channel.display_name}`}
</Text>
{suffix}
</Text>
{suffix}
</Text>
</TouchableOpacity>
);
};

View File

@@ -3,6 +3,7 @@
import React from 'react';
import {Text, TextStyle} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {popToRoot, showSearchModal, dismissAllModals} from '@screens/navigation';
@@ -21,12 +22,11 @@ const Hashtag = ({hashtag, linkStyle}: HashtagProps) => {
};
return (
<Text
style={linkStyle}
onPress={handlePress}
>
{`#${hashtag}`}
</Text>
<TouchableOpacity onPress={handlePress}>
<Text style={linkStyle}>
{`#${hashtag}`}
</Text>
</TouchableOpacity>
);
};

View File

@@ -42,6 +42,7 @@ type MarkdownProps = {
isEdited?: boolean;
isReplyPost?: boolean;
isSearchResult?: boolean;
location?: string;
mentionKeys?: UserMentionKey[];
minimumHashtagLength?: number;
onPostPress?: (event: GestureResponderEvent) => void;
@@ -190,8 +191,9 @@ class Markdown extends PureComponent<MarkdownProps> {
// We have enough problems rendering images as is, so just render a link inside of a table
return (
<MarkdownTableImage
disabled={this.props.disableGallery}
disabled={this.props.disableGallery ?? Boolean(!this.props.location)}
imagesMetadata={this.props.imagesMetadata}
location={this.props.location}
postId={this.props.postId!}
source={src}
/>
@@ -200,11 +202,12 @@ class Markdown extends PureComponent<MarkdownProps> {
return (
<MarkdownImage
disabled={this.props.disableGallery}
disabled={this.props.disableGallery ?? Boolean(!this.props.location)}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
linkDestination={linkDestination}
imagesMetadata={this.props.imagesMetadata}
isReplyPost={this.props.isReplyPost}
location={this.props.location}
postId={this.props.postId!}
source={src}
/>
@@ -283,9 +286,7 @@ class Markdown extends PureComponent<MarkdownProps> {
return (
<View style={blockStyle}>
<Text>
{children}
</Text>
{children}
</View>
);
};

View File

@@ -6,10 +6,10 @@ import Clipboard from '@react-native-community/clipboard';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, Text, TextStyle, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {bottomSheet, dismissBottomSheet, goToScreen} from '@screens/navigation';
import {getDisplayNameForLanguage} from '@utils/markdown';
@@ -226,10 +226,9 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
};
return (
<TouchableWithFeedback
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
type={'opacity'}
>
<View style={style.container}>
<View style={style.lineNumbers}>
@@ -245,7 +244,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
</View>
{renderLanguageBlock()}
</View>
</TouchableWithFeedback>
</TouchableOpacity>
);
};

View File

@@ -3,11 +3,15 @@
import {useManagedConfig} from '@mattermost/react-native-emm';
import Clipboard from '@react-native-community/clipboard';
import React, {useCallback, useRef, useState} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
import {LongPressGestureHandler, TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import parseUrl from 'url-parse';
import {GalleryInit} from '@app/context/gallery';
import {useGalleryItem} from '@app/hooks/gallery';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import ProgressiveImage from '@components/progressive_image';
@@ -17,7 +21,8 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {openGallerWithMockFile} from '@utils/gallery';
import {lookupMimeType} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
@@ -28,6 +33,7 @@ type MarkdownImageProps = {
imagesMetadata?: Record<string, PostImage>;
isReplyPost?: boolean;
linkDestination?: string;
location?: string;
postId: string;
source: string;
}
@@ -50,42 +56,63 @@ const style = StyleSheet.create({
const MarkdownImage = ({
disabled, errorTextStyle, imagesMetadata, isReplyPost = false,
linkDestination, postId, source,
linkDestination, location, postId, source,
}: MarkdownImageProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const managedConfig = useManagedConfig();
const genericFileId = useRef(generateId()).current;
const genericFileId = useRef(generateId('uid')).current;
const tapRef = useRef<TapGestureHandler>();
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0];
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const originalSize = {width: metadata?.width || 0, height: metadata?.height || 0};
const serverUrl = useServerUrl();
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
const uri = useMemo(() => {
if (source.startsWith('/')) {
return serverUrl + source;
}
let uri = source;
if (uri.startsWith('/')) {
uri = serverUrl + uri;
}
return source;
}, [source, serverUrl]);
const link = decodeURIComponent(uri);
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
let extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
extension = ext;
}
const fileInfo = useMemo(() => {
const link = decodeURIComponent(uri);
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
let extension = filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
extension = ext;
}
const fileInfo = {
id: genericFileId,
name: filename,
extension,
has_preview_image: true,
post_id: postId,
uri: link,
width: originalSize.width,
height: originalSize.height,
};
return {
id: genericFileId,
name: filename,
extension,
has_preview_image: true,
post_id: postId,
uri: link,
width: originalSize.width,
height: originalSize.height,
};
}, []);
const handlePreviewImage = useCallback(() => {
const item: GalleryItemType = {
...fileInfo,
mime_type: lookupMimeType(fileInfo.name),
type: 'image',
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
}, []);
const {ref, onGestureEvent, styles} = useGalleryItem(
galleryIdentifier,
0,
handlePreviewImage,
);
const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, isTablet));
@@ -150,10 +177,6 @@ const MarkdownImage = ({
}
}, [managedConfig, intl, theme]);
const handlePreviewImage = useCallback(() => {
openGallerWithMockFile(fileInfo.uri, postId, fileInfo.height, fileInfo.width, fileInfo.id);
}, []);
const handleOnError = useCallback(() => {
setFailed(true);
}, []);
@@ -186,20 +209,30 @@ const MarkdownImage = ({
);
} else {
image = (
<TouchableWithFeedback
disabled={disabled}
onLongPress={handleLinkLongPress}
onPress={handlePreviewImage}
style={[{width, height}, style.container]}
<LongPressGestureHandler
enabled={!disabled}
onGestureEvent={handleLinkLongPress}
waitFor={tapRef}
>
<ProgressiveImage
id={fileInfo.id}
defaultSource={{uri: fileInfo.uri}}
onError={handleOnError}
resizeMode='contain'
style={{width, height}}
/>
</TouchableWithFeedback>
<Animated.View style={[styles, {width, height}, style.container]}>
<TapGestureHandler
enabled={!disabled}
onGestureEvent={onGestureEvent}
ref={tapRef}
>
<Animated.View testID='markdown_image'>
<ProgressiveImage
forwardRef={ref}
id={fileInfo.id}
defaultSource={{uri: fileInfo.uri}}
onError={handleOnError}
resizeMode='contain'
style={{width, height}}
/>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</LongPressGestureHandler>
);
}
}
@@ -211,15 +244,23 @@ const MarkdownImage = ({
onLongPress={handleLinkLongPress}
style={[{width, height}, style.container]}
>
{image}
<ProgressiveImage
id={fileInfo.id}
defaultSource={{uri: fileInfo.uri}}
onError={handleOnError}
resizeMode='contain'
style={{width, height}}
/>
</TouchableWithFeedback>
);
}
return (
<View testID='markdown_image'>
{image}
</View>
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View testID='markdown_image'>
{image}
</Animated.View>
</GalleryInit>
);
};

View File

@@ -8,6 +8,7 @@ import Clipboard from '@react-native-community/clipboard';
import React, {Children, ReactElement, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet, Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import urlParse from 'url-parse';
@@ -164,12 +165,14 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
const renderChildren = experimentalNormalizeMarkdownLinks ? parseChildren() : children;
return (
<Text
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
>
{renderChildren}
</Text>
<Text>
{renderChildren}
</Text>
</TouchableOpacity>
);
};

View File

@@ -4,11 +4,11 @@
import React, {PureComponent, ReactNode} from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {Dimensions, EventSubscription, LayoutChangeEvent, Platform, ScaledSize, ScrollView, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import LinearGradient from 'react-native-linear-gradient';
import CompassIcon from '@components/compass_icon';
import {CELL_MAX_WIDTH, CELL_MIN_WIDTH} from '@components/markdown/markdown_table_cell';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import DeviceTypes from '@constants/device';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
@@ -244,8 +244,7 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
let expandButton = null;
if (expandButtonOffset > 0) {
expandButton = (
<TouchableWithFeedback
type='opacity'
<TouchableOpacity
onPress={this.handlePress}
style={[style.expandButton, {left: expandButtonOffset}]}
testID='markdown_table.expand.button'
@@ -258,15 +257,14 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
/>
</View>
</View>
</TouchableWithFeedback>
</TouchableOpacity>
);
}
return (
<TouchableWithFeedback
<TouchableOpacity
style={style.tablePadding}
onPress={this.handlePress}
type='opacity'
testID='markdown_table'
>
<ScrollView
@@ -281,7 +279,7 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
{moreRight}
{moreBelow}
{expandButton}
</TouchableWithFeedback>
</TouchableOpacity>
);
}
}

View File

@@ -3,36 +3,40 @@
import React, {memo, useCallback, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import parseUrl from 'url-parse';
import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {openGallerWithMockFile} from '@utils/gallery';
import {useGalleryItem} from '@hooks/gallery';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions, isGifTooLarge} from '@utils/images';
type MarkdownTableImageProps = {
disabled?: boolean;
imagesMetadata: Record<string, PostImage>;
location?: string;
postId: string;
serverURL?: string;
source: string;
}
const styles = StyleSheet.create({
const style = StyleSheet.create({
container: {
alignItems: 'center',
flex: 1,
},
});
const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: MarkdownTableImageProps) => {
const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL, source}: MarkdownTableImageProps) => {
const metadata = imagesMetadata[source];
const fileId = useRef(generateId()).current;
const fileId = useRef(generateId('uid')).current;
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const currentServerUrl = useServerUrl();
const galleryIdentifier = `${postId}-${fileId}-${location}`;
const getImageSource = () => {
let uri = source;
@@ -78,9 +82,19 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M
if (!file?.uri) {
return;
}
openGallerWithMockFile(file.uri, file.post_id, file.height, file.width, file.id);
const item: GalleryItemType = {
...fileToGalleryItem(file),
type: 'image',
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
}, []);
const {ref, onGestureEvent, styles} = useGalleryItem(
galleryIdentifier,
0,
handlePreviewImage,
);
const onLoadFailed = useCallback(() => {
setFailed(true);
}, []);
@@ -96,24 +110,29 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M
} else {
const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100);
image = (
<TouchableWithFeedback
disabled={disabled}
onPress={handlePreviewImage}
style={{width, height}}
<TapGestureHandler
enabled={!disabled}
onGestureEvent={onGestureEvent}
>
<ProgressiveImage
id={fileId}
defaultSource={{uri: source}}
onError={onLoadFailed}
resizeMode='contain'
style={{width, height}}
/>
</TouchableWithFeedback>
<Animated.View
style={[styles, {width, height}]}
testID='markdown_table_image'
>
<ProgressiveImage
id={fileId}
defaultSource={{uri: source}}
forwardRef={ref}
onError={onLoadFailed}
resizeMode='contain'
style={{width, height}}
/>
</Animated.View>
</TapGestureHandler>
);
}
return (
<View style={styles.container}>
<View style={style.container}>
{image}
</View>
);

View File

@@ -18,6 +18,7 @@ type Props = {
testID?: string;
channelId: string;
rootId?: string;
currentUserId: string;
// Cursor Position Handler
updateCursorPosition: (pos: number) => void;
@@ -77,6 +78,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
export default function DraftInput({
testID,
channelId,
currentUserId,
files,
maxMessageLength,
rootId = '',
@@ -139,6 +141,7 @@ export default function DraftInput({
sendMessage={sendMessage}
/>
<Uploads
currentUserId={currentUserId}
files={files}
uploadFileError={uploadFileError}
channelId={channelId}

View File

@@ -226,6 +226,7 @@ export default function SendHandler({
<DraftInput
testID={testID}
channelId={channelId}
currentUserId={currentUserId}
rootId={rootId}
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {
ScrollView,
Text,
@@ -10,9 +10,10 @@ import {
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {GalleryInit} from '@context/gallery';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@init/draft_upload_manager';
import {openGalleryAtIndex} from '@utils/gallery';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {makeStyleSheetFromTheme} from '@utils/theme';
import UploadItem from './upload_item';
@@ -23,6 +24,7 @@ const ERROR_HEIGHT_MAX = 20;
const ERROR_HEIGHT_MIN = 0;
type Props = {
currentUserId: string;
files: FileInfo[];
uploadFileError: React.ReactNode;
channelId: string;
@@ -67,16 +69,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
});
export default function Uploads({
currentUserId,
files,
uploadFileError,
channelId,
rootId,
}: Props) {
const galleryIdentifier = `${channelId}-uploadedItems-${rootId}`;
const theme = useTheme();
const style = getStyleSheet(theme);
const errorHeight = useSharedValue(ERROR_HEIGHT_MIN);
const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX);
const filesForGallery = useRef(files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)));
const errorAnimatedStyle = useAnimatedStyle(() => {
return {
@@ -90,9 +95,13 @@ export default function Uploads({
};
});
const fileContainerStyle = {
const fileContainerStyle = useMemo(() => ({
paddingBottom: files.length ? 5 : 0,
};
}), [files.length]);
useEffect(() => {
filesForGallery.current = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
}, [files]);
useEffect(() => {
if (uploadFileError) {
@@ -111,19 +120,21 @@ export default function Uploads({
}, [files.length > 0]);
const openGallery = useCallback((file: FileInfo) => {
const galleryFiles = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
const index = galleryFiles.indexOf(file);
openGalleryAtIndex(index, galleryFiles);
}, [files]);
const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId));
const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId);
openGalleryAtIndex(galleryIdentifier, index, items, true);
}, [currentUserId, files]);
const buildFilePreviews = () => {
return files.map((file) => {
return files.map((file, index) => {
return (
<UploadItem
key={file.clientId}
file={file}
openGallery={openGallery}
channelId={channelId}
galleryIdentifier={galleryIdentifier}
index={index}
file={file}
key={file.clientId}
openGallery={openGallery}
rootId={rootId}
/>
);
@@ -131,33 +142,35 @@ export default function Uploads({
};
return (
<View style={style.previewContainer}>
<Animated.View
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
>
<ScrollView
horizontal={true}
style={style.scrollView}
contentContainerStyle={style.scrollViewContent}
keyboardShouldPersistTaps={'handled'}
<GalleryInit galleryIdentifier={galleryIdentifier}>
<View style={style.previewContainer}>
<Animated.View
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
>
{buildFilePreviews()}
</ScrollView>
</Animated.View>
<ScrollView
horizontal={true}
style={style.scrollView}
contentContainerStyle={style.scrollViewContent}
keyboardShouldPersistTaps={'handled'}
>
{buildFilePreviews()}
</ScrollView>
</Animated.View>
<Animated.View
style={[style.errorContainer, errorAnimatedStyle]}
>
{Boolean(uploadFileError) &&
<View style={style.errorTextContainer}>
<Animated.View
style={[style.errorContainer, errorAnimatedStyle]}
>
{Boolean(uploadFileError) &&
<View style={style.errorTextContainer}>
<Text style={style.warning}>
{uploadFileError}
</Text>
<Text style={style.warning}>
{uploadFileError}
</Text>
</View>
}
</Animated.View>
</View>
</View>
}
</Animated.View>
</View>
</GalleryInit>
);
}

View File

@@ -2,7 +2,9 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
import {StyleSheet, View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import {updateDraftFile} from '@actions/local/draft';
import FileIcon from '@components/post_list/post/body/files/file_icon';
@@ -11,6 +13,7 @@ import ProgressBar from '@components/progress_bar';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import {useGalleryItem} from '@hooks/gallery';
import DraftUploadManager from '@init/draft_upload_manager';
import {isImage} from '@utils/file';
import {changeOpacity} from '@utils/theme';
@@ -19,13 +22,15 @@ import UploadRemove from './upload_remove';
import UploadRetry from './upload_retry';
type Props = {
file: FileInfo;
channelId: string;
rootId: string;
galleryIdentifier: string;
index: number;
file: FileInfo;
openGallery: (file: FileInfo) => void;
rootId: string;
}
const styles = StyleSheet.create({
const style = StyleSheet.create({
preview: {
paddingTop: 5,
marginLeft: 12,
@@ -52,10 +57,8 @@ const styles = StyleSheet.create({
});
export default function UploadItem({
file,
channelId,
rootId,
openGallery,
channelId, galleryIdentifier, index, file,
rootId, openGallery,
}: Props) {
const theme = useTheme();
const serverUrl = useServerUrl();
@@ -101,11 +104,14 @@ export default function UploadItem({
DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
}, [serverUrl, channelId, rootId, file]);
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress);
const filePreviewComponent = useMemo(() => {
if (isImage(file)) {
return (
<ImageFile
file={file}
forwardRef={ref}
resizeMode='cover'
/>
);
@@ -122,21 +128,21 @@ export default function UploadItem({
return (
<View
key={file.clientId}
style={styles.preview}
style={style.preview}
>
<View style={styles.previewContainer}>
<TouchableOpacity onPress={handlePress}>
<View style={styles.filePreview}>
<View style={style.previewContainer}>
<TapGestureHandler onGestureEvent={onGestureEvent}>
<Animated.View style={[styles, style.filePreview]}>
{filePreviewComponent}
</View>
</TouchableOpacity>
</Animated.View>
</TapGestureHandler>
{file.failed &&
<UploadRetry
onPress={retryFileUpload}
/>
}
{loading && !file.failed &&
<View style={styles.progress}>
<View style={style.progress}>
<ProgressBar
progress={progress || 0}
color={theme.buttonBg}

View File

@@ -7,13 +7,13 @@ import React, {ReactNode, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import SystemAvatar from '@components/system_avatar';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {View as ViewConstant} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server';
@@ -140,12 +140,9 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, po
if (!fromWebHook) {
component = (
<TouchableWithFeedback
onPress={onViewUserProfile}
type={'opacity'}
>
<TouchableOpacity onPress={onViewUserProfile}>
{component}
</TouchableWithFeedback>
</TouchableOpacity>
);
}

View File

@@ -6,6 +6,7 @@ import withObservables from '@nozbe/with-observables';
import React, {ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {Text} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
@@ -181,16 +182,14 @@ const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) =>
defaultMessage={outOfChannelMessageText}
style={styles.message}
/>
<Text
style={textStyles.link}
testID='add_channel_member_link'
onPress={handleAddChannelMember}
>
<TouchableOpacity onPress={handleAddChannelMember}>
<FormattedText
id={linkId}
defaultMessage={linkText}
style={textStyles.link}
testID='add_channel_member_link'
/>
</Text>
</TouchableOpacity>
<FormattedText
id={'post_body.check_for_out_of_channel_mentions.message_last'}
defaultMessage={'? They will have access to all message history.'}

View File

@@ -5,23 +5,26 @@ import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {Animated, StyleSheet, View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {getRedirectLocation} from '@actions/remote/general';
import FileIcon from '@components/post_list/post/body/files/file_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {GalleryInit} from '@context/gallery';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {openGallerWithMockFile} from '@utils/gallery';
import {useGalleryItem} from '@hooks/gallery';
import {lookupMimeType} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {changeOpacity} from '@utils/theme';
import {isImageLink, isValidUrl} from '@utils/url';
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
@@ -30,12 +33,13 @@ type ImagePreviewProps = {
expandedLink?: string;
isReplyPost: boolean;
link: string;
location: string;
metadata: PostMetadata;
postId: string;
theme: Theme;
}
const styles = StyleSheet.create({
const style = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
justifyContent: 'flex-start',
@@ -50,10 +54,11 @@ const styles = StyleSheet.create({
},
});
const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}: ImagePreviewProps) => {
const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, postId, theme}: ImagePreviewProps) => {
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
const [error, setError] = useState(false);
const serverUrl = useServerUrl();
const fileId = useRef(generateId()).current;
const fileId = useRef(generateId('uid')).current;
const [imageUrl, setImageUrl] = useState(expandedLink || link);
const isTablet = useIsTablet();
const imageProps = metadata.images![link];
@@ -63,9 +68,25 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
setError(true);
}, []);
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageProps.height, imageProps.width, fileId);
}, [imageUrl]);
const onPress = () => {
const item: GalleryItemType = {
id: fileId,
postId,
uri: imageUrl,
width: imageProps.width,
height: imageProps.height,
name: extractFilenameFromUrl(imageUrl) || 'imagePreview.png',
mime_type: lookupMimeType(imageUrl) || 'images/png',
type: 'image',
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
};
const {ref, onGestureEvent, styles} = useGalleryItem(
galleryIdentifier,
0,
onPress,
);
useEffect(() => {
if (!isImageLink(link) && expandedLink === undefined) {
@@ -89,8 +110,8 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
if (error || !isValidUrl(expandedLink || link) || isGifTooLarge(imageProps)) {
return (
<View style={[styles.imageContainer, {height: dimensions.height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
<View style={[styles.image, {width: dimensions.width, height: dimensions.height}]}>
<View style={[style.imageContainer, {height: dimensions.height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
<View style={[style.image, {width: dimensions.width, height: dimensions.height}]}>
<FileIcon
failed={true}
/>
@@ -99,23 +120,23 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
);
}
// Note that the onPress prop of TouchableWithoutFeedback only works if its child is a View
return (
<TouchableWithFeedback
onPress={onPress}
style={[styles.imageContainer, {height: dimensions.height}]}
type={'none'}
>
<View>
<ProgressiveImage
id={fileId}
style={[styles.image, {width: dimensions.width, height: dimensions.height}]}
imageUri={imageUrl}
resizeMode='contain'
onError={onError}
/>
</View>
</TouchableWithFeedback>
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[styles, style.imageContainer, {height: dimensions.height}]}>
<TapGestureHandler onGestureEvent={onGestureEvent}>
<Animated.View testID={`ImagePreview-${fileId}`}>
<ProgressiveImage
forwardRef={ref}
id={fileId}
imageUri={imageUrl}
onError={onError}
resizeMode='contain'
style={[style.image, {width: dimensions.width, height: dimensions.height}]}
/>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</GalleryInit>
);
};

View File

@@ -15,6 +15,7 @@ import type PostModel from '@typings/database/models/servers/post';
type ContentProps = {
isReplyPost: boolean;
location: string;
post: PostModel;
theme: Theme;
}
@@ -27,7 +28,7 @@ const contentType: Record<string, string> = {
youtube: 'youtube',
};
const Content = ({isReplyPost, post, theme}: ContentProps) => {
const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
let type: string = post.metadata?.embeds?.[0].type as string;
if (!type && post.props?.attachments?.length) {
type = contentType.app_bindings;
@@ -42,6 +43,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
return (
<ImagePreview
isReplyPost={isReplyPost}
location={location}
metadata={post.metadata!}
postId={post.id}
theme={theme}
@@ -60,6 +62,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
return (
<Opengraph
isReplyPost={isReplyPost}
location={location}
metadata={post.metadata!}
postId={post.id}
removeLinkPreview={post.props?.remove_link_preview === 'true'}
@@ -71,6 +74,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
return (
<MessageAttachments
attachments={post.props.attachments}
location={location}
metadata={post.metadata!}
postId={post.id}
theme={theme}

View File

@@ -5,6 +5,7 @@ import React from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
@@ -68,13 +69,14 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
/>
}
{Boolean(name) &&
<Text
key='author_name'
style={[style.name, Boolean(link) && style.link]}
onPress={openLink}
>
{name}
</Text>
<TouchableOpacity onPress={openLink}>
<Text
key='author_name'
style={[style.name, Boolean(link) && style.link]}
>
{name}
</Text>
</TouchableOpacity>
}
</View>
);

View File

@@ -3,16 +3,20 @@
import React, {useCallback, useRef, useState} from 'react';
import {View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import FileIcon from '@components/post_list/post/body/files/file_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {GalleryInit} from '@context/gallery';
import {useIsTablet} from '@hooks/device';
import {openGallerWithMockFile} from '@utils/gallery';
import {useGalleryItem} from '@hooks/gallery';
import {lookupMimeType} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
@@ -41,13 +45,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
export type Props = {
imageMetadata: PostImage;
imageUrl: string;
location: string;
postId: string;
theme: Theme;
}
const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
const AttachmentImage = ({imageUrl, imageMetadata, location, postId, theme}: Props) => {
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
const [error, setError] = useState(false);
const fileId = useRef(generateId()).current;
const fileId = useRef(generateId('uid')).current;
const isTablet = useIsTablet();
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, isTablet));
const style = getStyleSheet(theme);
@@ -56,9 +62,25 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
setError(true);
}, []);
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageMetadata.height, imageMetadata.width);
}, [imageUrl]);
const onPress = () => {
const item: GalleryItemType = {
id: fileId,
postId,
uri: imageUrl,
width: imageMetadata.width,
height: imageMetadata.height,
name: extractFilenameFromUrl(imageUrl) || 'attachmentImage.png',
mime_type: lookupMimeType(imageUrl) || 'images/png',
type: 'image',
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
};
const {ref, onGestureEvent, styles} = useGalleryItem(
galleryIdentifier,
0,
onPress,
);
if (error || !isValidUrl(imageUrl) || isGifTooLarge(imageMetadata)) {
return (
@@ -73,24 +95,23 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
}
return (
<TouchableWithFeedback
onPress={onPress}
style={[style.container, {width}]}
type={'none'}
>
<View
style={[style.imageContainer, {width, height}]}
>
<ProgressiveImage
id={fileId}
imageStyle={style.attachmentMargin}
imageUri={imageUrl}
onError={onError}
resizeMode='contain'
style={{height, width}}
/>
</View>
</TouchableWithFeedback>
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[styles, style.container, {width}]}>
<TapGestureHandler onGestureEvent={onGestureEvent}>
<Animated.View testID={`attachmentImage-${fileId}`}>
<ProgressiveImage
forwardRef={ref}
id={fileId}
imageStyle={style.attachmentMargin}
imageUri={imageUrl}
onError={onError}
resizeMode='contain'
style={{height, width}}
/>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</GalleryInit>
);
};

View File

@@ -4,6 +4,7 @@
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -59,12 +60,11 @@ const AttachmentTitle = ({link, theme, value}: Props) => {
let title;
if (link) {
title = (
<Text
style={[style.title, Boolean(link) && style.link]}
onPress={openLink}
>
{value}
</Text>
<TouchableOpacity onPress={openLink}>
<Text style={[style.title, Boolean(link) && style.link]}>
{value}
</Text>
</TouchableOpacity>
);
} else {
title = (

View File

@@ -8,8 +8,9 @@ import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachment[];
postId: string;
location: string;
metadata?: PostMetadata;
postId: string;
theme: Theme;
}
@@ -20,7 +21,7 @@ const styles = StyleSheet.create({
},
});
const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => {
const MessageAttachments = ({attachments, location, metadata, postId, theme}: Props) => {
const content = [] as React.ReactNode[];
attachments.forEach((attachment, i) => {
@@ -28,6 +29,7 @@ const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => {
<MessageAttachment
attachment={attachment}
key={'att_' + i.toString()}
location={location}
metadata={metadata}
postId={postId}
theme={theme}

View File

@@ -21,6 +21,7 @@ import AttachmentTitle from './attachment_title';
type Props = {
attachment: MessageAttachment;
location: string;
metadata?: PostMetadata;
postId: string;
theme: Theme;
@@ -50,7 +51,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function MessageAttachment({attachment, metadata, postId, theme}: Props) {
export default function MessageAttachment({attachment, location, metadata, postId, theme}: Props) {
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
@@ -132,6 +133,7 @@ export default function MessageAttachment({attachment, metadata, postId, theme}:
<AttachmentImage
imageUrl={attachment.image_url}
imageMetadata={metadata!.images![attachment.image_url]}
location={location}
postId={postId}
theme={theme}
/>

View File

@@ -7,10 +7,10 @@ import withObservables from '@nozbe/with-observables';
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getPreferenceAsBool} from '@helpers/api/preference';
@@ -25,6 +25,7 @@ import type SystemModel from '@typings/database/models/servers/system';
type OpengraphProps = {
isReplyPost: boolean;
location: string;
metadata: PostMetadata;
postId: string;
showLinkPreviews: boolean;
@@ -70,7 +71,7 @@ const selectOpenGraphData = (url: string, metadata: PostMetadata) => {
})?.data;
};
const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
const intl = useIntl();
const link = metadata.embeds![0]!.url;
const openGraphData = selectOpenGraphData(link, metadata);
@@ -123,10 +124,9 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
if (title) {
siteTitle = (
<View style={style.wrapper}>
<TouchableWithFeedback
<TouchableOpacity
style={style.flex}
onPress={goToLink}
type={'opacity'}
>
<Text
style={[style.siteTitle, {marginRight: isReplyPost ? 10 : 0}]}
@@ -135,7 +135,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
>
{title as string}
</Text>
</TouchableWithFeedback>
</TouchableOpacity>
</View>
);
}
@@ -163,6 +163,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
{hasImage &&
<OpengraphImage
isReplyPost={isReplyPost}
location={location}
openGraphImages={openGraphData.images as never[]}
metadata={metadata}
postId={postId}
@@ -173,7 +174,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
);
};
const withOpenGraphInput = withObservables(
const enhanced = withObservables(
['removeLinkPreview'], ({database, removeLinkPreview}: WithDatabaseArgs & {removeLinkPreview: boolean}) => {
if (removeLinkPreview) {
return {showLinkPreviews: of$(false)};
@@ -198,4 +199,4 @@ const withOpenGraphInput = withObservables(
return {showLinkPreviews};
});
export default withDatabase(withOpenGraphInput(React.memo(Opengraph)));
export default withDatabase(enhanced(React.memo(Opengraph)));

View File

@@ -1,21 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {useWindowDimensions, View} from 'react-native';
import React, {useMemo, useRef} from 'react';
import {useWindowDimensions} from 'react-native';
import FastImage, {Source} from 'react-native-fast-image';
import {TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device as DeviceConstant, View as ViewConstants} from '@constants';
import {openGallerWithMockFile} from '@utils/gallery';
import {GalleryInit} from '@context/gallery';
import {useGalleryItem} from '@hooks/gallery';
import {lookupMimeType} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions} from '@utils/images';
import {BestImage, getNearestPoint} from '@utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
type OpengraphImageProps = {
isReplyPost: boolean;
location: string;
metadata: PostMetadata;
openGraphImages: never[];
postId: string;
@@ -49,14 +54,16 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt
return viewPortWidth - tabletOffset;
};
const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
const fileId = useRef(generateId()).current;
const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
const fileId = useRef(generateId('uid')).current;
const dimensions = useWindowDimensions();
const style = getStyleSheet(theme);
const bestDimensions = {
const galleryIdentifier = `${postId}-OpenGraphImage-${location}`;
const bestDimensions = useMemo(() => ({
height: MAX_IMAGE_HEIGHT,
width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
};
}), [isReplyPost, dimensions]);
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage;
const imageUrl = (bestImage.secure_url || bestImage.url)!;
const imagesMetadata = metadata.images;
@@ -81,30 +88,50 @@ const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}:
imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width));
}
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageDimensions.height, imageDimensions.width, fileId);
}, []);
const onPress = () => {
const item: GalleryItemType = {
id: fileId,
postId,
uri: imageUrl,
width: imageDimensions.width,
height: imageDimensions.height,
name: extractFilenameFromUrl(imageUrl) || 'openGraph.png',
mime_type: lookupMimeType(imageUrl) || 'images/png',
type: 'image',
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
};
const source: Source = {};
if (isValidUrl(imageUrl)) {
source.uri = imageUrl;
}
const {ref, onGestureEvent, styles} = useGalleryItem(
galleryIdentifier,
0,
onPress,
);
const dimensionsStyle = {width: imageDimensions.width, height: imageDimensions.height};
return (
<View style={[style.imageContainer, dimensionsStyle]}>
<TouchableWithFeedback
onPress={onPress}
type={'none'}
>
<FastImage
style={[style.image, dimensionsStyle]}
source={source}
resizeMode='contain'
nativeID={`image-${fileId}`}
/>
</TouchableWithFeedback>
</View>
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[styles, style.imageContainer, dimensionsStyle]}>
<TapGestureHandler onGestureEvent={onGestureEvent}>
<Animated.View testID={`OpenGraphImage-${fileId}`}>
<FastImage
style={[style.image, dimensionsStyle]}
source={source}
// @ts-expect-error legacy ref
ref={ref}
resizeMode='contain'
nativeID={`OpenGraphImage-${fileId}`}
/>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</GalleryInit>
);
};

View File

@@ -5,13 +5,13 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Image, Platform, StatusBar, StyleSheet} from 'react-native';
import {Alert, Image, Platform, StatusBar, StyleSheet, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useIsTablet} from '@hooks/device';
import {emptyFunction} from '@utils/general';
@@ -157,10 +157,9 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
}
return (
<TouchableWithFeedback
<TouchableOpacity
style={[styles.imageContainer, {height: dimensions.height}]}
onPress={playYouTubeVideo}
type={'opacity'}
>
<ProgressiveImage
id={imgUrl}
@@ -170,15 +169,11 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
resizeMode='cover'
onError={emptyFunction}
>
<TouchableWithFeedback
style={styles.playButton}
onPress={playYouTubeVideo}
type={'opacity'}
>
<View style={styles.playButton}>
<Image source={require('@assets/images/icons/youtube-play-icon.png')}/>
</TouchableWithFeedback>
</View>
</ProgressiveImage>
</TouchableWithFeedback>
</TouchableOpacity>
);
};

View File

@@ -4,11 +4,11 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {removePost} from '@actions/local/post';
import CompassIcon from '@components/compass_icon';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
@@ -75,17 +75,16 @@ const Failed = ({post, theme}: FailedProps) => {
}, []);
return (
<TouchableWithFeedback
<TouchableOpacity
onPress={onPress}
style={styles.retry}
type={'opacity'}
>
<CompassIcon
name='information-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableWithFeedback>
</TouchableOpacity>
);
};

View File

@@ -7,16 +7,15 @@ import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Platform, StatusBar, StatusBarStyle, StyleSheet, View} from 'react-native';
import FileViewer from 'react-native-file-viewer';
import {TouchableOpacity} from 'react-native-gesture-handler';
import tinyColor from 'tinycolor2';
import ProgressBar from '@components/progress_bar';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device} from '@constants';
import {DOWNLOAD_TIMEOUT} from '@constants/network';
import {useServerUrl} from '@context/server';
import NetworkManager from '@init/network_manager';
import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
import {getLocalFilePathFromFile} from '@utils/file';
import {fileExists, getLocalFilePathFromFile} from '@utils/file';
import FileIcon from './file_icon';
@@ -33,7 +32,6 @@ type DocumentFileProps = {
theme: Theme;
}
const {DOCUMENTS_PATH} = Device;
const styles = StyleSheet.create({
progress: {
justifyContent: 'flex-end',
@@ -67,20 +65,17 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
};
const downloadAndPreviewFile = async () => {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
setDidCancel(false);
let path;
try {
let exists = false;
if (path) {
const info = await FileSystem.getInfoAsync(path);
exists = info.exists;
}
path = getLocalFilePathFromFile(serverUrl, file);
const exists = await fileExists(path);
if (exists) {
openDocument();
} else {
setDownloading(true);
downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!, {timeoutInterval: DOWNLOAD_TIMEOUT});
downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT});
downloadTask.current?.progress?.(setProgress);
await downloadTask.current;
@@ -126,7 +121,7 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
const openDocument = () => {
if (!didCancel && !preview) {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
const path = getLocalFilePathFromFile(serverUrl, file);
setPreview(true);
setStatusBarColor('dark-content');
FileViewer.open(path!, {
@@ -190,12 +185,9 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
}
return (
<TouchableWithFeedback
onPress={handlePreviewPress}
type={'opacity'}
>
<TouchableOpacity onPress={handlePreviewPress}>
{fileAttachmentComponent}
</TouchableWithFeedback>
</TouchableOpacity>
);
});

View File

@@ -1,11 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import {TapGestureHandler} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isDocument, isImage} from '@utils/file';
import {useGalleryItem} from '@hooks/gallery';
import {isDocument, isImage, isVideo} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DocumentFile, {DocumentFileRef} from './document_file';
@@ -13,17 +16,21 @@ import FileIcon from './file_icon';
import FileInfo from './file_info';
import ImageFile from './image_file';
import ImageFileOverlay from './image_file_overlay';
import VideoFile from './video_file';
type FileProps = {
canDownloadFiles: boolean;
file: FileInfo;
galleryIdentifier: string;
index: number;
inViewPort: boolean;
isSingleImage: boolean;
nonVisibleImagesCount: number;
onPress: (index: number) => void;
publicLinkEnabled: boolean;
theme: Theme;
wrapperWidth?: number;
updateFileForGallery: (idx: number, file: FileInfo) => void;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -46,44 +53,73 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const File = ({
canDownloadFiles, file, index = 0, inViewPort = false, isSingleImage = false,
nonVisibleImagesCount = 0, onPress, theme, wrapperWidth = 300,
canDownloadFiles, file, galleryIdentifier, index, inViewPort = false, isSingleImage = false,
nonVisibleImagesCount = 0, onPress, publicLinkEnabled, theme, wrapperWidth = 300, updateFileForGallery,
}: FileProps) => {
const document = useRef<DocumentFileRef>(null);
const style = getStyleSheet(theme);
const handlePress = () => {
onPress(index);
};
const handlePreviewPress = () => {
const handlePreviewPress = useCallback(() => {
if (document.current) {
document.current.handlePreviewPress();
} else {
handlePress();
onPress(index);
}
};
}, [index]);
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress);
if (isVideo(file) && publicLinkEnabled) {
return (
<TapGestureHandler
onGestureEvent={onGestureEvent}
shouldCancelWhenOutside={true}
>
<Animated.View style={[styles]}>
<VideoFile
file={file}
forwardRef={ref}
inViewPort={inViewPort}
isSingleImage={isSingleImage}
resizeMode={'cover'}
wrapperWidth={wrapperWidth}
updateFileForGallery={updateFileForGallery}
index={index}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
theme={theme}
value={nonVisibleImagesCount}
/>
}
</Animated.View>
</TapGestureHandler>
);
}
if (isImage(file)) {
return (
<TouchableWithFeedback
onPress={handlePreviewPress}
type={'opacity'}
<TapGestureHandler
onGestureEvent={onGestureEvent}
shouldCancelWhenOutside={true}
>
<ImageFile
file={file}
inViewPort={inViewPort}
wrapperWidth={wrapperWidth}
isSingleImage={isSingleImage}
resizeMode={'cover'}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
theme={theme}
value={nonVisibleImagesCount}
/>
}
</TouchableWithFeedback>
<Animated.View style={[styles]}>
<ImageFile
file={file}
forwardRef={ref}
inViewPort={inViewPort}
isSingleImage={isSingleImage}
resizeMode={'cover'}
wrapperWidth={wrapperWidth}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
theme={theme}
value={nonVisibleImagesCount}
/>
}
</Animated.View>
</TapGestureHandler>
);
}

View File

@@ -3,8 +3,8 @@
import React from 'react';
import {Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {getFormattedFileSize} from '@utils/file';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -43,12 +43,8 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
const style = getStyleSheet(theme);
return (
<TouchableWithFeedback
onPress={onPress}
type={'opacity'}
style={style.attachmentContainer}
>
<>
<View style={style.attachmentContainer}>
<TouchableOpacity onPress={onPress}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
@@ -65,8 +61,8 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
{`${getFormattedFileSize(file.size)}`}
</Text>
</View>
</>
</TouchableWithFeedback>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,37 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import Animated, {useDerivedValue} from 'react-native-reanimated';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
import {GalleryInit} from '@context/gallery';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import NetworkManager from '@init/network_manager';
import {isGif, isImage} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {isGif, isImage, isVideo} from '@utils/file';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth} from '@utils/images';
import {preventDoubleTap} from '@utils/tap';
import File from './file';
import type {Client} from '@client/rest';
import type {WithDatabaseArgs} from '@typings/database/database';
import type FileModel from '@typings/database/models/servers/file';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
type FilesProps = {
authorId: string;
canDownloadFiles: boolean;
failed?: boolean;
files: FileModel[];
location: string;
isReplyPost: boolean;
postId: string;
publicLinkEnabled: boolean;
theme: Theme;
}
@@ -53,62 +48,55 @@ const styles = StyleSheet.create({
},
});
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme}: FilesProps) => {
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location, postId, publicLinkEnabled, theme}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const imageAttachments = useRef<FileInfo[]>([]).current;
const nonImageAttachments = useRef<FileInfo[]>([]).current;
const filesInfo: FileInfo[] = useMemo(() => files.map((f) => ({
id: f.id,
user_id: authorId,
post_id: postId,
create_at: 0,
delete_at: 0,
update_at: 0,
name: f.name,
extension: f.extension,
mini_preview: f.imageThumbnail,
size: f.size,
mime_type: f.mimeType,
height: f.height,
has_preview_image: Boolean(f.imageThumbnail),
localPath: f.localPath,
width: f.width,
})), [files]);
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing
}
const filesInfo: FileInfo[] = useMemo(() => files.map((f) => f.toFileInfo(authorId)), [authorId, files]);
if (!imageAttachments.length && !nonImageAttachments.length) {
filesInfo.reduce((info, file) => {
if (isImage(file)) {
const {images: imageAttachments, nonImages: nonImageAttachments} = useMemo(() => {
return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => {
const imageFile = isImage(file);
const videoFile = isVideo(file);
if (imageFile || (videoFile && publicLinkEnabled)) {
let uri;
if (file.localPath) {
uri = file.localPath;
} else {
uri = isGif(file) ? client?.getFileUrl(file.id!, 0) : client?.getFilePreviewUrl(file.id!, 0);
uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!);
}
info.imageAttachments.push({...file, uri});
images.push({...file, uri});
} else {
info.nonImageAttachments.push(file);
}
return info;
}, {imageAttachments, nonImageAttachments});
}
if (videoFile) {
// fallback if public links are not enabled
file.uri = buildFileUrl(serverUrl, file.id!);
}
nonImages.push(file);
}
return {images, nonImages};
}, {images: [], nonImages: []});
}, [files, publicLinkEnabled, serverUrl]);
const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments),
[imageAttachments, nonImageAttachments]);
const filesForGallery = useRef<FileInfo[]>(imageAttachments.concat(nonImageAttachments)).current;
const attachmentIndex = (fileId: string) => {
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
return filesForGallery.value.findIndex((file) => file.id === fileId) || 0;
};
const handlePreviewPress = preventDoubleTap((idx: number) => {
openGalleryAtIndex(idx, filesForGallery);
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, authorId));
openGalleryAtIndex(galleryIdentifier, idx, items);
});
const updateFileForGallery = (idx: number, file: FileInfo) => {
'worklet';
filesForGallery.value[idx] = file;
};
const isSingleImage = () => (files.length === 1 && isImage(files[0]));
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
@@ -125,13 +113,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
if (idx !== 0 && includeGutter) {
container = containerWithGutter;
}
return (
<View
style={container}
key={file.id}
>
<File
galleryIdentifier={galleryIdentifier}
key={file.id}
canDownloadFiles={canDownloadFiles}
file={file}
@@ -140,6 +128,8 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
theme={theme}
isSingleImage={singleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
publicLinkEnabled={publicLinkEnabled}
updateFileForGallery={updateFileForGallery}
wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15}
inViewPort={inViewPort}
/>
@@ -179,30 +169,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
}, []);
return (
<View style={[failed && styles.failed]}>
{renderImageRow()}
{renderItems(nonImageAttachments)}
</View>
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[failed && styles.failed]}>
{renderImageRow()}
{renderItems(nonImageAttachments)}
</Animated.View>
</GalleryInit>
);
};
const withCanDownload = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => {
const enableMobileFileDownload = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')),
);
const complianceDisabled = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')),
);
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
map(([download, compliance]) => compliance || download),
);
return {
authorId: of$(post.userId),
canDownloadFiles,
postId: of$(post.id),
};
});
export default withDatabase(withCanDownload(React.memo(Files)));
export default React.memo(Files);

View File

@@ -1,51 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import {buildFilePreviewUrl, buildFileThumbnailUrl} from '@actions/remote/file';
import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
import {isGif as isGifImage} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileIcon from './file_icon';
import type {Client} from '@client/rest';
import type {ResizeMode} from 'react-native-fast-image';
type ImageFileProps = {
backgroundColor?: string;
file: FileInfo;
forwardRef: React.RefObject<unknown>;
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
wrapperWidth?: number;
}
type ProgressiveImageProps = {
defaultSource?: {uri: string};
imageUri?: string;
inViewPort?: boolean;
thumbnailUri?: string;
}
const SMALL_IMAGE_MAX_HEIGHT = 48;
const SMALL_IMAGE_MAX_WIDTH = 48;
const GRADIENT_COLORS = ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, .32)'];
const GRADIENT_END = {x: 1, y: 1};
const GRADIENT_LOCATIONS = [0.5, 1];
const GRADIENT_START = {x: 0.5, y: 0.5};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
imagePreview: {
...StyleSheet.absoluteFillObject,
boxPlaceholder: {
paddingBottom: '100%',
},
fileImageWrapper: {
borderRadius: 5,
overflow: 'hidden',
},
boxPlaceholder: {
paddingBottom: '100%',
},
failed: {
justifyContent: 'center',
alignItems: 'center',
@@ -53,6 +50,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
borderRadius: 4,
borderWidth: 1,
},
gifContainer: {
alignItems: 'flex-end',
justifyContent: 'flex-end',
padding: 8,
...StyleSheet.absoluteFillObject,
},
imagePreview: {
...StyleSheet.absoluteFillObject,
},
smallImageBorder: {
borderRadius: 5,
},
@@ -70,21 +76,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
const ImageFile = ({
backgroundColor, file, inViewPort, isSingleImage,
backgroundColor, file, forwardRef, inViewPort, isSingleImage,
resizeMode = 'cover', wrapperWidth,
}: ImageFileProps) => {
const serverUrl = useServerUrl();
const [failed, setFailed] = useState(false);
const dimensions = useWindowDimensions();
const theme = useTheme();
const serverUrl = useServerUrl();
const [isGif, setIsGif] = useState(isGifImage(file));
const [failed, setFailed] = useState(false);
const style = getStyleSheet(theme);
let image;
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing
}
const getImageDimensions = () => {
if (isSingleImage) {
@@ -108,14 +109,18 @@ const ImageFile = ({
if (file.mini_preview && file.mime_type) {
props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
} else {
props.thumbnailUri = client?.getFileThumbnailUrl(file.id, 0);
props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id);
}
props.imageUri = client?.getFilePreviewUrl(file.id, 0);
props.imageUri = buildFilePreviewUrl(serverUrl, file.id);
props.inViewPort = inViewPort;
}
return props;
};
useEffect(() => {
setIsGif(isGifImage(file));
}, [file]);
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
let wrapperStyle: StyleProp<ViewStyle> = style.fileImageWrapper;
if (isSingleImage) {
@@ -129,6 +134,7 @@ const ImageFile = ({
image = (
<ProgressiveImage
id={file.id!}
forwardRef={forwardRef}
style={{height: file.height, width: file.width}}
tintDefaultSource={!file.localPath && !failed}
onError={handleError}
@@ -170,6 +176,7 @@ const ImageFile = ({
image = (
<ProgressiveImage
id={file.id!}
forwardRef={forwardRef}
style={[isSingleImage ? null : style.imagePreview, imageDimensions]}
tintDefaultSource={!file.localPath && !failed}
onError={handleError}
@@ -190,12 +197,34 @@ const ImageFile = ({
);
}
let gifIndicator;
if (isGif) {
gifIndicator = (
<View style={StyleSheet.absoluteFill}>
<LinearGradient
start={GRADIENT_START}
end={GRADIENT_END}
locations={GRADIENT_LOCATIONS}
colors={GRADIENT_COLORS}
style={[style.imagePreview, {...imageDimensions}]}
/>
<View style={[style.gifContainer, {...imageDimensions}]}>
<CompassIcon
name='file-gif'
color='#FFF'
size={24}
/>
</View>
</View>
);
}
return (
<View
style={style.fileImageWrapper}
>
{!isSingleImage && <View style={style.boxPlaceholder}/>}
{image}
{gifIndicator}
</View>
);
};

View File

@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useMemo} from 'react';
import {PixelRatio, StyleSheet, Text, useWindowDimensions, View} from 'react-native';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
type ImageFileOverlayProps = {
@@ -11,37 +12,36 @@ type ImageFileOverlayProps = {
value: number;
}
const getStyleSheet = (scale: number, th: Theme) => {
const style = makeStyleSheetFromTheme((theme: Theme) => {
return {
moreImagesWrapper: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 5,
},
moreImagesText: {
color: theme.sidebarHeaderTextColor,
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
fontFamily: 'OpenSans',
textAlign: 'center',
},
};
});
return style(th);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
moreImagesWrapper: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 5,
},
moreImagesText: {
color: theme.sidebarHeaderTextColor,
fontFamily: 'OpenSans',
textAlign: 'center',
},
}));
const ImageFileOverlay = ({theme, value}: ImageFileOverlayProps) => {
const dimensions = useWindowDimensions();
const scale = dimensions.width / 320;
const style = getStyleSheet(scale, theme);
return null;
const isTablet = useIsTablet();
const style = getStyleSheet(theme);
const textStyles = useMemo(() => {
const scale = isTablet ? dimensions.scale : 1;
return [
style.moreImagesText,
{fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale))},
];
}, [isTablet]);
return (
<View style={style.moreImagesWrapper}>
<Text style={style.moreImagesText}>
<Text style={textStyles}>
{`+${value}`}
</Text>
</View>

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import Files from './files';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
const enhance = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => {
const config = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const enableMobileFileDownload = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')),
);
const publicLinkEnabled = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.EnablePublicLink !== 'false')),
);
const complianceDisabled = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')),
);
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
map(([download, compliance]) => compliance || download),
);
return {
authorId: of$(post.userId),
canDownloadFiles,
postId: of$(post.id),
publicLinkEnabled,
};
});
export default withDatabase(enhance(Files));

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getThumbnailAsync} from 'expo-video-thumbnails';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, useWindowDimensions, View} from 'react-native';
import {updateLocalFile} from '@actions/local/file';
import {buildFilePreviewUrl, fetchPublicLink} from '@actions/remote/file';
import CompassIcon from '@components/compass_icon';
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {fileExists} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileIcon from './file_icon';
import type {ResizeMode} from 'react-native-fast-image';
type Props = {
index: number;
file: FileInfo;
forwardRef: React.RefObject<unknown>;
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
wrapperWidth?: number;
updateFileForGallery: (idx: number, file: FileInfo) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
imagePreview: {
...StyleSheet.absoluteFillObject,
},
fileImageWrapper: {
borderRadius: 5,
overflow: 'hidden',
},
boxPlaceholder: {
paddingBottom: '100%',
},
failed: {
justifyContent: 'center',
alignItems: 'center',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
},
playContainer: {
alignItems: 'center',
justifyContent: 'center',
...StyleSheet.absoluteFillObject,
},
play: {
backgroundColor: changeOpacity('#000', 0.16),
borderRadius: 20,
},
}));
const VideoFile = ({
index, file, forwardRef, inViewPort, isSingleImage,
resizeMode = 'cover', wrapperWidth, updateFileForGallery,
}: Props) => {
const serverUrl = useServerUrl();
const [failed, setFailed] = useState(false);
const dimensions = useWindowDimensions();
const theme = useTheme();
const style = getStyleSheet(theme);
const mounted = useRef(false);
const [video, setVideo] = useState({...file});
const imageDimensions = useMemo(() => {
if (isSingleImage) {
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
return calculateDimensions(video.height, video.width, wrapperWidth, viewPortHeight);
}
return undefined;
}, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth]);
const getThumbnail = async () => {
const data = {...file};
try {
const exists = data.mini_preview ? await fileExists(data.mini_preview) : false;
if (!data.mini_preview || !exists) {
// We use the public link to avoid having to pass the token through a third party
// library
const publicUri = await fetchPublicLink(serverUrl, data.id!);
if (('link') in publicUri) {
const {uri, height, width} = await getThumbnailAsync(publicUri.link, {time: 2000});
data.mini_preview = uri;
data.height = height;
data.width = width;
updateLocalFile(serverUrl, data);
if (mounted.current) {
setVideo(data);
}
}
}
} catch (error) {
data.mini_preview = buildFilePreviewUrl(serverUrl, data.id!);
if (mounted.current) {
setVideo(data);
}
} finally {
const {width: tw, height: th} = calculateDimensions(
data.height,
data.width,
dimensions.width - 60, // size of the gallery header probably best to set that as a constant
dimensions.height,
);
data.height = th;
data.width = tw;
updateFileForGallery(index, data);
}
};
const handleError = useCallback(() => {
setFailed(true);
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (inViewPort) {
getThumbnail();
}
}, [file, inViewPort]);
const imageProps = () => {
const props: ProgressiveImageProps = {
imageUri: video.mini_preview,
inViewPort,
};
return props;
};
let thumbnail = (
<ProgressiveImage
id={file.id!}
forwardRef={forwardRef}
style={[isSingleImage ? null : style.imagePreview, imageDimensions]}
onError={handleError}
resizeMode={resizeMode}
{...imageProps()}
/>
);
if (failed) {
thumbnail = (
<View style={[isSingleImage ? null : style.imagePreview, style.failed, imageDimensions]}>
<FileIcon
failed={failed}
file={file}
/>
</View>
);
}
return (
<View
style={style.fileImageWrapper}
>
{!isSingleImage && <View style={style.boxPlaceholder}/>}
{thumbnail}
<View style={style.playContainer}>
<View style={style.play}>
<CompassIcon
color={changeOpacity('#fff', 0.8)}
name='play'
size={40}
/>
</View>
</View>
</View>
);
};
export default VideoFile;

View File

@@ -151,6 +151,7 @@ const Body = ({
{hasContent &&
<Content
isReplyPost={isReplyPost}
location={location}
post={post}
theme={theme}
/>
@@ -159,6 +160,7 @@ const Body = ({
<Files
failed={post.props?.failed}
files={files}
location={location}
post={post}
isReplyPost={isReplyPost}
theme={theme}

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native';
import {LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native';
import Animated from 'react-native-reanimated';
import Markdown from '@components/markdown';
@@ -87,6 +87,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo
isEdited={isEdited}
isReplyPost={isReplyPost}
isSearchResult={location === SEARCH}
location={location}
postId={post.id}
textStyles={textStyles}
value={post.message}

View File

@@ -3,10 +3,10 @@
import React from 'react';
import {View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import LinearGradient from 'react-native-linear-gradient';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type ShowMoreButtonProps = {
@@ -100,10 +100,9 @@ const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreBu
}
<View style={style.container}>
<View style={style.dividerLeft}/>
<TouchableWithFeedback
<TouchableOpacity
onPress={onPress}
style={style.buttonContainer}
type={'opacity'}
>
<View
style={style.button}
@@ -115,7 +114,7 @@ const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreBu
style={style.sign}
/>
</View>
</TouchableWithFeedback>
</TouchableOpacity>
<View style={style.dividerRight}/>
</View>
</View>

View File

@@ -3,9 +3,9 @@
import React, {useCallback} from 'react';
import {Platform, Text} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type ReactionProps = {
@@ -49,12 +49,11 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
}, [highlight]);
return (
<TouchableWithFeedback
<TouchableOpacity
onPress={handlePress}
onLongPress={onLongPress}
delayLongPress={350}
style={[styles.reaction, (highlight && styles.highlight)]}
type={'opacity'}
>
<Emoji
emojiName={emojiName}
@@ -69,7 +68,7 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
>
{count}
</Text>
</TouchableWithFeedback>
</TouchableOpacity>
);
};

View File

@@ -4,10 +4,10 @@
import React, {useRef} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {addReaction, removeReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
@@ -131,18 +131,17 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
const {reactionsByName, highlightedReactions} = buildReactionsMap();
if (!disabled && canAddReaction && reactionsByName.size < MAX_ALLOWED_REACTIONS) {
addMoreReactions = (
<TouchableWithFeedback
<TouchableOpacity
key='addReaction'
onPress={handleAddReaction}
style={[styles.reaction]}
type={'opacity'}
style={styles.reaction}
>
<CompassIcon
name='emoticon-plus-outline'
size={24}
style={styles.addReaction}
/>
</TouchableWithFeedback>
</TouchableOpacity>
);
}

View File

@@ -4,10 +4,10 @@
import React, {useCallback, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Text, useWindowDimensions, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -114,20 +114,18 @@ const HeaderDisplayName = ({
);
} else if (displayName) {
return (
<TouchableWithFeedback
onPress={onPress}
style={displayNameStyle}
type={'opacity'}
>
<Text
style={style.displayName}
ellipsizeMode={'tail'}
numberOfLines={1}
testID='post_header.display_name'
>
{displayName}
</Text>
</TouchableWithFeedback>
<View style={displayNameStyle}>
<TouchableOpacity onPress={onPress}>
<Text
style={style.displayName}
ellipsizeMode={'tail'}
numberOfLines={1}
testID='post_header.display_name'
>
{displayName}
</Text>
</TouchableOpacity>
</View>
);
}

View File

@@ -3,9 +3,9 @@
import React, {useCallback} from 'react';
import {Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {SEARCH} from '@constants/screens';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
@@ -57,10 +57,9 @@ const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) =>
testID='post_header.reply'
style={style.replyWrapper}
>
<TouchableWithFeedback
<TouchableOpacity
onPress={onPress}
style={style.replyIconContainer}
type={'opacity'}
>
<CompassIcon
name='reply-outline'
@@ -75,7 +74,7 @@ const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) =>
{commentCount}
</Text>
}
</TouchableWithFeedback>
</TouchableOpacity>
</View>
);
};

View File

@@ -4,12 +4,12 @@
import React, {ReactNode, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native';
import {TouchableHighlight} from 'react-native-gesture-handler';
import {showPermalink} from '@actions/local/permalink';
import {removePost} from '@actions/local/post';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@@ -64,7 +64,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
consecutivePostContainer: {
marginBottom: 10,
marginRight: 10,
marginLeft: Platform.select({ios: 35, android: 34}),
marginLeft: Platform.select({ios: 34, android: 33}),
marginTop: 10,
},
container: {flexDirection: 'row'},
@@ -270,13 +270,11 @@ const Post = ({
testID={testID}
style={[styles.postStyle, style, highlightedStyle]}
>
<TouchableWithFeedback
<TouchableHighlight
testID={itemTestID}
onPress={handlePress}
onLongPress={showPostOptions}
delayLongPress={200}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<>
<PreHeader
@@ -294,7 +292,7 @@ const Post = ({
</View>
</View>
</>
</TouchableWithFeedback>
</TouchableHighlight>
</View>
);
};

View File

@@ -12,43 +12,61 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] =
]
}
>
<Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
Object {
"opacity": 1,
}
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
updated the channel display name from: old displayname to: new displayname
</Text>
}
testID="markdown_text"
>
updated the channel display name from: old displayname to: new displayname
</Text>
</View>
`;
@@ -65,43 +83,61 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
]
}
>
<Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
Object {
"opacity": 1,
}
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
updated the channel header from: old header to: new header
</Text>
}
testID="markdown_text"
>
updated the channel header from: old header to: new header
</Text>
</View>
`;
@@ -134,43 +170,61 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1
]
}
>
<Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
Object {
"opacity": 1,
}
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
joined the channel as a guest.
</Text>
}
testID="markdown_text"
>
joined the channel as a guest.
</Text>
</View>
`;
@@ -187,66 +241,104 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@other.user
</Text>
</Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"opacity": 1,
}
}
testID="markdown_text"
>
added to the channel as a guest by
</Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
@username.
<Text
style={Array []}
>
@other.user
</Text>
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
>
added to the channel as a guest by
</Text>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"opacity": 1,
}
}
>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username.
</Text>
</Text>
</View>
</RNGestureHandlerButton>
</View>
`;
@@ -262,21 +354,19 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us
]
}
>
<Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
archived the channel
</Text>
}
testID="markdown_text"
>
archived the channel
</Text>
</View>
`;
@@ -293,43 +383,61 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
]
}
>
<Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
Object {
"opacity": 1,
}
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
archived the channel
</Text>
}
testID="markdown_text"
>
archived the channel
</Text>
</View>
`;
@@ -346,43 +454,61 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
]
}
>
<Text>
<Text
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
Object {
"opacity": 1,
}
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
</View>
</RNGestureHandlerButton>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
testID="markdown_text"
>
unarchived the channel
</Text>
}
testID="markdown_text"
>
unarchived the channel
</Text>
</View>
`;

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement, useCallback, useEffect, useRef} from 'react';
import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native';
import CombinedUserActivity from '@components/post_list/combined_user_activity';
import DateSeparator from '@components/post_list/date_separator';
import NewMessagesLine from '@components/post_list/new_message_line';
import Post from '@components/post_list/post';
import {useTheme} from '@context/theme';
import {emptyFunction} from '@utils/general';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
import type PostModel from '@typings/database/models/servers/post';
type RefreshProps = {
children: ReactElement;
enabled: boolean;
onRefresh: () => void;
refreshing: boolean;
}
type Props = {
channelId: string;
contentContainerStyle?: StyleProp<ViewStyle>;
currentTimezone: string | null;
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
footer?: ReactElement;
testID: string;
}
type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
const style = StyleSheet.create({
container: {
flex: 1,
scaleY: -1,
},
scale: {
...Platform.select({
android: {
scaleY: -1,
},
}),
},
});
export const VIEWABILITY_CONFIG = {
itemVisiblePercentThreshold: 1,
minimumViewTime: 100,
};
const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id);
const styles = StyleSheet.create({
flex: {
flex: 1,
},
content: {
marginHorizontal: 20,
},
});
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => {
const props = {
onRefresh,
refreshing,
};
if (Platform.OS === 'android') {
return (
<RefreshControl
{...props}
enabled={enabled}
style={style.container}
>
{children}
</RefreshControl>
);
}
const refreshControl = <RefreshControl {...props}/>;
return React.cloneElement(
children,
{refreshControl, inverted: true},
);
};
const PostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, footer, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => {
const listRef = useRef<FlatList>(null);
const theme = useTheme();
const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false);
useEffect(() => {
listRef.current?.scrollToOffset({offset: 0, animated: false});
}, [channelId, listRef.current]);
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
if (!viewableItems.length) {
return;
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[item.id] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit('scrolled', viewableItemsMap);
}, []);
const renderItem = useCallback(({item, index}) => {
if (typeof item === 'string') {
if (isStartOfNewMessages(item)) {
// postIds includes a date item after the new message indicator so 2
// needs to be added to the index for the length check to be correct.
const moreNewMessages = orderedPosts.length === index + 2;
// The date line and new message line each count for a line. So the
// goal of this is to check for the 3rd previous, which for the start
// of a thread would be null as it doesn't exist.
const checkForPostId = index < orderedPosts.length - 3;
return (
<NewMessagesLine
theme={theme}
moreMessages={moreNewMessages && checkForPostId}
testID={`${testID}.new_messages_line`}
style={style.scale}
/>
);
} else if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
style={style.scale}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
if (isCombinedUserActivityPost(item)) {
const postProps = {
currentUsername,
postId: item,
style: Platform.OS === 'ios' ? style.scale : style.container,
testID: `${testID}.combined_user_activity`,
showJoinLeave: shouldShowJoinLeaveMessages,
theme,
};
return (<CombinedUserActivity {...postProps}/>);
}
}
let previousPost: PostModel|undefined;
let nextPost: PostModel|undefined;
if (index < posts.length - 1) {
const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string');
if (prev) {
previousPost = prev as PostModel;
}
}
if (index > 0) {
const next = orderedPosts.slice(0, index);
for (let i = next.length - 1; i >= 0; i--) {
const v = next[i];
if (typeof v !== 'string') {
nextPost = v;
break;
}
}
}
const postProps = {
highlightPinnedOrFlagged: true,
location: 'Channel',
nextPost,
previousPost,
shouldRenderReplyButton: true,
};
return (
<Post
key={item.id}
post={item}
style={style.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [orderedPosts, theme]);
return (
<PostListRefreshControl
enabled={false}
refreshing={false}
onRefresh={emptyFunction}
>
<FlatList
contentContainerStyle={[styles.content, contentContainerStyle]}
data={orderedPosts}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
keyExtractor={keyExtractor}
initialNumToRender={10}
ListFooterComponent={footer}
maxToRenderPerBatch={10}
onViewableItemsChanged={onViewableItemsChanged}
ref={listRef}
renderItem={renderItem}
removeClippedSubviews={true}
scrollEventThrottle={60}
style={styles.flex}
viewabilityConfig={VIEWABILITY_CONFIG}
/>
</PostListRefreshControl>
);
};
export default PostList;

View File

@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode, useEffect, useRef, useState} from 'react';
import {Animated, ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import FastImage, {ImageStyle, ResizeMode, Source} from 'react-native-fast-image';
import React, {ReactNode, useEffect, useState} from 'react';
import {ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import FastImage, {ImageStyle, ResizeMode} from 'react-native-fast-image';
import Animated, {interpolate, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -11,20 +12,19 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Thumbnail from './thumbnail';
const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
type ProgressiveImageProps = {
type Props = ProgressiveImageProps & {
children?: ReactNode | ReactNode[];
defaultSource?: Source; // this should be provided by the component
forwardRef?: React.RefObject<any>;
id: string;
imageStyle?: StyleProp<ImageStyle>;
imageUri?: string;
inViewPort?: boolean;
isBackgroundImage?: boolean;
onError: () => void;
resizeMode?: ResizeMode;
style?: StyleProp<ViewStyle>;
thumbnailUri?: string;
tintDefaultSource?: boolean;
};
@@ -43,22 +43,34 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
});
const ProgressiveImage = ({
children, defaultSource, id, imageStyle, imageUri, inViewPort, isBackgroundImage, onError, resizeMode = 'contain',
style = {}, thumbnailUri, tintDefaultSource,
}: ProgressiveImageProps) => {
const intensity = useRef(new Animated.Value(0)).current;
children, defaultSource, forwardRef, id, imageStyle, imageUri, inViewPort, isBackgroundImage,
onError, resizeMode = 'contain', style = {}, thumbnailUri, tintDefaultSource,
}: Props) => {
const [showHighResImage, setShowHighResImage] = useState(false);
const theme = useTheme();
const styles = getStyleSheet(theme);
const intensity = useSharedValue(0);
const defaultOpacity = useDerivedValue(() => (
interpolate(
intensity.value,
[0, 100],
[0.5, 0],
)
), []);
const onLoadImageEnd = () => {
Animated.timing(intensity, {
duration: 300,
toValue: 100,
useNativeDriver: true,
}).start();
intensity.value = withTiming(100, {duration: 300});
};
const animatedOpacity = useAnimatedStyle(() => ({
opacity: interpolate(
intensity.value,
[200, 100],
[0.2, 1],
),
}));
useEffect(() => {
if (inViewPort) {
setShowHighResImage(true);
@@ -84,6 +96,7 @@ const ProgressiveImage = ({
return (
<View style={[styles.defaultImageContainer, style]}>
<AnimatedFastImage
ref={forwardRef}
source={defaultSource}
style={[
StyleSheet.absoluteFill,
@@ -98,20 +111,14 @@ const ProgressiveImage = ({
);
}
const opacity = intensity.interpolate({
inputRange: [20, 100],
outputRange: [0.2, 1],
});
const defaultOpacity = intensity.interpolate({inputRange: [0, 100], outputRange: [0.5, 0]});
const containerStyle = {backgroundColor: changeOpacity(theme.centerChannelColor, Number(defaultOpacity))};
const containerStyle = {backgroundColor: changeOpacity(theme.centerChannelColor, Number(defaultOpacity.value))};
let image;
if (thumbnailUri) {
if (showHighResImage && imageUri) {
image = (
<AnimatedFastImage
ref={forwardRef}
nativeID={`image-${id}`}
resizeMode={resizeMode}
onError={onError}
@@ -119,7 +126,7 @@ const ProgressiveImage = ({
style={[
StyleSheet.absoluteFill,
imageStyle,
{opacity},
animatedOpacity,
]}
testID='progressive_image.highResImage'
onLoadEnd={onLoadImageEnd}
@@ -129,11 +136,12 @@ const ProgressiveImage = ({
} else if (imageUri) {
image = (
<AnimatedFastImage
ref={forwardRef}
nativeID={`image-${id}`}
resizeMode={resizeMode}
onError={onError}
source={{uri: imageUri}}
style={[StyleSheet.absoluteFill, imageStyle, {opacity}]}
style={[StyleSheet.absoluteFill, imageStyle, animatedOpacity]}
onLoadEnd={onLoadImageEnd}
testID='progressive_image.highResImage'
/>

View File

@@ -2,14 +2,16 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Animated, StyleProp, StyleSheet} from 'react-native';
import {StyleProp, StyleSheet} from 'react-native';
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
import Animated, {SharedValue} from 'react-native-reanimated';
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
type ThumbnailProps = {
onError: () => void;
opacity?: number | Animated.AnimatedInterpolation | Animated.AnimatedValue;
opacity?: SharedValue<number>;
source?: Source;
style: StyleProp<ImageStyle>;
}
@@ -34,7 +36,7 @@ const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => {
resizeMode='contain'
onError={onError}
source={require('@assets/images/thumb.png')}
style={[style, {opacity}]}
style={[style, {opacity: opacity?.value}]}
testID='progressive_image.thumbnail'
tintColor={tintColor}
/>

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {StyleProp, Text, useWindowDimensions, View, ViewStyle} from 'react-native';
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {typography} from '@utils/typography';
type ToastProps = {
animatedStyle: AnimatedStyleProp<ViewStyle>;
children?: React.ReactNode;
iconName?: string;
message?: string;
style: StyleProp<ViewStyle>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
center: {
alignItems: 'center',
width: '100%',
opacity: 0,
},
container: {
alignItems: 'center',
backgroundColor: theme.onlineIndicator,
borderRadius: 8,
elevation: 6,
flex: 1,
flexDirection: 'row',
height: 56,
paddingLeft: 20,
paddingRight: 10,
shadowColor: changeOpacity('#000', 0.12),
shadowOffset: {width: 0, height: 4},
shadowRadius: 6,
},
flex: {flex: 1},
text: {
color: theme.buttonColor,
marginLeft: 10,
...typography('Body', 100, 'SemiBold'),
},
}));
const Toast = ({animatedStyle, children, style, iconName, message}: ToastProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dim = useWindowDimensions();
const containerStyle = useMemo(() => {
const totalMargin = 40;
const width = Math.min(dim.height, dim.width, 400) - totalMargin;
return [styles.container, {width}, style];
}, [dim, styles.container, style]);
return (
<Animated.View style={[styles.center, animatedStyle]}>
<Animated.View style={containerStyle}>
{Boolean(iconName) &&
<CompassIcon
color={theme.buttonColor}
name={iconName!}
size={18}
/>
}
{Boolean(message) &&
<View style={styles.flex}>
<Text style={styles.text}>{message}</Text>
</View>
}
{children}
</Animated.View>
</Animated.View>
);
};
export default Toast;

View File

@@ -8,6 +8,8 @@ export default keyMirror({
CHANNEL_DELETED: null,
CLOSE_BOTTOM_SHEET: null,
CONFIG_CHANGED: null,
FREEZE_SCREEN: null,
GALLERY_ACTIONS: null,
LEAVE_CHANNEL: null,
LEAVE_TEAM: null,
LOADING_CHANNEL_POSTS: null,

5
app/constants/gallery.ts Normal file
View File

@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const GALLERY_FOOTER_HEIGHT = 75;
export const VIDEO_INSET = 100;

View File

@@ -16,6 +16,7 @@ export const CUSTOM_STATUS = 'CustomStatus';
export const EDIT_PROFILE = 'EditProfile';
export const EDIT_SERVER = 'EditServer';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const GALLERY = 'Gallery';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const IN_APP_NOTIFICATION = 'InAppNotification';
@@ -47,6 +48,7 @@ export default {
EDIT_PROFILE,
EDIT_SERVER,
FORGOT_PASSWORD,
GALLERY,
HOME,
INTEGRATION_SELECTOR,
IN_APP_NOTIFICATION,

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useLayoutEffect} from 'react';
import Animated, {makeMutable, runOnUI} from 'react-native-reanimated';
export interface GalleryManagerItem {
index: number;
ref: React.RefObject<unknown>;
}
export interface GalleryManagerItems {
[index: number]: GalleryManagerItem;
}
interface GalleryInitProps {
children: JSX.Element;
galleryIdentifier: string;
}
class Gallery {
private init = false;
private timeout: NodeJS.Timeout | null = null;
public refsByIndexSV: Animated.SharedValue<GalleryManagerItems> = makeMutable({});
public sharedValues: GalleryManagerSharedValues = {
width: makeMutable(0),
height: makeMutable(0),
x: makeMutable(0),
y: makeMutable(0),
opacity: makeMutable(1),
activeIndex: makeMutable(0),
targetWidth: makeMutable(0),
targetHeight: makeMutable(0),
};
public items = new Map<number, GalleryManagerItem>();
public get isInitialized() {
return this.init;
}
public resolveItem(index: number) {
return this.items.get(index);
}
public initialize() {
this.init = true;
}
public reset() {
this.init = false;
this.items.clear();
this.refsByIndexSV.value = {};
}
public resetSharedValues() {
const {
width,
height,
opacity,
activeIndex,
x,
y,
} = this.sharedValues;
runOnUI(() => {
'worklet';
width.value = 0;
height.value = 0;
opacity.value = 1;
activeIndex.value = -1;
x.value = 0;
y.value = 0;
})();
}
public registerItem(index: number, ref: React.RefObject<unknown>) {
if (this.items.has(index)) {
return;
}
this.addItem(index, ref);
}
private addItem(index: number, ref: GalleryManagerItem['ref']) {
this.items.set(index, {
index,
ref,
});
if (this.timeout !== null) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.refsByIndexSV.value = this.convertMapToObject(this.items);
this.timeout = null;
}, 16);
}
private convertMapToObject<T extends Map<string | number, GalleryManagerItem>>(map: T) {
const obj: Record<string, GalleryManagerItem> = {};
for (const [key, value] of map) {
obj[key] = value;
}
return obj;
}
}
class GalleryManager {
private galleries: Record<string, Gallery> = {};
public get(identifier: string): Gallery {
if (this.galleries[identifier]) {
return this.galleries[identifier];
}
const gallery = new Gallery();
this.galleries[identifier] = gallery;
return gallery;
}
public remove(identifier: string): boolean {
return delete this.galleries[identifier];
}
}
const galleryManager = new GalleryManager();
export function useGallery(galleryIdentifier: string) {
const gallery = galleryManager.get(galleryIdentifier);
if (!gallery) {
throw new Error(
'Cannot retrieve gallery manager from the context. Did you forget to wrap the app with GalleryProvider?',
);
}
return gallery;
}
export function GalleryInit({children, galleryIdentifier}: GalleryInitProps) {
const gallery = useGallery(galleryIdentifier);
useLayoutEffect(() => {
gallery.initialize();
return () => {
gallery.reset();
};
}, []);
useEffect(() => {
return () => {
galleryManager.remove(galleryIdentifier);
};
}, []);
return children;
}

View File

@@ -54,4 +54,19 @@ export default class FileModel extends Model {
/** post : The related Post record for this file */
@immutableRelation(POST, 'post_id') post!: Relation<PostModel>;
toFileInfo = (authorId: string): FileInfo => ({
id: this.id,
user_id: authorId,
post_id: this.postId,
name: this.name,
extension: this.extension,
mini_preview: this.imageThumbnail,
size: this.size,
mime_type: this.mimeType,
height: this.height,
has_preview_image: Boolean(this.imageThumbnail),
localPath: this.localPath,
width: this.width,
});
}

View File

@@ -36,7 +36,7 @@ export const transformChannelRecord = ({action, database, value}: TransformerArg
channel.createAt = raw.create_at;
channel.creatorId = raw.creator_id;
channel.deleteAt = raw.delete_at;
channel.displayName = raw.display_name;
channel.displayName = raw.display_name || record?.displayName || '';
channel.isGroupConstrained = Boolean(raw.group_constrained);
channel.name = raw.name;
channel.shared = Boolean(raw.shared);

View File

@@ -109,9 +109,9 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs):
file.extension = raw.extension;
file.size = raw.size;
file.mimeType = raw?.mime_type ?? '';
file.width = raw?.width ?? 0;
file.height = raw?.height ?? 0;
file.imageThumbnail = raw?.mini_preview ?? '';
file.width = raw?.width || record?.width || 0;
file.height = raw?.height || record?.height || 0;
file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || '';
file.localPath = raw?.localPath ?? '';
};

28
app/hooks/freeze.ts Normal file
View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {DeviceEventEmitter, Platform} from 'react-native';
import {Events} from '@constants';
const useFreeze = () => {
const [freeze, setFreeze] = useState(false);
const [backgroundColor, setBackgroundColor] = useState('#000');
useEffect(() => {
const freezeListener = DeviceEventEmitter.addListener(Events.FREEZE_SCREEN, (shouldFreeze: boolean, color = '#000') => {
// kept until this https://github.com/software-mansion/react-freeze/issues/7 is fixed
if (Platform.OS === 'ios') {
setFreeze(shouldFreeze);
setBackgroundColor(color);
}
});
return () => freezeListener.remove();
});
return {freeze, backgroundColor};
};
export default useFreeze;

257
app/hooks/gallery.ts Normal file
View File

@@ -0,0 +1,257 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef} from 'react';
import {Platform} from 'react-native';
import {
Easing, makeRemote, runOnJS, useAnimatedRef, useAnimatedStyle, useEvent,
useSharedValue,
withTiming, WithTimingConfig,
} from 'react-native-reanimated';
import {useGallery} from '@context/gallery';
import {measureItem} from '@utils/gallery';
import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler';
function useRemoteContext<T extends object>(initialValue: T) {
const initRef = useRef<{ context: T } | null>(null);
if (initRef.current === null) {
initRef.current = {
context: makeRemote(initialValue ?? {}),
};
}
const {context} = initRef.current;
return context;
}
function diff(context: any, name: string, value: any) {
'worklet';
if (!context.___diffs) {
context.___diffs = {};
}
if (!context.___diffs[name]) {
context.___diffs[name] = {
stash: 0,
prev: null,
};
}
const d = context.___diffs[name];
d.stash = d.prev === null ? 0 : value - d.prev;
d.prev = value;
return d.stash;
}
export function useCreateAnimatedGestureHandler<T extends GestureHandlerGestureEvent, TContext extends Context>(handlers: GestureHandlers<T['nativeEvent'], TContext>) {
const context = useRemoteContext<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;
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<(event: T['nativeEvent']) => void, OnGestureEvent<T>>(
handler, ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], false,
);
}
export function useGalleryControls() {
const controlsHidden = useSharedValue(false);
const translateYConfig: WithTimingConfig = {
duration: 400,
easing: Easing.bezier(0.33, 0.01, 0, 1),
};
const headerStyles = useAnimatedStyle(() => ({
opacity: controlsHidden.value ? withTiming(0) : withTiming(1),
transform: [
{
translateY: controlsHidden.value ? withTiming(-100, translateYConfig) : withTiming(0, translateYConfig),
},
],
position: 'absolute',
top: 0,
width: '100%',
zIndex: 1,
}));
const footerStyles = useAnimatedStyle(() => ({
opacity: controlsHidden.value ? withTiming(0) : withTiming(1),
transform: [
{
translateY: controlsHidden.value ? withTiming(100, translateYConfig) : withTiming(0, translateYConfig),
},
],
position: 'absolute',
bottom: 0,
width: '100%',
zIndex: 1,
}));
const setControlsHidden = useCallback((hidden: boolean) => {
'worklet';
if (controlsHidden.value === hidden) {
return;
}
controlsHidden.value = hidden;
}, []);
return {
controlsHidden,
headerStyles,
footerStyles,
setControlsHidden,
};
}
export function useGalleryItem(
identifier: string,
index: number,
onPress: (identifier: string, itemIndex: number) => void,
) {
const gallery = useGallery(identifier);
const ref = useAnimatedRef<any>();
const {opacity, activeIndex} = gallery!.sharedValues;
const styles = useAnimatedStyle(() => {
return {
opacity: activeIndex.value === index ? opacity.value : 1,
};
}, []);
useEffect(() => {
gallery.registerItem(index, ref);
}, []);
const onGestureEvent = useAnimatedGestureHandler({
onFinish: (_evt, _ctx, isCanceledOrFailed) => {
if (isCanceledOrFailed) {
return;
}
activeIndex.value = index;
// measure the images
// width/height and position to animate from it to the full screen one
measureItem(ref, gallery.sharedValues);
runOnJS(onPress)(identifier, index);
},
});
return {
ref,
styles,
onGestureEvent,
};
}

View File

@@ -7,6 +7,7 @@ import {DeviceEventEmitter, Keyboard, Platform, View} from 'react-native';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FreezeScreen from '@components/freeze_screen';
import NavigationHeader from '@components/navigation_header';
import PostDraft from '@components/post_draft';
import {Navigation} from '@constants';
@@ -106,7 +107,7 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe
const channelIsSet = Boolean(channelId);
return (
<>
<FreezeScreen>
<SafeAreaView
style={styles.flex}
mode='margin'
@@ -140,7 +141,7 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe
</>
}
</SafeAreaView>
</>
</FreezeScreen>
);
};

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native';
import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import FileIcon from '@components/post_list/post/body/files/file_icon';
import {Events, Preferences} from '@constants';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {isDocument} 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';
type Props = {
canDownloadFiles: boolean;
item: GalleryItemType;
onShouldHideControls: (hide: boolean) => void;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
maxWidth: 600,
},
filename: {
color: '#FFF',
...typography('Body', 200, 'SemiBold'),
marginVertical: 8,
paddingHorizontal: 25,
textAlign: 'center',
},
unsupported: {
color: '#FFF',
...typography('Body', 100, 'SemiBold'),
marginTop: 10,
paddingHorizontal: 25,
opacity: 0.64,
textAlign: 'center',
},
});
const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: 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 optionText = isSupported ? formatMessage({
id: 'gallery.open_file',
defaultMessage: 'Open file',
}) : formatMessage({
id: 'gallery.unsupported',
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
});
const handleHideControls = useCallback(() => {
onShouldHideControls(controls);
setControls(!controls);
}, [controls, onShouldHideControls]);
const setGalleryAction = useCallback((action: GalleryAction) => {
DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action);
if (action === 'none') {
setEnabled(true);
}
}, []);
const handleOpenFile = useCallback(() => {
setEnabled(false);
}, []);
return (
<>
<TouchableWithoutFeedback onPress={handleHideControls}>
<Animated.View style={styles.container}>
<FileIcon
backgroundColor='transparent'
file={file}
iconSize={120}
/>
<Text
numberOfLines={2}
style={styles.filename}
>
{item.name}
</Text>
{!isSupported &&
<Text style={styles.unsupported}>{optionText}</Text>
}
{isSupported && canDownloadFiles &&
<View style={{marginTop: 16}}>
<RectButton
enabled={enabled}
exclusive={true}
onPress={handleOpenFile}
rippleColor={changeOpacity('#fff', 0.16)}
>
<View style={buttonBackgroundStyle(Preferences.THEMES.denim, 'lg', 'primary', enabled ? 'default' : 'disabled')}>
<Text style={buttonTextStyle(Preferences.THEMES.denim, 'lg', 'primary', enabled ? 'default' : 'disabled')} >{optionText}</Text>
</View>
</RectButton>
</View>
}
</Animated.View>
</TouchableWithoutFeedback>
{!enabled &&
<DownloadWithAction
action='opening'
setAction={setGalleryAction}
item={item}
/>
}
</>
);
};
export default DocumentRenderer;

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import DocumentRenderer from './document_renderer';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const {CONFIG, LICENSE} = SYSTEM_IDENTIFIERS;
const {SERVER: {SYSTEM}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const enableMobileFileDownload = database.get<SystemModel>(SYSTEM).findAndObserve(CONFIG).pipe(
switchMap(({value}) => of$(value.EnableMobileFileDownload !== 'false')),
);
const complianceDisabled = database.get<SystemModel>(SYSTEM).findAndObserve(LICENSE).pipe(
switchMap(({value}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')),
);
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
switchMap(([download, compliance]) => of$(compliance || download)),
);
return {
canDownloadFiles,
};
});
export default withDatabase(enhanced(DocumentRenderer));

View File

@@ -0,0 +1,54 @@
// 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 React, {useCallback} from 'react';
import {Platform, Pressable, PressableAndroidRippleConfig, PressableStateCallbackType, StyleProp, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {changeOpacity} from '@utils/theme';
type Props = {
disabled: 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 androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'};
const Action = ({disabled, iconName, onPress, style}: Props) => {
const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([
pressedStyle(pressed),
style,
]), [style]);
return (
<Pressable
android_ripple={androidRippleConfig}
disabled={disabled}
hitSlop={24}
onPress={onPress}
style={pressableStyle}
>
<CompassIcon
color={changeOpacity('#fff', disabled ? 0.4 : 1)}
name={iconName}
size={24}
/>
</Pressable>
);
};
export default Action;

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import Action from './action';
type Props = {
canDownloadFiles: boolean;
disabled: boolean;
enablePublicLinks: boolean;
fileId: string;
onCopyPublicLink: () => void;
onDownload: () => void;
onShare: () => void;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
},
action: {
marginLeft: 24,
},
});
const Actions = ({
canDownloadFiles, disabled, enablePublicLinks, fileId,
onCopyPublicLink, onDownload, onShare,
}: Props) => {
const managedConfig = useManagedConfig();
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyPasteProtection !== 'true';
return (
<View style={styles.container}>
{canCopyPublicLink &&
<Action
disabled={disabled}
iconName='link-variant'
onPress={onCopyPublicLink}
/>}
{canDownloadFiles &&
<>
<Action
disabled={disabled}
iconName='download-outline'
onPress={onDownload}
style={styles.action}
/>
<Action
disabled={disabled}
iconName='export-variant'
onPress={onShare}
style={styles.action}
/>
</>
}
</View>
);
};
export default Actions;

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {buildAbsoluteUrl} from '@actions/remote/file';
import {buildProfileImageUrl} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import {useServerUrl} from '@context/server';
import {changeOpacity} from '@utils/theme';
type Props = {
authorId?: string;
overrideIconUrl?: string;
}
const styles = StyleSheet.create({
avatarContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
padding: 2,
width: 34,
height: 34,
},
avatar: {
height: 32,
width: 32,
},
avatarRadius: {
borderRadius: 18,
},
});
const Avatar = ({authorId, overrideIconUrl}: Props) => {
const serverUrl = useServerUrl();
const avatarUri = useMemo(() => {
try {
if (overrideIconUrl) {
return buildAbsoluteUrl(serverUrl, overrideIconUrl);
} else if (authorId) {
const pictureUrl = buildProfileImageUrl(serverUrl, authorId);
return `${serverUrl}${pictureUrl}`;
}
return undefined;
} catch {
return undefined;
}
}, [serverUrl, authorId, overrideIconUrl]);
let picture;
if (avatarUri) {
picture = (
<FastImage
source={{uri: avatarUri}}
style={[styles.avatar, styles.avatarRadius]}
/>
);
} else {
picture = (
<CompassIcon
name='account-outline'
size={32}
color={changeOpacity('#fff', 0.48)}
/>
);
}
return (
<View style={[styles.avatarContainer, styles.avatarRadius]}>
{picture}
</View>
);
};
export default Avatar;

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Clipboard from '@react-native-community/clipboard';
import React, {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {fetchPublicLink} from '@actions/remote/file';
import Toast from '@components/toast';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {useServerUrl} from '@context/server';
type Props = {
item: GalleryItemType;
setAction: (action: GalleryAction) => void;
}
const styles = StyleSheet.create({
error: {
backgroundColor: '#D24B4E',
},
toast: {
backgroundColor: '#3DB887', // intended hardcoded color
},
});
const CopyPublicLink = ({item, setAction}: Props) => {
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const [showToast, setShowToast] = useState<boolean|undefined>();
const [error, setError] = useState('');
const mounted = useRef(false);
const animatedStyle = useAnimatedStyle(() => ({
position: 'absolute',
bottom: GALLERY_FOOTER_HEIGHT + 8,
opacity: withTiming(showToast ? 1 : 0, {duration: 300}),
}));
const copyLink = async () => {
try {
const publicLink = await fetchPublicLink(serverUrl, item.id!);
if ('link' in publicLink) {
Clipboard.setString(publicLink.link);
} else {
setError(formatMessage({id: 'gallery.copy_link.failed', defaultMessage: 'Failed to copy link to clipboard'}));
}
} catch {
setError(formatMessage({id: 'gallery.copy_link.failed', defaultMessage: 'Failed to copy link to clipboard'}));
} finally {
setShowToast(true);
setTimeout(() => {
if (mounted.current) {
setShowToast(false);
}
}, 3000);
}
};
useEffect(() => {
mounted.current = true;
copyLink();
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (showToast === false) {
setTimeout(() => {
if (mounted.current) {
setAction('none');
}
}, 350);
}
}, [showToast]);
return (
<Toast
animatedStyle={animatedStyle}
style={error ? styles.error : styles.toast}
message={error || formatMessage({id: 'public_link_copied', defaultMessage: 'Link copied to clipboard'})}
iconName='check'
/>
);
};
export default CopyPublicLink;

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {typography} from '@utils/typography';
type Props = {
channelName: string;
isDirectChannel: boolean;
ownPost: boolean;
userDisplayName: string;
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'column',
marginHorizontal: 12,
},
chanelText: {
color: '#FFFFFF',
...typography('Body', 75),
marginTop: 3,
opacity: 0.56,
},
userText: {
color: '#FFFFFF',
...typography('Body', 200, 'SemiBold'),
},
});
const Details = ({channelName, isDirectChannel, ownPost, userDisplayName}: Props) => {
const displayName = useMemo(() => ({displayName: userDisplayName}), [userDisplayName]);
const channelDisplayName = useMemo(() =>
({channelName: `${isDirectChannel ? '@' : '~'}${channelName}`}),
[channelName, isDirectChannel]);
let userElement = (
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.userText}
>
{userDisplayName}
</Text>
);
if (ownPost) {
userElement = (
<FormattedText
id='channel_header.directchannel.you'
defaultMessage='{displayName} (you)'
ellipsizeMode='tail'
numberOfLines={1}
style={styles.userText}
values={displayName}
/>
);
}
return (
<View style={styles.container}>
{userElement}
<FormattedText
id='gallery.footer.channel_name'
defaultMessage='Shared in {channelName}'
ellipsizeMode='tail'
numberOfLines={1}
style={styles.chanelText}
values={channelDisplayName}
/>
</View>
);
};
export default Details;

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import CameraRoll from '@react-native-community/cameraroll';
import * as FileSystem from 'expo-file-system';
import React, {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {NativeModules, Platform, StyleSheet, Text, View} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FileViewer from 'react-native-file-viewer';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import Share from 'react-native-share';
import {downloadFile} from '@actions/remote/file';
import {typography} from '@app/utils/typography';
import CompassIcon from '@components/compass_icon';
import ProgressBar from '@components/progress_bar';
import Toast from '@components/toast';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {useServerUrl} from '@context/server';
import {alertFailedToOpenDocument} from '@utils/document';
import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission} from '@utils/file';
import {galleryItemToFileInfo} from '@utils/gallery';
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
type Props = {
action: GalleryAction;
item: GalleryItemType;
setAction: (action: GalleryAction) => void;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
},
toast: {
backgroundColor: '#3F4350', // intended hardcoded color
},
error: {
backgroundColor: '#D24B4E',
},
fileSaved: {
backgroundColor: '#3DB887',
},
option: {
alignItems: 'flex-end',
justifyContent: 'center',
flex: 1,
marginTop: 8,
},
progress: {
marginTop: -10,
width: '85%',
},
title: {
color: '#FFF',
...typography('Body', 75, 'SemiBold'),
},
});
const DownloadWithAction = ({action, item, setAction}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [showToast, setShowToast] = useState<boolean|undefined>();
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const [progress, setProgress] = useState(0);
const mounted = useRef(false);
const downloadPromise = useRef<ProgressPromise<ClientResponse>>();
let title;
let iconName;
let message;
let toastStyle = styles.toast;
switch (action) {
case 'sharing':
title = intl.formatMessage({id: 'gallery.preparing', defaultMessage: 'Preparing...'});
break;
case 'opening':
title = intl.formatMessage({id: 'gallery.opening', defaultMessage: 'Opening...'});
break;
default:
title = intl.formatMessage({id: 'gallery.downloading', defaultMessage: 'Downloading...'});
break;
}
if (error) {
iconName = 'alert-circle-outline';
message = error;
toastStyle = styles.error;
} else if (saved) {
iconName = 'check';
toastStyle = styles.fileSaved;
switch (item.type) {
case 'image':
message = intl.formatMessage({id: 'gallery.image_saved', defaultMessage: 'Image saved'});
break;
case 'video':
message = intl.formatMessage({id: 'gallery.video_saved', defaultMessage: 'Video saved'});
break;
}
}
const animatedStyle = useAnimatedStyle(() => ({
position: 'absolute',
bottom: GALLERY_FOOTER_HEIGHT + 8,
opacity: withTiming(showToast ? 1 : 0, {duration: 300}),
}));
const cancel = async () => {
try {
await downloadPromise.current?.cancel?.();
const path = getLocalFilePathFromFile(serverUrl, galleryItemToFileInfo(item));
await FileSystem.deleteAsync(path, {idempotent: true});
downloadPromise.current = undefined;
} catch {
// do nothing
} finally {
if (mounted.current) {
setShowToast(false);
}
}
};
const openFile = async (response: ClientResponse) => {
if (mounted.current) {
if (response.data?.path) {
const path = response.data.path as string;
FileViewer.open(path, {
displayName: item.name,
showAppsSuggestions: true,
showOpenWithDialog: true,
}).catch(() => {
const file = galleryItemToFileInfo(item);
alertFailedToOpenDocument(file, intl);
});
}
setShowToast(false);
}
};
const saveFile = async (path: string) => {
if (mounted.current) {
if (Platform.OS === 'android') {
try {
await NativeModules.MattermostManaged.saveFile(path.replace('file://', '/'));
} catch {
// do nothing in case the user decides not to save the file
}
setAction('none');
return;
}
Share.open({
url: path,
saveToFiles: true,
}).catch(() => {
// do nothing
});
setAction('none');
}
};
const saveImageOrVideo = async (path: string) => {
if (mounted.current) {
try {
const applicationName = DeviceInfo.getApplicationName();
await CameraRoll.save(path, {
type: item.type === 'image' ? 'photo' : 'video',
album: applicationName,
});
setSaved(true);
} catch {
setError(intl.formatMessage({id: 'gallery.save_failed', defaultMessage: 'Unable to save the file'}));
}
}
};
const save = async (response: ClientResponse) => {
if (response.data?.path) {
const path = response.data.path as string;
const hasPermission = await hasWriteStoragePermission(intl);
if (hasPermission) {
switch (item.type) {
case 'file':
saveFile(path);
break;
default:
saveImageOrVideo(path);
break;
}
}
}
};
const shareFile = async (response: ClientResponse) => {
if (mounted.current) {
if (response.data?.path) {
const path = response.data.path as string;
Share.open({
message: '',
title: '',
url: path,
showAppsToView: true,
}).catch(() => {
// do nothing
});
}
setShowToast(false);
}
};
const startDownload = async () => {
try {
const path = getLocalFilePathFromFile(serverUrl, galleryItemToFileInfo(item));
if (path) {
const exists = await fileExists(path);
let actionToExecute: (request: ClientResponse) => Promise<void>;
switch (action) {
case 'sharing':
actionToExecute = shareFile;
break;
case 'opening':
actionToExecute = openFile;
break;
default:
actionToExecute = save;
break;
}
if (exists) {
setProgress(100);
actionToExecute({
code: 200,
ok: true,
data: {path},
});
} else {
downloadPromise.current = downloadFile(serverUrl, item.id!, path);
downloadPromise.current?.then(actionToExecute).catch(() => {
setError(intl.formatMessage({id: 'download.error', defaultMessage: 'Unable to download the file. Try again later'}));
});
downloadPromise.current?.progress?.(setProgress);
}
}
} catch (e) {
setShowToast(false);
}
};
useEffect(() => {
mounted.current = true;
setShowToast(true);
startDownload();
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
let t: NodeJS.Timeout;
if (error || saved) {
t = setTimeout(() => {
setShowToast(false);
}, 3500);
}
return () => clearTimeout(t);
}, [error, saved]);
useEffect(() => {
let t: NodeJS.Timeout;
if (showToast === false) {
t = setTimeout(() => {
if (mounted.current) {
setAction('none');
}
}, 350);
}
return () => clearTimeout(t);
}, [showToast]);
return (
<Toast
animatedStyle={animatedStyle}
style={toastStyle}
message={message}
iconName={iconName}
>
{!error && !saved &&
<View style={styles.container}>
<View style={styles.progress}>
<Text style={styles.title}>{title}</Text>
<ProgressBar
color='#fff'
progress={progress}
style={{marginTop: 5}}
/>
</View>
<View style={styles.option}>
<TouchableOpacity onPress={cancel}>
<CompassIcon
color='#FFF'
name='close'
size={24}
/>
</TouchableOpacity>
</View>
</View>
}
</Toast>
);
};
export default DownloadWithAction;

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {DeviceEventEmitter, StyleSheet, View, ViewStyle} from 'react-native';
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
import {SafeAreaView, Edge} from 'react-native-safe-area-context';
import {Events} from '@constants';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
import Actions from './actions';
import Avatar from './avatar';
import CopyPublicLink from './copy_public_link';
import Details from './details';
import DownloadWithAction from './download_with_action';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
canDownloadFiles: boolean;
channelName: string;
currentUserId: string;
enablePostIconOverride: boolean;
enablePostUsernameOverride: boolean;
enablePublicLink: boolean;
hideActions: boolean;
isDirectChannel: boolean;
item: GalleryItemType;
post?: PostModel;
style: AnimatedStyleProp<ViewStyle>;
teammateNameDisplay: string;
}
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
const edges: Edge[] = ['left', 'right'];
const styles = StyleSheet.create({
container: {
alignItems: 'center',
backgroundColor: changeOpacity('#000', 0.6),
borderTopColor: changeOpacity('#fff', 0.4),
borderTopWidth: 1,
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
height: GALLERY_FOOTER_HEIGHT,
paddingHorizontal: 20,
},
details: {flex: 3, flexDirection: 'row'},
icon: {
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
title: {
...typography('Heading', 300),
color: 'white',
},
});
const Footer = ({
author, canDownloadFiles, channelName, currentUserId,
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink,
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
}: Props) => {
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
const [action, setAction] = useState<GalleryAction>('none');
let overrideIconUrl;
if (enablePostIconOverride && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) {
overrideIconUrl = post.props.override_icon_url;
}
let userDisplayName;
if (enablePostUsernameOverride && post?.props?.override_username) {
userDisplayName = post?.props.override_username as string;
} else {
userDisplayName = displayUsername(author, undefined, teammateNameDisplay);
}
const handleCopyLink = useCallback(() => {
setAction('copying');
}, []);
const handleDownload = useCallback(async () => {
setAction('downloading');
}, []);
const handleShare = useCallback(() => {
setAction('sharing');
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.GALLERY_ACTIONS, (value: GalleryAction) => {
setAction(value);
});
return () => listener.remove();
}, []);
return (
<AnimatedSafeAreaView
mode='padding'
edges={edges}
style={[style]}
>
{['downloading', 'sharing'].includes(action) &&
<DownloadWithAction
action={action}
item={item}
setAction={setAction}
/>
}
{action === 'copying' &&
<CopyPublicLink
item={item}
setAction={setAction}
/>
}
<View style={styles.container}>
<View style={styles.details}>
<Avatar
authorId={author?.id}
overrideIconUrl={overrideIconUrl}
/>
<Details
channelName={channelName}
isDirectChannel={isDirectChannel}
ownPost={author?.id === currentUserId}
userDisplayName={userDisplayName}
/>
</View>
{showActions &&
<Actions
disabled={action !== 'none'}
canDownloadFiles={canDownloadFiles}
enablePublicLinks={enablePublicLink}
fileId={item.id!}
onCopyPublicLink={handleCopyLink}
onDownload={handleDownload}
onShare={handleShare}
/>
}
</View>
</AnimatedSafeAreaView>
);
};
export default Footer;

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$, Observable} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import Footer from './footer';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
type FooterProps = WithDatabaseArgs & {
item: GalleryItemType;
}
const {CONFIG, CURRENT_CHANNEL_ID, CURRENT_USER_ID, LICENSE} = SYSTEM_IDENTIFIERS;
const {SERVER: {CHANNEL, POST, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
const enhanced = withObservables(['item'], ({database, item}: FooterProps) => {
const post: Observable<PostModel|undefined> =
item.postId ? database.get<PostModel>(POST).findAndObserve(item.postId) : of$(undefined);
const currentChannelId = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_CHANNEL_ID).pipe(
switchMap(({value}) => of$(value)),
);
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const config = database.get<SystemModel>(SYSTEM).findAndObserve(CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const preferences = database.get<PreferenceModel>(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe();
const teammateNameDisplay = combineLatest([preferences, config, license]).pipe(
switchMap(([prefs, cfg, lcs]) => of$(getTeammateNameDisplaySetting(prefs, cfg, lcs))),
);
const author = post.pipe(
switchMap((p) => {
const id = p?.userId || item.authorId;
if (id) {
return database.get<UserModel>(USER).findAndObserve(id);
}
return of$(undefined);
}),
);
const channel = combineLatest([currentChannelId, post]).pipe(
switchMap(([cId, p]) => {
return p?.channel.observe() || database.get<ChannelModel>(CHANNEL).findAndObserve(cId);
}),
);
const enablePostUsernameOverride = config.pipe(switchMap((c) => of$(c.EnablePostUsernameOverride === 'true')));
const enablePostIconOverride = config.pipe(switchMap((c) => of$(c.EnablePostIconOverride === 'true')));
const enablePublicLink = config.pipe(switchMap((c) => of$(c.EnablePublicLink === 'true')));
const enableMobileFileDownload = config.pipe(switchMap((c) => of$(c.EnableMobileFileDownload !== 'false')));
const complianceDisabled = license.pipe(switchMap((l) => of$(l.IsLicensed === 'false' || l.Compliance === 'false')));
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
switchMap(([download, compliance]) => of$(compliance || download)),
);
const channelName = channel.pipe(switchMap((c: ChannelModel) => of$(c.displayName)));
const isDirectChannel = channel.pipe(switchMap((c: ChannelModel) => of$(c.type === General.DM_CHANNEL)));
return {
author,
canDownloadFiles,
channelName,
currentUserId,
enablePostIconOverride,
enablePostUsernameOverride,
enablePublicLink,
isDirectChannel,
post,
teammateNameDisplay,
};
});
export default withDatabase(enhanced(Footer));

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {BackHandler, StyleProp} from 'react-native';
import FastImage, {ImageStyle} from 'react-native-fast-image';
import Animated, {runOnJS, runOnUI, useAnimatedReaction} from 'react-native-reanimated';
import {useGallery} from '@context/gallery';
import {freezeOtherScreens, measureItem} from '@utils/gallery';
import DocumentRenderer from './document_renderer';
import {ImageRendererProps} from './image_renderer';
import LightboxSwipeout, {LightboxSwipeoutRef, RenderItemInfo} from './lightbox_swipeout';
import Backdrop, {BackdropProps} from './lightbox_swipeout/backdrop';
import VideoRenderer from './video_renderer';
import GalleryViewer from './viewer';
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
const AnimatedImage = Animated.createAnimatedComponent(FastImage);
interface GalleryProps {
galleryIdentifier: string;
initialIndex: number;
items: GalleryItemType[];
onIndexChange?: (index: number) => void;
onHide: () => void;
targetDimensions: { width: number; height: number };
onShouldHideControls: (hide: boolean) => void;
}
export interface GalleryRef {
close: () => void;
}
const Gallery = forwardRef<GalleryRef, GalleryProps>(({
galleryIdentifier,
initialIndex,
items,
onHide,
targetDimensions,
onShouldHideControls,
onIndexChange,
}: GalleryProps, ref) => {
const {refsByIndexSV, sharedValues} = useGallery(galleryIdentifier);
const [localIndex, setLocalIndex] = useState(initialIndex);
const lightboxRef = useRef<LightboxSwipeoutRef>(null);
const item = items[localIndex];
const close = () => {
lightboxRef.current?.closeLightbox();
};
const onLocalIndex = (index: number) => {
setLocalIndex(index);
onIndexChange?.(index);
};
useEffect(() => {
runOnUI(() => {
'worklet';
const tw = targetDimensions.width;
sharedValues.targetWidth.value = tw;
const scaleFactor = item.width / targetDimensions.width;
const th = item.height / scaleFactor;
sharedValues.targetHeight.value = th;
})();
}, [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]) {
measureItem(galleryItems[index].ref, sharedValues);
}
},
);
const onIndexChangeWorklet = useCallback((nextIndex: number) => {
'worklet';
runOnJS(onLocalIndex)(nextIndex);
sharedValues.activeIndex.value = nextIndex;
}, []);
const renderBackdropComponent = useCallback(
({animatedStyles, translateY}: BackdropProps) => {
return (
<Backdrop
animatedStyles={animatedStyles}
translateY={translateY}
/>
);
},
[],
);
function onSwipeActive(translateY: number) {
'worklet';
if (Math.abs(translateY) > 8) {
onShouldHideControls(true);
}
}
function onSwipeFailure() {
'worklet';
runOnJS(freezeOtherScreens)(true);
onShouldHideControls(false);
}
function hideLightboxItem() {
'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;
runOnJS(onHide)();
}
const onRenderItem = useCallback((info: RenderItemInfo) => {
if (item.type === 'video' && item.posterUri) {
return (
<AnimatedImage
source={{uri: item.posterUri}}
style={info.itemStyles as StyleProp<Animated.AnimateStyle<StyleProp<ImageStyle>>>}
/>
);
}
return null;
}, [item]);
const onRenderPage = useCallback((props: ImageRendererProps, idx: number) => {
switch (props.item.type) {
case 'video':
return (
<VideoRenderer
{...props}
index={idx}
initialIndex={initialIndex}
onShouldHideControls={onShouldHideControls}
/>
);
case 'file':
return (
<DocumentRenderer
item={props.item}
onShouldHideControls={onShouldHideControls}
/>
);
default:
return null;
}
}, []);
return (
<LightboxSwipeout
ref={lightboxRef}
target={item}
onAnimationFinished={hideLightboxItem}
sharedValues={sharedValues}
source={item.uri}
onSwipeActive={onSwipeActive}
onSwipeFailure={onSwipeFailure}
renderBackdropComponent={renderBackdropComponent}
targetDimensions={targetDimensions}
renderItem={onRenderItem}
>
{({onGesture, shouldHandleEvent}) => (
<GalleryViewer
items={items}
onIndexChange={onIndexChangeWorklet}
shouldPagerHandleGestureEvent={shouldHandleEvent}
onShouldHideControls={onShouldHideControls}
height={targetDimensions.height}
width={targetDimensions.width}
initialIndex={initialIndex}
onPagerEnabledGesture={onGesture}
numToRender={1}
renderPage={onRenderPage}
/>
)}
</LightboxSwipeout>
);
});
Gallery.displayName = 'Gallery';
export default Gallery;

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
import {SafeAreaView, Edge} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useDefaultHeaderHeight} from '@hooks/header';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
index: number;
onClose: () => void;
style: AnimatedStyleProp<ViewStyle>;
total: number;
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
backgroundColor: changeOpacity('#000', 0.6),
borderBottomColor: changeOpacity('#fff', 0.4),
borderBottomWidth: 1,
flexDirection: 'row',
},
icon: {
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
title: {
...typography('Heading', 300),
color: 'white',
},
});
const edges: Edge[] = ['left', 'right', 'top'];
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
const Header = ({index, onClose, style, total}: Props) => {
const {width} = useWindowDimensions();
const height = useDefaultHeaderHeight();
const containerStyle = useMemo(() => [styles.container, {height}], [height]);
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]);
return (
<AnimatedSafeAreaView
edges={edges}
style={style}
>
<Animated.View style={containerStyle}>
<TouchableOpacity
onPress={onClose}
style={iconStyle}
>
<CompassIcon
color='white'
name='close'
size={24}
/>
</TouchableOpacity>
<View style={titleStyle}>
<FormattedText
id='mobile.gallery.title'
defaultMessage='{index} of {total}'
style={styles.title}
values={titleValue}
/>
</View>
</Animated.View>
</AnimatedSafeAreaView>
);
};
export default Header;

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {PagerProps} from '../pager';
import {RenderPageProps} from '../pager/page';
import ImageTransformer, {ImageTransformerProps} from './transformer';
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}
onStateChange={onPageStateChange}
outerGestureHandlerRefs={pagerRefs}
source={item.uri}
width={item.width}
onDoubleTap={onDoubleTap}
onTap={onTap}
onInteraction={onInteraction}
/>
);
}
export default ImageRenderer;

View File

@@ -0,0 +1,583 @@
// 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} from 'react-native';
import FastImage, {Source} from 'react-native-fast-image';
import {
PanGestureHandler, PanGestureHandlerGestureEvent, PinchGestureHandler, PinchGestureHandlerGestureEvent,
State, TapGestureHandler, TapGestureHandlerGestureEvent,
} from 'react-native-gesture-handler';
import Animated, {
cancelAnimation, Easing, useAnimatedReaction, useAnimatedStyle, useDerivedValue,
useSharedValue,
withDecay, withSpring, WithSpringConfig, withTiming, WithTimingConfig,
} from 'react-native-reanimated';
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',
},
});
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: Source;
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>;
onStateChange?: (isActive: boolean) => void;
outerGestureHandlerActive?: Animated.SharedValue<boolean>;
outerGestureHandlerRefs?: Array<React.Ref<unknown>>;
source: Source | 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,
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) {
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},
],
};
}, []);
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}>
<FastImage
onLoad={onLoadImageSuccess}
source={imageSource}
style={{width: targetWidth, height: targetHeight}}
/>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</TapGestureHandler>
</Animated.View>
</PanGestureHandler>
</Animated.View>
</PinchGestureHandler>
</Animated.View>
);
};
export default React.memo(ImageTransformer);

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {NativeModules, useWindowDimensions, Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
import {dismissModal} from '@screens/navigation';
import {freezeOtherScreens} from '@utils/gallery';
import Footer from './footer';
import Gallery, {GalleryRef} from './gallery';
import Header from './header';
type Props = {
galleryIdentifier: string;
hideActions: boolean;
initialIndex: number;
items: GalleryItemType[];
}
const GalleryScreen = ({galleryIdentifier, hideActions, initialIndex, items}: Props) => {
const dim = useWindowDimensions();
const isTablet = useIsTablet();
const theme = useTheme();
const [localIndex, setLocalIndex] = useState(initialIndex);
const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls();
const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim.width]);
const galleryRef = useRef<GalleryRef>(null);
const onClose = useCallback(() => {
// We keep the un freeze here as we want
// the screen to be visible when the gallery
// starts to dismiss as the hanlder for shouldHandleEvent
// of the lightbox is not called
freezeOtherScreens(false);
requestAnimationFrame(() => {
galleryRef.current?.close();
});
}, []);
const close = useCallback(() => {
if (Platform.OS === 'ios' && !isTablet) {
// We need both the navigation & the module
Navigation.setDefaultOptions({
layout: {
orientation: ['portrait'],
},
});
NativeModules.MattermostManaged.lockPortrait();
}
freezeOtherScreens(false);
requestAnimationFrame(async () => {
dismissModal({
componentId: Screens.GALLERY,
layout: {
orientation: isTablet ? undefined : ['portrait'],
},
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
},
});
});
}, [isTablet]);
const onIndexChange = useCallback((index: number) => {
setLocalIndex(index);
}, []);
return (
<>
<Header
index={localIndex}
onClose={onClose}
style={headerStyles}
total={items.length}
/>
<Gallery
galleryIdentifier={galleryIdentifier}
initialIndex={initialIndex}
items={items}
onHide={close}
onIndexChange={onIndexChange}
onShouldHideControls={setControlsHidden}
ref={galleryRef}
targetDimensions={dimensions}
/>
<Footer
hideActions={hideActions}
item={items[localIndex]}
style={footerStyles}
/>
</>
);
};
export default GalleryScreen;

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, ViewStyle} from 'react-native';
import Animated, {AnimatedStyleProp, Extrapolate, interpolate, SharedValue, useAnimatedStyle} from 'react-native-reanimated';
export type BackdropProps = {
animatedStyles: AnimatedStyleProp<ViewStyle>;
translateY: SharedValue<number>;
}
const styles = StyleSheet.create({
backdrop: {
backgroundColor: 'black',
},
});
const Backdrop = ({animatedStyles, translateY}: BackdropProps) => {
const customBackdropStyles = useAnimatedStyle(() => {
return {
opacity: interpolate(
Math.abs(translateY.value),
[0, 100],
[1, 0],
Extrapolate.CLAMP,
),
};
}, []);
return (
<Animated.View style={[StyleSheet.absoluteFill, customBackdropStyles]}>
<Animated.View style={[animatedStyles, styles.backdrop]}/>
</Animated.View>
);
};
export default Backdrop;

View File

@@ -0,0 +1,352 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react';
import {ImageStyle, StyleSheet, View, ViewStyle} from 'react-native';
import FastImage, {Source} from 'react-native-fast-image';
import {
GestureHandlerGestureEventNativeEvent,
PanGestureHandlerEventPayload, PanGestureHandlerGestureEvent,
} from 'react-native-gesture-handler';
import Animated, {
cancelAnimation, Easing, interpolate, runOnJS, runOnUI,
useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming, WithTimingConfig,
} from 'react-native-reanimated';
import {useCreateAnimatedGestureHandler} from '@hooks/gallery';
import {freezeOtherScreens} from '@utils/gallery';
import {calculateDimensions} from '@utils/images';
import type {BackdropProps} from './backdrop';
interface Size {
height: number;
width: number;
}
export interface RenderItemInfo {
source: Source;
width: number;
height: number;
itemStyles: ViewStyle | ImageStyle;
}
interface LightboxSwipeoutChildren {
onGesture: (evt: GestureHandlerGestureEventNativeEvent & PanGestureHandlerEventPayload) => void;
shouldHandleEvent: () => boolean;
}
interface LightboxSwipeoutProps {
children: ({onGesture, shouldHandleEvent}: LightboxSwipeoutChildren) => JSX.Element;
onAnimationFinished: () => void;
onSwipeActive: (translateY: number) => void;
onSwipeFailure: () => void;
renderBackdropComponent?: (info: BackdropProps) => JSX.Element;
renderItem: (info: RenderItemInfo) => JSX.Element | null;
sharedValues: GalleryManagerSharedValues;
source: Source | string;
target: GalleryItemType;
targetDimensions: Size;
}
export interface LightboxSwipeoutRef {
closeLightbox: () => void;
}
// @ts-expect-error FastImage does animate
const AnimatedImage = Animated.createAnimatedComponent(FastImage);
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,
renderBackdropComponent, renderItem,
sharedValues, source, target, targetDimensions,
}: LightboxSwipeoutProps, ref) => {
const imageSource: Source = typeof source === 'string' ? {uri: source} : source;
const {x, y, width, height, opacity, targetWidth, targetHeight} = sharedValues;
const animationProgress = useSharedValue(0);
const childTranslateY = useSharedValue(0);
const translateY = useSharedValue(0);
const translateX = useSharedValue(0);
const scale = useSharedValue(1);
const lightboxImageOpacity = useSharedValue(1);
const childrenOpacity = useSharedValue(0);
const [renderChildren, setRenderChildren] = useState<boolean>(false);
const shouldHandleEvent = () => {
'worklet';
return childTranslateY.value === 0;
};
const closeLightbox = () => {
'worklet';
lightboxImageOpacity.value = 1;
childrenOpacity.value = 0;
animationProgress.value = withTiming(
0,
timingConfig,
() => {
'worklet';
opacity.value = 1;
onAnimationFinished();
},
);
};
useAnimatedReaction(
() => childTranslateY.value,
(value) => {
if (Math.abs(value) >= target.height + 100) {
cancelAnimation(childTranslateY);
}
},
);
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,
}));
const isVisibleImage = () => {
'worklet';
return (
targetDimensions.height >= y.value &&
targetDimensions.width >= x.value &&
x.value >= 0 &&
y.value >= 0
);
};
const handler = useCallback(
useCreateAnimatedGestureHandler<PanGestureHandlerGestureEvent, {}>({
shouldHandleEvent: (evt) => {
'worklet';
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,
},
],
};
});
return (
<View style={{flex: 1}}>
{renderBackdropComponent &&
renderBackdropComponent({
animatedStyles: backdropStyles,
translateY: childTranslateY,
})}
<Animated.View style={StyleSheet.absoluteFillObject}>
{target.type !== 'image' && 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]}
>
{renderChildren && (
<Animated.View
style={[StyleSheet.absoluteFill]}
onLayout={onChildrenLayout}
>
{children({onGesture: handler, shouldHandleEvent})}
</Animated.View>
)}
</Animated.View>
</View>
);
});
LightboxSwipeout.displayName = 'LightboxSwipeout';
export default LightboxSwipeout;

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {typedMemo} from '@utils/gallery';
type IGutterProps = {
width: number;
};
const Gutter = typedMemo(({width}: IGutterProps) => {
return <View style={{width}}/>;
});
export default Gutter;

View File

@@ -0,0 +1,400 @@
// 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, PanGestureHandlerGestureEvent, TapGestureHandler} from 'react-native-gesture-handler';
import Animated, {cancelAnimation, runOnJS, SharedValue, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, WithSpringConfig} from 'react-native-reanimated';
import {useAnimatedGestureHandler} from '@hooks/gallery';
import {clampVelocity, friction, getShouldRender, workletNoop, workletNoopTrue} from '@utils/gallery';
import Page, {PageRefs, RenderPageProps} from './page';
export interface PagerReusableProps {
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;
}
const GUTTER_WIDTH = 10;
const MIN_VELOCITY = 700;
const MAX_VELOCITY = 3000;
const styles = StyleSheet.create({
backdrop: {
flex: 1,
},
pager: {
flex: 1,
flexDirection: 'row',
},
});
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,
}: PagerProps) => {
const sharedWidth = useSharedValue(width);
const gutterWidthToUse = shouldRenderGutter ? gutterWidth : 0;
const getPageTranslate = (i: number, w?: number) => {
'worklet';
const t = i * (w || sharedWidth.value);
const g = gutterWidthToUse * i;
return -(t + g);
};
useEffect(() => {
sharedWidth.value = width;
}, [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 = useRef(activeIndex);
const updateIndex = (nextIndex: number) => {
setActiveIndex(nextIndex);
activeIndexRef.current = nextIndex;
};
const index = useSharedValue(initialIndex);
const length = useSharedValue(totalCount);
const pagerX = useSharedValue(0);
const toValueAnimation = useSharedValue(getPageTranslate(initialIndex));
const offsetX = useDerivedValue(() => getPageTranslate(activeIndexRef.current), [width]);
const totalWidth = useDerivedValue(() => ((length.value * width) + ((gutterWidthToUse * length.value) - 2)), [width]);
const onIndexChangeCb = useCallback((nextIndex: number) => {
'worklet';
if (onIndexChange) {
onIndexChange(nextIndex);
}
runOnJS(updateIndex)(nextIndex);
}, []);
useEffect(() => {
index.value = initialIndex;
onIndexChangeCb(initialIndex);
}, [initialIndex]);
function getSpringConfig(noVelocity?: boolean) {
'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: noVelocity ? 0 : velocity.value,
} as WithSpringConfig;
}
const onChangePageAnimation = (noVelocity?: boolean) => {
'worklet';
const config = getSpringConfig(noVelocity);
if (offsetX.value === toValueAnimation.value) {
return;
}
// @ts-expect-error defined as read only but this is the
// only way it works with rotation
offsetX.value = withSpring(
toValueAnimation.value,
config,
(isCanceled) => {
'worklet';
if (!isCanceled) {
velocity.value = 0;
}
},
);
};
// 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;
// @ts-expect-error defined as read only but this is the
// only way it works with rotation
offsetX.value += pagerX.value;
pagerX.value = 0;
// we invert the value since the translationY is left to right
toValueAnimation.value = -(shouldMoveToNextPage ? -getPageTranslate(nextIndex) : -getPageTranslate(index.value));
onChangePageAnimation(!shouldMoveToNextPage);
if (shouldMoveToNextPage) {
index.value = nextIndex;
onIndexChangeCb(nextIndex);
}
},
});
const onTap = useAnimatedGestureHandler({
shouldHandleEvent: (evt) => {
return evt.numberOfPointers === 1 && isActive.value;
},
onStart: () => {
cancelAnimation(offsetX);
},
onEnd: () => {
onChangePageAnimation(true);
},
});
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}
renderPage={renderPage}
getPageTranslate={getPageTranslate}
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>
);
};
export default React.memo(Pager);

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {PanGestureHandler, TapGestureHandler} from 'react-native-gesture-handler';
import {SharedValue, useDerivedValue} from 'react-native-reanimated';
import {typedMemo} from '@utils/gallery';
import Gutter from './gutter';
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;
gutterWidth: number;
index: number;
length: number;
renderPage: (props: RenderPageProps, 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({
container: {
flex: 1,
position: 'absolute',
top: 0,
bottom: 0,
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
const Page = typedMemo(
({
currentIndex, getPageTranslate, gutterWidth, index, isPagerInProgress, item, length,
onPageStateChange, pagerRefs, renderPage, shouldRenderGutter, width, height,
}: PageProps) => {
const isPageActive = useDerivedValue(() => (currentIndex.value === index), []);
return (
<View style={[styles.container, {left: -getPageTranslate(index, width)}]}>
<View style={[styles.center, {width}]}>
{renderPage(
{
index,
onPageStateChange,
item,
width,
isPageActive,
isPagerInProgress,
pagerRefs,
height,
},
index,
)}
</View>
{index !== length - 1 && shouldRenderGutter && (
<Gutter width={gutterWidth}/>
)}
</View>
);
},
);
export default Page;

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, StyleSheet, useWindowDimensions} from 'react-native';
import Animated, {Easing, useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming, WithTimingConfig} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Video, {LoadError, OnPlaybackRateData} from 'react-native-video';
import CompassIcon from '@components/compass_icon';
import {GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
import {changeOpacity} from '@utils/theme';
import {ImageRendererProps} from '../image_renderer';
interface VideoRendererProps extends ImageRendererProps {
index: number;
initialIndex: number;
onShouldHideControls: (hide: boolean) => void;
}
const AnimatedVideo = Animated.createAnimatedComponent(Video);
const timingConfig: WithTimingConfig = {
duration: 250,
easing: Easing.bezier(0.33, 0.01, 0, 1),
};
const styles = StyleSheet.create({
playContainer: {
alignItems: 'center',
height: '100%',
justifyContent: 'center',
position: 'absolute',
width: '100%',
},
play: {
backgroundColor: changeOpacity('#000', 0.16),
borderRadius: 40,
},
video: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
},
});
const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => {
const dimensions = useWindowDimensions();
const fullscreen = useSharedValue(false);
const {bottom} = useSafeAreaInsets();
const videoRef = useAnimatedRef<Video>();
const [paused, setPaused] = useState(!(initialIndex === index));
const [videoReady, setVideoReady] = useState(false);
const source = useMemo(() => ({uri: item.uri}), [item.uri]);
const setFullscreen = (value: boolean) => {
fullscreen.value = value;
};
const onEnd = useCallback(() => {
setFullscreen(false);
onShouldHideControls(true);
setPaused(true);
videoRef.current?.dismissFullscreenPlayer();
}, [onShouldHideControls]);
const onError = useCallback((error: LoadError) => {
// eslint-disable-next-line no-console
console.log(
'Error loading, figure out what to do here... give the option to download?',
error,
);
}, []);
const onFullscreenPlayerWillDismiss = useCallback(() => {
setFullscreen(false);
onShouldHideControls(!paused);
}, [paused, onShouldHideControls]);
const onFullscreenPlayerWillPresent = useCallback(() => {
setFullscreen(true);
onShouldHideControls(true);
}, [onShouldHideControls]);
const onPlay = useCallback(() => {
setPaused(false);
}, []);
const onPlaybackRateChange = useCallback(({playbackRate}: OnPlaybackRateData) => {
if (isPageActive.value) {
const isPlaying = Boolean(playbackRate);
onShouldHideControls(isPlaying);
setPaused(!isPlaying);
}
}, [onShouldHideControls]);
const onReadyForDisplay = useCallback(() => {
setVideoReady(true);
}, []);
const animatedStyle = useAnimatedStyle(() => {
let w = width;
let h = height - (VIDEO_INSET + GALLERY_FOOTER_HEIGHT + bottom);
if (fullscreen.value) {
w = dimensions.width;
h = dimensions.height;
}
return {
width: withTiming(w, timingConfig),
height: withTiming(h, timingConfig),
};
}, [dimensions.height]);
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 (
<>
<AnimatedVideo
ref={videoRef}
source={source}
paused={paused}
poster={item.posterUri}
onError={onError}
style={[styles.video, animatedStyle]}
controls={isPageActive.value}
onPlaybackRateChange={onPlaybackRateChange}
onFullscreenPlayerWillDismiss={onFullscreenPlayerWillDismiss}
onFullscreenPlayerWillPresent={onFullscreenPlayerWillPresent}
onReadyForDisplay={onReadyForDisplay}
onEnd={onEnd}
/>
{Platform.OS === 'android' && paused && videoReady &&
<Animated.View style={styles.playContainer}>
<CompassIcon
color={changeOpacity('#fff', 0.8)}
style={styles.play}
name='play'
onPress={onPlay}
size={80}
/>
</Animated.View>
}
</>
);
};
export default VideoRenderer;

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {runOnJS} from 'react-native-reanimated';
import ImageRenderer, {Handlers, ImageRendererProps} from '../image_renderer';
import {InteractionType} from '../image_renderer/transformer';
import Pager from '../pager';
import {RenderPageProps} from '../pager/page';
export interface GalleryViewerProps extends Handlers {
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;
width: number;
}
const GalleryViewer = ({
gutterWidth, height, initialIndex, items, keyExtractor, numToRender,
onDoubleTap, onGesture, onIndexChange, onInteraction, onPagerEnabledGesture,
onShouldHideControls, onTap, renderPage, shouldPagerHandleGestureEvent, width,
}: 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';
runOnJS(setTempIndex)(nextIndex);
if (onIndexChange) {
onIndexChange(nextIndex);
}
}, []);
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 = {
...pagerProps,
width,
height,
onDoubleTap: doubleTap,
onTap: tap,
onInteraction: interaction,
};
if (props.item.type !== 'image' && typeof renderPage === 'function') {
return renderPage(props, index);
}
return (<ImageRenderer {...props}/>);
}, [items]);
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}
/>
);
};
export default GalleryViewer;

View File

@@ -8,6 +8,7 @@ import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import ChannelList from '@components/channel_list';
import FreezeScreen from '@components/freeze_screen';
import TeamSidebar from '@components/team_sidebar';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@@ -76,7 +77,7 @@ const ChannelListScreen = (props: ChannelProps) => {
return {height: insets.top, backgroundColor: theme.sidebarBg};
}, [theme]);
return (
const content = (
<>
{<Animated.View style={top}/>}
<SafeAreaView
@@ -105,6 +106,16 @@ const ChannelListScreen = (props: ChannelProps) => {
</SafeAreaView>
</>
);
if (isTablet) {
return (
<FreezeScreen>
{content}
</FreezeScreen>
);
}
return content;
};
export default ChannelListScreen;

View File

@@ -6,7 +6,7 @@ import {NavigationContainer} from '@react-navigation/native';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Platform} from 'react-native';
import {enableScreens} from 'react-native-screens';
import {enableFreeze, enableScreens} from 'react-native-screens';
import {Events, Screens} from '@constants';
import {useTheme} from '@context/theme';
@@ -25,6 +25,8 @@ if (Platform.OS === 'ios') {
enableScreens(false);
}
enableFreeze(true);
type HomeProps = LaunchProps & {
time?: number;
};
@@ -40,7 +42,9 @@ export default function HomeScreen(props: HomeProps) {
notificationError(intl, value);
});
return () => listener.remove();
return () => {
listener.remove();
};
}, []);
return (

View File

@@ -84,6 +84,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.FORGOT_PASSWORD:
screen = withIntl(require('@screens/forgot_password').default);
break;
case Screens.GALLERY:
screen = withServerDatabase((require('@screens/gallery').default));
break;
case Screens.IN_APP_NOTIFICATION: {
const notificationScreen = require('@screens/in_app_notification').default;
Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () => Platform.select({

View File

@@ -57,7 +57,7 @@ export const loginAnimationOptions = () => {
alpha,
},
push: {
waitForRender: true,
waitForRender: false,
content: {
alpha,
},
@@ -454,7 +454,7 @@ export function showModalOverCurrentContext(name: string, passProps = {}, option
case 'android':
animations = {
showModal: {
waitForRender: true,
waitForRender: false,
alpha: {
from: 0,
to: 1,

View File

@@ -134,7 +134,7 @@ export default class FilePickerUtil {
}
if (uri) {
files.push({...file, fileName, uri, type});
files.push({...file, fileName, uri, type, width: file.width, height: file.height});
}
}
})));
@@ -155,10 +155,7 @@ export default class FilePickerUtil {
switch (hasPhotoLibraryPermission) {
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
return false;
}
break;
return permissionRequest === Permissions.RESULTS.GRANTED;
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: this.intl.formatMessage({
@@ -183,7 +180,6 @@ export default class FilePickerUtil {
}
default: return true;
}
return false;
};
private hasStoragePermission = async () => {
@@ -197,9 +193,7 @@ export default class FilePickerUtil {
switch (hasPermissionToStorage) {
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(storagePermission);
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
return false;
}
return permissionRequest === Permissions.RESULTS.GRANTED;
break;
case Permissions.RESULTS.BLOCKED: {
const {title, text} = this.getPermissionDeniedMessage();
@@ -223,8 +217,6 @@ export default class FilePickerUtil {
}
default: return true;
}
return false;
};
private buildUri = async (doc: DocumentPickerResponse) => {

View File

@@ -6,15 +6,17 @@ import Model from '@nozbe/watermelondb/Model';
import * as FileSystem from 'expo-file-system';
import mimeDB from 'mime-db';
import {IntlShape} from 'react-intl';
import {Platform} from 'react-native';
import {Alert, Platform} from 'react-native';
import AndroidOpenSettings from 'react-native-android-open-settings';
import DeviceInfo from 'react-native-device-info';
import {DocumentPickerResponse} from 'react-native-document-picker';
import {Asset} from 'react-native-image-picker';
import Permissions, {PERMISSIONS} from 'react-native-permissions';
import {Files} from '@constants';
import {generateId} from '@utils/general';
import {deleteEntititesFile, getIOSAppGroupDetails} from '@utils/mattermost_managed';
import {hashCode} from '@utils/security';
import {removeProtocol} from '@utils/url';
import type FileModel from '@typings/database/models/servers/file';
@@ -50,7 +52,7 @@ const SUPPORTED_DOCS_FORMAT = Platform.select({
const SUPPORTED_VIDEO_FORMAT = Platform.select({
ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm', 'video/quicktime'],
});
const types: Record<string, string> = {};
@@ -253,10 +255,9 @@ export const isImage = (file?: FileInfo | FileModel) => {
const fi = file as FileInfo;
const fm = file as FileModel;
const hasPreview = Boolean(fi.mini_preview || fm.imageThumbnail);
const mimeType = fi.mime_type || fm.mimeType || '';
return (hasPreview || isGif(file) || mimeType.startsWith('image/'));
return (isGif(file) || mimeType.startsWith('image/'));
};
export const isDocument = (file?: FileInfo | FileModel) => {
@@ -338,9 +339,9 @@ export function getFileType(file: FileInfo): string {
}) || 'other';
}
export function getLocalFilePathFromFile(dir: string, serverUrl: string, file: FileInfo | FileModel) {
if (dir && serverUrl) {
const server = removeProtocol(serverUrl);
export function getLocalFilePathFromFile(serverUrl: string, file: FileInfo | FileModel) {
if (serverUrl) {
const server = hashCode(serverUrl);
if (file?.name) {
let extension: string | undefined = file.extension;
let filename = file.name;
@@ -361,13 +362,13 @@ export function getLocalFilePathFromFile(dir: string, serverUrl: string, file: F
}
}
return `${dir}/${server}/${filename}-${hashCode(file.id!)}.${extension}`;
return `${FileSystem.cacheDirectory}${server}/${filename}-${hashCode(file.id!)}.${extension}`;
} else if (file?.id && file?.extension) {
return `${dir}/${server}/${file.id}.${file.extension}`;
return `${FileSystem.cacheDirectory}${server}/${file.id}.${file.extension}`;
}
}
return undefined;
throw new Error('File path could not be set');
}
export async function extractFileInfo(files: Array<Asset | DocumentPickerResponse | PastedFile>) {
@@ -440,3 +441,62 @@ export function uploadDisabledWarning(intl: IntlShape) {
defaultMessage: 'File uploads from mobile are disabled.',
});
}
export const fileExists = async (path: string) => {
try {
const filePath = Platform.select({ios: path.replace('file://', ''), default: path});
const info = await FileSystem.getInfoAsync(filePath);
return info.exists;
} catch {
return false;
}
};
export const hasWriteStoragePermission = async (intl: IntlShape) => {
if (Platform.OS === 'ios') {
return true;
}
const storagePermission = PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE;
let permissionRequest;
const hasPermissionToStorage = await Permissions.check(storagePermission);
switch (hasPermissionToStorage) {
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(storagePermission);
return permissionRequest === Permissions.RESULTS.GRANTED;
case Permissions.RESULTS.BLOCKED: {
const applicationName = DeviceInfo.getApplicationName();
const title = intl.formatMessage(
{
id: 'mobile.storage_permission_denied_title',
defaultMessage:
'{applicationName} would like to access your files',
},
{applicationName},
);
const text = intl.formatMessage({
id: 'mobile.write_storage_permission_denied_description',
defaultMessage:
'Save files to your device. Open Settings to grant {applicationName} write access to files on this device.',
});
Alert.alert(title, text, [
{
text: intl.formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: "Don't Allow",
}),
},
{
text: intl.formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => AndroidOpenSettings.appDetailsSettings(),
},
]);
return false;
}
default: return true;
}
};

View File

@@ -1,139 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Dimensions, Keyboard, Platform} from 'react-native';
import {Options, SharedElementTransition, StackAnimationOptions, ViewAnimationOptions} from 'react-native-navigation';
import parseUrl from 'url-parse';
import React from 'react';
import {DeviceEventEmitter, Keyboard, NativeModules, Platform} from 'react-native';
import {Navigation, Options, OptionsLayout} from 'react-native-navigation';
import {measure} from 'react-native-reanimated';
import {goToScreen} from '@screens/navigation';
import {isImage, lookupMimeType} from '@utils/file';
import {Events, Screens} from '@constants';
import {showModalOverCurrentContext} from '@screens/navigation';
import {isImage, isVideo} from '@utils/file';
import {generateId} from '@utils/general';
import type FileModel from '@typings/database/models/servers/file';
export const clamp = (value: number, lowerBound: number, upperBound: number) => {
'worklet';
export function openGalleryAtIndex(index: number, files: FileInfo[] | FileModel[]) {
Keyboard.dismiss();
requestAnimationFrame(() => {
const screen = 'Gallery';
const passProps = {
index,
files,
};
const windowHeight = Dimensions.get('window').height;
const sharedElementTransitions: SharedElementTransition[] = [];
return Math.min(Math.max(lowerBound, value), upperBound);
};
const contentPush = {} as ViewAnimationOptions;
const contentPop = {} as ViewAnimationOptions;
const file = files[index];
export const clampVelocity = (velocity: number, minVelocity: number, maxVelocity: number) => {
'worklet';
if (isImage(file)) {
sharedElementTransitions.push({
fromId: `image-${file.id}`,
toId: `gallery-${file.id}`,
duration: 300,
interpolation: {type: 'accelerateDecelerate'},
});
} else {
contentPush.y = {
from: windowHeight,
to: 0,
duration: 300,
interpolation: {type: 'decelerate'},
};
if (velocity > 0) {
return Math.min(Math.max(velocity, minVelocity), maxVelocity);
}
return Math.max(Math.min(velocity, -minVelocity), -maxVelocity);
};
if (Platform.OS === 'ios') {
contentPop.translationY = {
from: 0,
to: windowHeight,
duration: 300,
};
} else {
contentPop.y = {
from: 0,
to: windowHeight,
duration: 300,
};
contentPop.alpha = {
from: 1,
to: 0,
duration: 100,
};
}
}
const options: Options = {
layout: {
backgroundColor: '#000',
componentBackgroundColor: '#000',
orientation: ['portrait', 'landscape'],
},
topBar: {
background: {
color: '#000',
},
visible: Platform.OS === 'android',
},
animations: {
push: {
waitForRender: true,
sharedElementTransitions,
},
},
};
if (Object.keys(contentPush).length) {
options.animations!.push = {
...options.animations!.push,
...Platform.select<ViewAnimationOptions | StackAnimationOptions>({
android: contentPush,
ios: {
content: contentPush,
},
}),
};
}
if (Object.keys(contentPop).length) {
options.animations!.pop = Platform.select<ViewAnimationOptions | StackAnimationOptions>({
android: contentPop,
ios: {
content: contentPop,
},
});
}
goToScreen(screen, '', passProps, options);
});
}
export function openGallerWithMockFile(uri: string, postId: string, height: number, width: number, fileId?: string) {
const url = decodeURIComponent(uri);
let filename = parseUrl(url.substr(url.lastIndexOf('/'))).pathname.replace('/', '');
let extension = filename.split('.').pop();
if (!extension || extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
extension = ext;
export const fileToGalleryItem = (file: FileInfo, authorId?: string): GalleryItemType => {
let type: GalleryItemType['type'] = 'file';
if (isVideo(file)) {
type = 'video';
} else if (isImage(file)) {
type = 'image';
}
const file: FileInfo = {
id: fileId || generateId(),
clientId: 'mock_client_id',
create_at: Date.now(),
delete_at: 0,
extension,
has_preview_image: true,
height,
mime_type: lookupMimeType(filename),
name: filename,
post_id: postId,
size: 0,
update_at: 0,
uri,
user_id: 'mock_user_id',
width,
return {
authorId,
extension: file.extension,
height: file.height,
id: file.id || generateId('uid'),
mime_type: file.mime_type,
name: file.name,
posterUri: type === 'video' ? file.mini_preview : undefined, // set the video poster to the mini_preview
postId: file.post_id,
size: file.size,
type,
uri: file.localPath || file.uri || '',
width: file.width,
};
};
export const freezeOtherScreens = (value: boolean) => {
DeviceEventEmitter.emit(Events.FREEZE_SCREEN, value);
};
export const friction = (value: number) => {
'worklet';
const MAX_FRICTION = 30;
const MAX_VALUE = 200;
const res = Math.max(
1,
Math.min(
MAX_FRICTION,
(1 + (Math.abs(value) * (MAX_FRICTION - 1))) / MAX_VALUE,
),
);
return value > 0 ? res : -res;
};
export const galleryItemToFileInfo = (item: GalleryItemType): FileInfo => ({
id: item.id,
name: item.name,
create_at: 0,
delete_at: 0,
update_at: 0,
width: item.width,
height: item.height,
extension: item.extension || '',
mime_type: item.mime_type,
has_preview_image: false,
post_id: item.postId!,
size: 0,
user_id: item.authorId!,
});
export const getShouldRender = (index: number, activeIndex: number, diffValue = 3) => {
const diff = Math.abs(index - activeIndex);
if (diff > diffValue) {
return false;
}
return true;
};
export function measureItem(ref: React.RefObject<any>, sharedValues: GalleryManagerSharedValues) {
'worklet';
try {
const measurements = measure(ref);
sharedValues.x.value = measurements.pageX;
sharedValues.y.value = measurements.pageY;
sharedValues.width.value = measurements.width;
sharedValues.height.value = measurements.height;
} catch (err) {
sharedValues.x.value = 999999;
sharedValues.y.value = 999999;
sharedValues.width.value = 0;
sharedValues.height.value = 0;
}
}
export function openGalleryAtIndex(galleryIdentifier: string, initialIndex: number, items: GalleryItemType[], hideActions = false) {
Keyboard.dismiss();
const props = {
galleryIdentifier,
hideActions,
initialIndex,
items,
};
const layout: OptionsLayout = {
orientation: ['portrait', 'landscape'],
};
const options: Options = {
layout,
topBar: {
background: {
color: '#000',
},
visible: Platform.OS === 'android',
},
statusBar: {
backgroundColor: '#000',
style: 'light',
},
animations: {
showModal: {
waitForRender: false,
enabled: false,
},
dismissModal: {
enabled: false,
},
},
};
openGalleryAtIndex(0, [file]);
if (Platform.OS === 'ios') {
// on iOS we need both the navigation & the module
Navigation.setDefaultOptions({layout});
NativeModules.MattermostManaged.unlockOrientation();
}
showModalOverCurrentContext(Screens.GALLERY, props, options);
setTimeout(() => {
freezeOtherScreens(true);
}, 500);
}
export const typedMemo: <T>(c: T) => T = React.memo;
export const workletNoop = () => {
'worklet';
};
export const workletNoopTrue = () => {
'worklet';
return true;
};

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Animated, {useSharedValue} from 'react-native-reanimated';
type SharedValueType = number;
type Callback = () => unknown;
type VectorType = Vector<any> | SharedVector<any>;
type VectorList = Array<VectorType | SharedValueType>;
type Operation = 'divide' | 'add' | 'sub' | 'multiply';
type VectorProp = 'x' | 'y';
export type Vector<T extends SharedValueType> = {
x: T;
y: T;
};
export type SharedVector<T extends SharedValueType> = {
x: Animated.SharedValue<T>;
y: Animated.SharedValue<T>;
};
const isVector = (value: any): value is Vector<any> => {
'worklet';
return value.x !== undefined && value.y !== undefined;
};
const isSharedValue = (
value: any,
): value is Animated.SharedValue<any> => {
'worklet';
return typeof value.value !== 'undefined';
};
const get = <T extends Animated.SharedValue<SharedValueType> | SharedValueType>(
value: T,
) => {
'worklet';
if (isSharedValue(value)) {
return value.value;
}
return value;
};
const reduce = (
operation: Operation,
prop: VectorProp,
vectors: VectorList,
) => {
'worklet';
const first = vectors[0];
const rest = vectors.slice(1);
const initial = get(isVector(first) ? first[prop] : first);
const res = rest.reduce((acc, current) => {
const value = get(isVector(current) ? current[prop] : current);
const r = (() => {
switch (operation) {
case 'divide':
if (value === 0) {
return 0;
}
return acc / value;
case 'add':
return acc + value;
case 'sub':
return acc - value;
case 'multiply':
return acc * value;
default:
return acc;
}
})();
return r;
}, initial);
return res;
};
export const useSharedVector = <T>(x: T, y = x) => {
return {
x: useSharedValue(x),
y: useSharedValue(y),
};
};
export const create = <T extends SharedValueType>(x: T, y: T) => {
'worklet';
return {
x,
y,
};
};
export const add = (...vectors: VectorList) => {
'worklet';
return {
x: reduce('add', 'x', vectors),
y: reduce('add', 'y', vectors),
};
};
export const sub = (...vectors: VectorList) => {
'worklet';
return {
x: reduce('sub', 'x', vectors),
y: reduce('sub', 'y', vectors),
};
};
export const divide = (...vectors: VectorList) => {
'worklet';
return {
x: reduce('divide', 'x', vectors),
y: reduce('divide', 'y', vectors),
};
};
export const multiply = (...vectors: VectorList) => {
'worklet';
return {
x: reduce('multiply', 'x', vectors),
y: reduce('multiply', 'y', vectors),
};
};
export const invert = <T extends Vector<any>>(vector: T) => {
'worklet';
return {
x: get(vector.x) * -1,
y: get(vector.y) * -1,
};
};
export const set = <T extends VectorType>(
vector: T,
value: VectorType | SharedValueType | Callback,
) => {
'worklet';
// handle animation
if (typeof value === 'function') {
vector.x.value = value();
vector.y.value = value();
}
const x = get(isVector(value) ? value.x : value);
const y = get(isVector(value) ? value.y : value);
if (typeof vector.x.value === 'undefined') {
vector.x = x;
vector.y = y;
} else {
vector.x.value = x;
vector.y.value = y;
}
};
export const min = (...vectors: VectorList) => {
'worklet';
const getMin = (prop: VectorProp) => {
return Math.min.apply(
undefined,
vectors.map((item) =>
get(isVector(item) ? item[prop] : item),
),
);
};
return {
x: getMin('x'),
y: getMin('y'),
};
};
export const max = (...vectors: VectorList) => {
'worklet';
const getMax = (prop: VectorProp) =>
Math.max.apply(
undefined,
vectors.map((item) =>
get(isVector(item) ? item[prop] : item),
),
);
return {
x: getMax('x'),
y: getMax('y'),
};
};
export const clamp = <T extends Vector<any>>(
value: T,
lowerBound: VectorType | SharedValueType,
upperBound: VectorType | SharedValueType,
) => {
'worklet';
return min(max(lowerBound, value), upperBound);
};
export const eq = <T extends Vector<any>>(
vector: T,
value: VectorType | SharedValueType,
) => {
'worklet';
const x = get(isVector(value) ? value.x : value);
const y = get(isVector(value) ? value.y : value);
return get(vector.x) === x && get(vector.y) === y;
};

View File

@@ -13,7 +13,7 @@ export function emptyFunction(..._args: any[]) {
}
// Generates a RFC-4122 version 4 compliant globally unique identifier.
export const generateId = (): string => {
export const generateId = (prefix?: string): string => {
// implementation taken from http://stackoverflow.com/a/2117523
let id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
id = id.replace(/[xy]/g, (c) => {
@@ -29,6 +29,11 @@ export const generateId = (): string => {
return v.toString(16);
});
if (prefix) {
return `${prefix}-${id}`;
}
return id;
};

View File

@@ -7,6 +7,8 @@ import {View} from '@constants';
import {IMAGE_MAX_HEIGHT, IMAGE_MIN_DIMENSION, MAX_GIF_SIZE, VIEWPORT_IMAGE_OFFSET, VIEWPORT_IMAGE_REPLY_OFFSET} from '@constants/image';
export const calculateDimensions = (height: number, width: number, viewPortWidth = 0, viewPortHeight = 0) => {
'worklet';
if (!height || !width) {
return {
height: 0,
@@ -20,7 +22,7 @@ export const calculateDimensions = (height: number, width: number, viewPortWidth
let imageWidth = width;
let imageHeight = height;
if (width > viewPortWidth) {
if (width >= viewPortWidth) {
imageWidth = viewPortWidth;
imageHeight = imageWidth * ratio;
} else if (width < IMAGE_MIN_DIMENSION) {

View File

@@ -269,3 +269,8 @@ export function cleanUrlForLogging(baseUrl: string, apiUrl: string): string {
return url;
}
export function extractFilenameFromUrl(url: string) {
const uri = urlParse(url);
return uri.pathname.split('/').pop();
}

View File

@@ -101,6 +101,7 @@
"custom_status.suggestions.working_from_home": "Working from home",
"date_separator.today": "Today",
"date_separator.yesterday": "Yesterday",
"download.error": "Unable to download the file. Try again later",
"edit_server.description": "Specify a display name for this server",
"edit_server.display_help": "Server: {url}",
"edit_server.save": "Save",
@@ -125,6 +126,15 @@
"emoji_skin.medium_light_skin_tone": "medium light skin tone",
"emoji_skin.medium_skin_tone": "medium skin tone",
"file_upload.fileAbove": "Files must be less than {max}",
"gallery.downloading": "Downloading...",
"gallery.footer.channel_name": "Shared in {channelName}",
"gallery.image_saved": "Image saved",
"gallery.open_file": "Open file",
"gallery.opening": "Opening...",
"gallery.preparing": "Preparing...",
"gallery.save_failed": "Unable to save the file",
"gallery.unsupported": "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
"gallery.video_saved": "Video saved",
"get_post_link_modal.title": "Copy Link",
"home.header.plus_menu": "Options",
"intro.add_people": "Add People",
@@ -221,6 +231,7 @@
"mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.",
"mobile.files_paste.error_dismiss": "Dismiss",
"mobile.files_paste.error_title": "Paste failed",
"mobile.gallery.title": "{index} of {total}",
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.",
@@ -347,6 +358,7 @@
"mobile.unsupported_server.message": "Attachments, link previews, reactions and embed data may not be displayed correctly. If this issue persists contact your System Administrator to upgrade your Mattermost server.",
"mobile.unsupported_server.ok": "OK",
"mobile.unsupported_server.title": "Unsupported server version",
"mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.",
"mobile.youtube_playback_error.description": "An error occurred while trying to play the YouTube video.\nDetails: {details}",
"mobile.youtube_playback_error.title": "YouTube playback error",
"modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?",
@@ -392,6 +404,7 @@
"post_message_view.edited": "(edited)",
"post.options.title": "Options",
"posts_view.newMsg": "New Messages",
"public_link_copied": "Link copied to clipboard",
"screen.mentions.subtitle": "Messages you've been mentioned in",
"screen.mentions.title": "Recent Mentions",
"screen.search.placeholder": "Search messages & files",

Some files were not shown because too many files have changed in this diff Show More