forked from Ivasoft/mattermost-mobile
[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:
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
13
app/actions/local/file.ts
Normal 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});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
app/components/freeze_screen/index.tsx
Normal file
37
app/components/freeze_screen/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -226,6 +226,7 @@ export default function SendHandler({
|
||||
<DraftInput
|
||||
testID={testID}
|
||||
channelId={channelId}
|
||||
currentUserId={currentUserId}
|
||||
rootId={rootId}
|
||||
cursorPosition={cursorPosition}
|
||||
updateCursorPosition={updateCursorPosition}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
app/components/post_list/post/body/files/index.ts
Normal file
43
app/components/post_list/post/body/files/index.ts
Normal 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));
|
||||
187
app/components/post_list/post/body/files/video_file.tsx
Normal file
187
app/components/post_list/post/body/files/video_file.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
82
app/components/toast/index.tsx
Normal file
82
app/components/toast/index.tsx
Normal 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;
|
||||
|
||||
@@ -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
5
app/constants/gallery.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
164
app/context/gallery/index.tsx
Normal file
164
app/context/gallery/index.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
28
app/hooks/freeze.ts
Normal 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
257
app/hooks/gallery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
125
app/screens/gallery/document_renderer/document_renderer.tsx
Normal file
125
app/screens/gallery/document_renderer/document_renderer.tsx
Normal 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;
|
||||
35
app/screens/gallery/document_renderer/index.ts
Normal file
35
app/screens/gallery/document_renderer/index.ts
Normal 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));
|
||||
54
app/screens/gallery/footer/actions/action.tsx
Normal file
54
app/screens/gallery/footer/actions/action.tsx
Normal 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;
|
||||
64
app/screens/gallery/footer/actions/index.tsx
Normal file
64
app/screens/gallery/footer/actions/index.tsx
Normal 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;
|
||||
78
app/screens/gallery/footer/avatar/index.tsx
Normal file
78
app/screens/gallery/footer/avatar/index.tsx
Normal 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;
|
||||
|
||||
91
app/screens/gallery/footer/copy_public_link/index.tsx
Normal file
91
app/screens/gallery/footer/copy_public_link/index.tsx
Normal 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;
|
||||
79
app/screens/gallery/footer/details/index.tsx
Normal file
79
app/screens/gallery/footer/details/index.tsx
Normal 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;
|
||||
325
app/screens/gallery/footer/download_with_action/index.tsx
Normal file
325
app/screens/gallery/footer/download_with_action/index.tsx
Normal 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;
|
||||
154
app/screens/gallery/footer/footer.tsx
Normal file
154
app/screens/gallery/footer/footer.tsx
Normal 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;
|
||||
94
app/screens/gallery/footer/index.ts
Normal file
94
app/screens/gallery/footer/index.ts
Normal 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));
|
||||
213
app/screens/gallery/gallery.tsx
Normal file
213
app/screens/gallery/gallery.tsx
Normal 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;
|
||||
82
app/screens/gallery/header/index.tsx
Normal file
82
app/screens/gallery/header/index.tsx
Normal 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;
|
||||
55
app/screens/gallery/image_renderer/index.tsx
Normal file
55
app/screens/gallery/image_renderer/index.tsx
Normal 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;
|
||||
583
app/screens/gallery/image_renderer/transformer.tsx
Normal file
583
app/screens/gallery/image_renderer/transformer.tsx
Normal 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);
|
||||
|
||||
102
app/screens/gallery/index.tsx
Normal file
102
app/screens/gallery/index.tsx
Normal 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;
|
||||
38
app/screens/gallery/lightbox_swipeout/backdrop.tsx
Normal file
38
app/screens/gallery/lightbox_swipeout/backdrop.tsx
Normal 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;
|
||||
352
app/screens/gallery/lightbox_swipeout/index.tsx
Normal file
352
app/screens/gallery/lightbox_swipeout/index.tsx
Normal 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;
|
||||
17
app/screens/gallery/pager/gutter.tsx
Normal file
17
app/screens/gallery/pager/gutter.tsx
Normal 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;
|
||||
400
app/screens/gallery/pager/index.tsx
Normal file
400
app/screens/gallery/pager/index.tsx
Normal 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);
|
||||
92
app/screens/gallery/pager/page.tsx
Normal file
92
app/screens/gallery/pager/page.tsx
Normal 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;
|
||||
161
app/screens/gallery/video_renderer/index.tsx
Normal file
161
app/screens/gallery/video_renderer/index.tsx
Normal 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;
|
||||
138
app/screens/gallery/viewer/index.tsx
Normal file
138
app/screens/gallery/viewer/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
232
app/utils/gallery/vectors.ts
Normal file
232
app/utils/gallery/vectors.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user