[Gekidou] Gallery (#6008)

* Gallery screen (ground work)

* Open the gallery from posts

* Open the gallery from post draft

* feedback review

* Feedback review 2

* do not remove dm channel names and localization fix

* update to the latest network-client

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

* bring back ScrollView wrapper for message component

* Remove Text wrapper for markdown paragraph

* Fix YouTube play icon placeholder

* Make video file play button container round

* Add gif image placeholder

* Save images & videos to camera roll

* Feedback review 3

* load video thumbnail when post is in viewport

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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