forked from Ivasoft/mattermost-mobile
[Gekidou] Gallery (#6008)
* Gallery screen (ground work) * Open the gallery from posts * Open the gallery from post draft * feedback review * Feedback review 2 * do not remove dm channel names and localization fix * update to the latest network-client * do not override file width, height and imageThumbail if received file does not have it set * bring back ScrollView wrapper for message component * Remove Text wrapper for markdown paragraph * Fix YouTube play icon placeholder * Make video file play button container round * Add gif image placeholder * Save images & videos to camera roll * Feedback review 3 * load video thumbnail when post is in viewport * simplify prefix
This commit is contained in:
37
app/components/freeze_screen/index.tsx
Normal file
37
app/components/freeze_screen/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Freeze} from 'react-freeze';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import useFreeze from '@hooks/freeze';
|
||||
|
||||
type FreezePlaceholderProps = {
|
||||
backgroundColor: string;
|
||||
};
|
||||
|
||||
type FreezeScreenProps = {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FreezePlaceholder = ({backgroundColor}: FreezePlaceholderProps) => {
|
||||
return <View style={{flex: 1, backgroundColor}}/>;
|
||||
};
|
||||
|
||||
const FreezeScreen = ({children}: FreezeScreenProps) => {
|
||||
const {freeze, backgroundColor} = useFreeze();
|
||||
|
||||
const placeholder = (<FreezePlaceholder backgroundColor={backgroundColor}/>);
|
||||
|
||||
return (
|
||||
<Freeze
|
||||
freeze={freeze}
|
||||
placeholder={placeholder}
|
||||
>
|
||||
{children}
|
||||
</Freeze>
|
||||
);
|
||||
};
|
||||
|
||||
export default FreezeScreen;
|
||||
@@ -9,6 +9,7 @@ import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
@@ -172,7 +173,7 @@ const AtMention = ({
|
||||
let isMention = false;
|
||||
let mention;
|
||||
let onLongPress;
|
||||
let onPress;
|
||||
let onPress: (e?: GestureResponderEvent) => void;
|
||||
let suffix;
|
||||
let suffixElement;
|
||||
let styleText;
|
||||
@@ -204,7 +205,7 @@ const AtMention = ({
|
||||
|
||||
if (canPress) {
|
||||
onLongPress = handleLongPress;
|
||||
onPress = isSearchResult ? onPostPress : goToUserProfile;
|
||||
onPress = (isSearchResult ? onPostPress : goToUserProfile) as (e?: GestureResponderEvent) => void;
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
@@ -225,16 +226,17 @@ const AtMention = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={styleText}
|
||||
onPress={onPress}
|
||||
<TouchableOpacity
|
||||
onPress={onPress!}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<Text style={mentionTextStyle}>
|
||||
{'@' + mention}
|
||||
<Text style={styleText}>
|
||||
<Text style={mentionTextStyle}>
|
||||
{'@' + mention}
|
||||
</Text>
|
||||
{suffixElement}
|
||||
</Text>
|
||||
{suffixElement}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import withObservables from '@nozbe/with-observables';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleProp, Text, TextStyle} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {joinChannel, switchToChannelById} from '@actions/remote/channel';
|
||||
@@ -111,15 +112,14 @@ const ChannelMention = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={textStyle}>
|
||||
<Text
|
||||
onPress={handlePress}
|
||||
style={linkStyle}
|
||||
>
|
||||
{`~${channel.display_name}`}
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<Text style={textStyle}>
|
||||
<Text style={linkStyle}>
|
||||
{`~${channel.display_name}`}
|
||||
</Text>
|
||||
{suffix}
|
||||
</Text>
|
||||
{suffix}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TextStyle} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import {popToRoot, showSearchModal, dismissAllModals} from '@screens/navigation';
|
||||
|
||||
@@ -21,12 +22,11 @@ const Hashtag = ({hashtag, linkStyle}: HashtagProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={linkStyle}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{`#${hashtag}`}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<Text style={linkStyle}>
|
||||
{`#${hashtag}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type MarkdownProps = {
|
||||
isEdited?: boolean;
|
||||
isReplyPost?: boolean;
|
||||
isSearchResult?: boolean;
|
||||
location?: string;
|
||||
mentionKeys?: UserMentionKey[];
|
||||
minimumHashtagLength?: number;
|
||||
onPostPress?: (event: GestureResponderEvent) => void;
|
||||
@@ -190,8 +191,9 @@ class Markdown extends PureComponent<MarkdownProps> {
|
||||
// We have enough problems rendering images as is, so just render a link inside of a table
|
||||
return (
|
||||
<MarkdownTableImage
|
||||
disabled={this.props.disableGallery}
|
||||
disabled={this.props.disableGallery ?? Boolean(!this.props.location)}
|
||||
imagesMetadata={this.props.imagesMetadata}
|
||||
location={this.props.location}
|
||||
postId={this.props.postId!}
|
||||
source={src}
|
||||
/>
|
||||
@@ -200,11 +202,12 @@ class Markdown extends PureComponent<MarkdownProps> {
|
||||
|
||||
return (
|
||||
<MarkdownImage
|
||||
disabled={this.props.disableGallery}
|
||||
disabled={this.props.disableGallery ?? Boolean(!this.props.location)}
|
||||
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
|
||||
linkDestination={linkDestination}
|
||||
imagesMetadata={this.props.imagesMetadata}
|
||||
isReplyPost={this.props.isReplyPost}
|
||||
location={this.props.location}
|
||||
postId={this.props.postId!}
|
||||
source={src}
|
||||
/>
|
||||
@@ -283,9 +286,7 @@ class Markdown extends PureComponent<MarkdownProps> {
|
||||
|
||||
return (
|
||||
<View style={blockStyle}>
|
||||
<Text>
|
||||
{children}
|
||||
</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,10 @@ import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {bottomSheet, dismissBottomSheet, goToScreen} from '@screens/navigation';
|
||||
import {getDisplayNameForLanguage} from '@utils/markdown';
|
||||
@@ -226,10 +226,9 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<View style={style.container}>
|
||||
<View style={style.lineNumbers}>
|
||||
@@ -245,7 +244,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
|
||||
</View>
|
||||
{renderLanguageBlock()}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
import {LongPressGestureHandler, TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import {GalleryInit} from '@app/context/gallery';
|
||||
import {useGalleryItem} from '@app/hooks/gallery';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
@@ -17,7 +21,8 @@ import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {normalizeProtocol, tryOpenURL} from '@utils/url';
|
||||
@@ -28,6 +33,7 @@ type MarkdownImageProps = {
|
||||
imagesMetadata?: Record<string, PostImage>;
|
||||
isReplyPost?: boolean;
|
||||
linkDestination?: string;
|
||||
location?: string;
|
||||
postId: string;
|
||||
source: string;
|
||||
}
|
||||
@@ -50,42 +56,63 @@ const style = StyleSheet.create({
|
||||
|
||||
const MarkdownImage = ({
|
||||
disabled, errorTextStyle, imagesMetadata, isReplyPost = false,
|
||||
linkDestination, postId, source,
|
||||
linkDestination, location, postId, source,
|
||||
}: MarkdownImageProps) => {
|
||||
const intl = useIntl();
|
||||
const isTablet = useIsTablet();
|
||||
const theme = useTheme();
|
||||
const managedConfig = useManagedConfig();
|
||||
const genericFileId = useRef(generateId()).current;
|
||||
const genericFileId = useRef(generateId('uid')).current;
|
||||
const tapRef = useRef<TapGestureHandler>();
|
||||
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0];
|
||||
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
||||
const originalSize = {width: metadata?.width || 0, height: metadata?.height || 0};
|
||||
const serverUrl = useServerUrl();
|
||||
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
|
||||
const uri = useMemo(() => {
|
||||
if (source.startsWith('/')) {
|
||||
return serverUrl + source;
|
||||
}
|
||||
|
||||
let uri = source;
|
||||
if (uri.startsWith('/')) {
|
||||
uri = serverUrl + uri;
|
||||
}
|
||||
return source;
|
||||
}, [source, serverUrl]);
|
||||
|
||||
const link = decodeURIComponent(uri);
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
const fileInfo = useMemo(() => {
|
||||
const link = decodeURIComponent(uri);
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
|
||||
const fileInfo = {
|
||||
id: genericFileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri: link,
|
||||
width: originalSize.width,
|
||||
height: originalSize.height,
|
||||
};
|
||||
return {
|
||||
id: genericFileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri: link,
|
||||
width: originalSize.width,
|
||||
height: originalSize.height,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePreviewImage = useCallback(() => {
|
||||
const item: GalleryItemType = {
|
||||
...fileInfo,
|
||||
mime_type: lookupMimeType(fileInfo.name),
|
||||
type: 'image',
|
||||
};
|
||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||
}, []);
|
||||
|
||||
const {ref, onGestureEvent, styles} = useGalleryItem(
|
||||
galleryIdentifier,
|
||||
0,
|
||||
handlePreviewImage,
|
||||
);
|
||||
|
||||
const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, isTablet));
|
||||
|
||||
@@ -150,10 +177,6 @@ const MarkdownImage = ({
|
||||
}
|
||||
}, [managedConfig, intl, theme]);
|
||||
|
||||
const handlePreviewImage = useCallback(() => {
|
||||
openGallerWithMockFile(fileInfo.uri, postId, fileInfo.height, fileInfo.width, fileInfo.id);
|
||||
}, []);
|
||||
|
||||
const handleOnError = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
@@ -186,20 +209,30 @@ const MarkdownImage = ({
|
||||
);
|
||||
} else {
|
||||
image = (
|
||||
<TouchableWithFeedback
|
||||
disabled={disabled}
|
||||
onLongPress={handleLinkLongPress}
|
||||
onPress={handlePreviewImage}
|
||||
style={[{width, height}, style.container]}
|
||||
<LongPressGestureHandler
|
||||
enabled={!disabled}
|
||||
onGestureEvent={handleLinkLongPress}
|
||||
waitFor={tapRef}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileInfo.id}
|
||||
defaultSource={{uri: fileInfo.uri}}
|
||||
onError={handleOnError}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
<Animated.View style={[styles, {width, height}, style.container]}>
|
||||
<TapGestureHandler
|
||||
enabled={!disabled}
|
||||
onGestureEvent={onGestureEvent}
|
||||
ref={tapRef}
|
||||
>
|
||||
<Animated.View testID='markdown_image'>
|
||||
<ProgressiveImage
|
||||
forwardRef={ref}
|
||||
id={fileInfo.id}
|
||||
defaultSource={{uri: fileInfo.uri}}
|
||||
onError={handleOnError}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</LongPressGestureHandler>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -211,15 +244,23 @@ const MarkdownImage = ({
|
||||
onLongPress={handleLinkLongPress}
|
||||
style={[{width, height}, style.container]}
|
||||
>
|
||||
{image}
|
||||
<ProgressiveImage
|
||||
id={fileInfo.id}
|
||||
defaultSource={{uri: fileInfo.uri}}
|
||||
onError={handleOnError}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID='markdown_image'>
|
||||
{image}
|
||||
</View>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View testID='markdown_image'>
|
||||
{image}
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {Children, ReactElement, useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, StyleSheet, Text, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import urlParse from 'url-parse';
|
||||
@@ -164,12 +165,14 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
|
||||
const renderChildren = experimentalNormalizeMarkdownLinks ? parseChildren() : children;
|
||||
|
||||
return (
|
||||
<Text
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
>
|
||||
{renderChildren}
|
||||
</Text>
|
||||
<Text>
|
||||
{renderChildren}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import React, {PureComponent, ReactNode} from 'react';
|
||||
import {injectIntl, IntlShape} from 'react-intl';
|
||||
import {Dimensions, EventSubscription, LayoutChangeEvent, Platform, ScaledSize, ScrollView, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {CELL_MAX_WIDTH, CELL_MIN_WIDTH} from '@components/markdown/markdown_table_cell';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import DeviceTypes from '@constants/device';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
@@ -244,8 +244,7 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
|
||||
let expandButton = null;
|
||||
if (expandButtonOffset > 0) {
|
||||
expandButton = (
|
||||
<TouchableWithFeedback
|
||||
type='opacity'
|
||||
<TouchableOpacity
|
||||
onPress={this.handlePress}
|
||||
style={[style.expandButton, {left: expandButtonOffset}]}
|
||||
testID='markdown_table.expand.button'
|
||||
@@ -258,15 +257,14 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
style={style.tablePadding}
|
||||
onPress={this.handlePress}
|
||||
type='opacity'
|
||||
testID='markdown_table'
|
||||
>
|
||||
<ScrollView
|
||||
@@ -281,7 +279,7 @@ class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState
|
||||
{moreRight}
|
||||
{moreBelow}
|
||||
{expandButton}
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,36 +3,40 @@
|
||||
|
||||
import React, {memo, useCallback, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
||||
|
||||
type MarkdownTableImageProps = {
|
||||
disabled?: boolean;
|
||||
imagesMetadata: Record<string, PostImage>;
|
||||
location?: string;
|
||||
postId: string;
|
||||
serverURL?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: MarkdownTableImageProps) => {
|
||||
const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL, source}: MarkdownTableImageProps) => {
|
||||
const metadata = imagesMetadata[source];
|
||||
const fileId = useRef(generateId()).current;
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
||||
const currentServerUrl = useServerUrl();
|
||||
const galleryIdentifier = `${postId}-${fileId}-${location}`;
|
||||
|
||||
const getImageSource = () => {
|
||||
let uri = source;
|
||||
@@ -78,9 +82,19 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M
|
||||
if (!file?.uri) {
|
||||
return;
|
||||
}
|
||||
openGallerWithMockFile(file.uri, file.post_id, file.height, file.width, file.id);
|
||||
const item: GalleryItemType = {
|
||||
...fileToGalleryItem(file),
|
||||
type: 'image',
|
||||
};
|
||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||
}, []);
|
||||
|
||||
const {ref, onGestureEvent, styles} = useGalleryItem(
|
||||
galleryIdentifier,
|
||||
0,
|
||||
handlePreviewImage,
|
||||
);
|
||||
|
||||
const onLoadFailed = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
@@ -96,24 +110,29 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M
|
||||
} else {
|
||||
const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100);
|
||||
image = (
|
||||
<TouchableWithFeedback
|
||||
disabled={disabled}
|
||||
onPress={handlePreviewImage}
|
||||
style={{width, height}}
|
||||
<TapGestureHandler
|
||||
enabled={!disabled}
|
||||
onGestureEvent={onGestureEvent}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
defaultSource={{uri: source}}
|
||||
onError={onLoadFailed}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
<Animated.View
|
||||
style={[styles, {width, height}]}
|
||||
testID='markdown_table_image'
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
defaultSource={{uri: source}}
|
||||
forwardRef={ref}
|
||||
onError={onLoadFailed}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={style.container}>
|
||||
{image}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ type Props = {
|
||||
testID?: string;
|
||||
channelId: string;
|
||||
rootId?: string;
|
||||
currentUserId: string;
|
||||
|
||||
// Cursor Position Handler
|
||||
updateCursorPosition: (pos: number) => void;
|
||||
@@ -77,6 +78,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
export default function DraftInput({
|
||||
testID,
|
||||
channelId,
|
||||
currentUserId,
|
||||
files,
|
||||
maxMessageLength,
|
||||
rootId = '',
|
||||
@@ -139,6 +141,7 @@ export default function DraftInput({
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
<Uploads
|
||||
currentUserId={currentUserId}
|
||||
files={files}
|
||||
uploadFileError={uploadFileError}
|
||||
channelId={channelId}
|
||||
|
||||
@@ -226,6 +226,7 @@ export default function SendHandler({
|
||||
<DraftInput
|
||||
testID={testID}
|
||||
channelId={channelId}
|
||||
currentUserId={currentUserId}
|
||||
rootId={rootId}
|
||||
cursorPosition={cursorPosition}
|
||||
updateCursorPosition={updateCursorPosition}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useTheme} from '@context/theme';
|
||||
import DraftUploadManager from '@init/draft_upload_manager';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import UploadItem from './upload_item';
|
||||
@@ -23,6 +24,7 @@ const ERROR_HEIGHT_MAX = 20;
|
||||
const ERROR_HEIGHT_MIN = 0;
|
||||
|
||||
type Props = {
|
||||
currentUserId: string;
|
||||
files: FileInfo[];
|
||||
uploadFileError: React.ReactNode;
|
||||
channelId: string;
|
||||
@@ -67,16 +69,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
});
|
||||
|
||||
export default function Uploads({
|
||||
currentUserId,
|
||||
files,
|
||||
uploadFileError,
|
||||
channelId,
|
||||
rootId,
|
||||
}: Props) {
|
||||
const galleryIdentifier = `${channelId}-uploadedItems-${rootId}`;
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const errorHeight = useSharedValue(ERROR_HEIGHT_MIN);
|
||||
const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX);
|
||||
const filesForGallery = useRef(files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)));
|
||||
|
||||
const errorAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -90,9 +95,13 @@ export default function Uploads({
|
||||
};
|
||||
});
|
||||
|
||||
const fileContainerStyle = {
|
||||
const fileContainerStyle = useMemo(() => ({
|
||||
paddingBottom: files.length ? 5 : 0,
|
||||
};
|
||||
}), [files.length]);
|
||||
|
||||
useEffect(() => {
|
||||
filesForGallery.current = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadFileError) {
|
||||
@@ -111,19 +120,21 @@ export default function Uploads({
|
||||
}, [files.length > 0]);
|
||||
|
||||
const openGallery = useCallback((file: FileInfo) => {
|
||||
const galleryFiles = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
|
||||
const index = galleryFiles.indexOf(file);
|
||||
openGalleryAtIndex(index, galleryFiles);
|
||||
}, [files]);
|
||||
const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId));
|
||||
const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId);
|
||||
openGalleryAtIndex(galleryIdentifier, index, items, true);
|
||||
}, [currentUserId, files]);
|
||||
|
||||
const buildFilePreviews = () => {
|
||||
return files.map((file) => {
|
||||
return files.map((file, index) => {
|
||||
return (
|
||||
<UploadItem
|
||||
key={file.clientId}
|
||||
file={file}
|
||||
openGallery={openGallery}
|
||||
channelId={channelId}
|
||||
galleryIdentifier={galleryIdentifier}
|
||||
index={index}
|
||||
file={file}
|
||||
key={file.clientId}
|
||||
openGallery={openGallery}
|
||||
rootId={rootId}
|
||||
/>
|
||||
);
|
||||
@@ -131,33 +142,35 @@ export default function Uploads({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style.previewContainer}>
|
||||
<Animated.View
|
||||
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
style={style.scrollView}
|
||||
contentContainerStyle={style.scrollViewContent}
|
||||
keyboardShouldPersistTaps={'handled'}
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<View style={style.previewContainer}>
|
||||
<Animated.View
|
||||
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
|
||||
>
|
||||
{buildFilePreviews()}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
style={style.scrollView}
|
||||
contentContainerStyle={style.scrollViewContent}
|
||||
keyboardShouldPersistTaps={'handled'}
|
||||
>
|
||||
{buildFilePreviews()}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[style.errorContainer, errorAnimatedStyle]}
|
||||
>
|
||||
{Boolean(uploadFileError) &&
|
||||
<View style={style.errorTextContainer}>
|
||||
<Animated.View
|
||||
style={[style.errorContainer, errorAnimatedStyle]}
|
||||
>
|
||||
{Boolean(uploadFileError) &&
|
||||
<View style={style.errorTextContainer}>
|
||||
|
||||
<Text style={style.warning}>
|
||||
{uploadFileError}
|
||||
</Text>
|
||||
<Text style={style.warning}>
|
||||
{uploadFileError}
|
||||
</Text>
|
||||
|
||||
</View>
|
||||
}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GalleryInit>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import {updateDraftFile} from '@actions/local/draft';
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
@@ -11,6 +13,7 @@ import ProgressBar from '@components/progress_bar';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import DraftUploadManager from '@init/draft_upload_manager';
|
||||
import {isImage} from '@utils/file';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
@@ -19,13 +22,15 @@ import UploadRemove from './upload_remove';
|
||||
import UploadRetry from './upload_retry';
|
||||
|
||||
type Props = {
|
||||
file: FileInfo;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
galleryIdentifier: string;
|
||||
index: number;
|
||||
file: FileInfo;
|
||||
openGallery: (file: FileInfo) => void;
|
||||
rootId: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const style = StyleSheet.create({
|
||||
preview: {
|
||||
paddingTop: 5,
|
||||
marginLeft: 12,
|
||||
@@ -52,10 +57,8 @@ const styles = StyleSheet.create({
|
||||
});
|
||||
|
||||
export default function UploadItem({
|
||||
file,
|
||||
channelId,
|
||||
rootId,
|
||||
openGallery,
|
||||
channelId, galleryIdentifier, index, file,
|
||||
rootId, openGallery,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
@@ -101,11 +104,14 @@ export default function UploadItem({
|
||||
DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
|
||||
}, [serverUrl, channelId, rootId, file]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress);
|
||||
|
||||
const filePreviewComponent = useMemo(() => {
|
||||
if (isImage(file)) {
|
||||
return (
|
||||
<ImageFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
resizeMode='cover'
|
||||
/>
|
||||
);
|
||||
@@ -122,21 +128,21 @@ export default function UploadItem({
|
||||
return (
|
||||
<View
|
||||
key={file.clientId}
|
||||
style={styles.preview}
|
||||
style={style.preview}
|
||||
>
|
||||
<View style={styles.previewContainer}>
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<View style={styles.filePreview}>
|
||||
<View style={style.previewContainer}>
|
||||
<TapGestureHandler onGestureEvent={onGestureEvent}>
|
||||
<Animated.View style={[styles, style.filePreview]}>
|
||||
{filePreviewComponent}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
{file.failed &&
|
||||
<UploadRetry
|
||||
onPress={retryFileUpload}
|
||||
/>
|
||||
}
|
||||
{loading && !file.failed &&
|
||||
<View style={styles.progress}>
|
||||
<View style={style.progress}>
|
||||
<ProgressBar
|
||||
progress={progress || 0}
|
||||
color={theme.buttonBg}
|
||||
|
||||
@@ -7,13 +7,13 @@ import React, {ReactNode, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, StyleSheet, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import SystemAvatar from '@components/system_avatar';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {View as ViewConstant} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useServerUrl} from '@context/server';
|
||||
@@ -140,12 +140,9 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, po
|
||||
|
||||
if (!fromWebHook) {
|
||||
component = (
|
||||
<TouchableWithFeedback
|
||||
onPress={onViewUserProfile}
|
||||
type={'opacity'}
|
||||
>
|
||||
<TouchableOpacity onPress={onViewUserProfile}>
|
||||
{component}
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import withObservables from '@nozbe/with-observables';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Text} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
@@ -181,16 +182,14 @@ const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) =>
|
||||
defaultMessage={outOfChannelMessageText}
|
||||
style={styles.message}
|
||||
/>
|
||||
<Text
|
||||
style={textStyles.link}
|
||||
testID='add_channel_member_link'
|
||||
onPress={handleAddChannelMember}
|
||||
>
|
||||
<TouchableOpacity onPress={handleAddChannelMember}>
|
||||
<FormattedText
|
||||
id={linkId}
|
||||
defaultMessage={linkText}
|
||||
style={textStyles.link}
|
||||
testID='add_channel_member_link'
|
||||
/>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<FormattedText
|
||||
id={'post_body.check_for_out_of_channel_mentions.message_last'}
|
||||
defaultMessage={'? They will have access to all message history.'}
|
||||
|
||||
@@ -5,23 +5,26 @@ import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {Animated, StyleSheet, View} from 'react-native';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {getRedirectLocation} from '@actions/remote/general';
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {isImageLink, isValidUrl} from '@utils/url';
|
||||
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
@@ -30,12 +33,13 @@ type ImagePreviewProps = {
|
||||
expandedLink?: string;
|
||||
isReplyPost: boolean;
|
||||
link: string;
|
||||
location: string;
|
||||
metadata: PostMetadata;
|
||||
postId: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const style = StyleSheet.create({
|
||||
imageContainer: {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
@@ -50,10 +54,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}: ImagePreviewProps) => {
|
||||
const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, postId, theme}: ImagePreviewProps) => {
|
||||
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
|
||||
const [error, setError] = useState(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const fileId = useRef(generateId()).current;
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const [imageUrl, setImageUrl] = useState(expandedLink || link);
|
||||
const isTablet = useIsTablet();
|
||||
const imageProps = metadata.images![link];
|
||||
@@ -63,9 +68,25 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
|
||||
setError(true);
|
||||
}, []);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
openGallerWithMockFile(imageUrl, postId, imageProps.height, imageProps.width, fileId);
|
||||
}, [imageUrl]);
|
||||
const onPress = () => {
|
||||
const item: GalleryItemType = {
|
||||
id: fileId,
|
||||
postId,
|
||||
uri: imageUrl,
|
||||
width: imageProps.width,
|
||||
height: imageProps.height,
|
||||
name: extractFilenameFromUrl(imageUrl) || 'imagePreview.png',
|
||||
mime_type: lookupMimeType(imageUrl) || 'images/png',
|
||||
type: 'image',
|
||||
};
|
||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||
};
|
||||
|
||||
const {ref, onGestureEvent, styles} = useGalleryItem(
|
||||
galleryIdentifier,
|
||||
0,
|
||||
onPress,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImageLink(link) && expandedLink === undefined) {
|
||||
@@ -89,8 +110,8 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
|
||||
|
||||
if (error || !isValidUrl(expandedLink || link) || isGifTooLarge(imageProps)) {
|
||||
return (
|
||||
<View style={[styles.imageContainer, {height: dimensions.height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
|
||||
<View style={[styles.image, {width: dimensions.width, height: dimensions.height}]}>
|
||||
<View style={[style.imageContainer, {height: dimensions.height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
|
||||
<View style={[style.image, {width: dimensions.width, height: dimensions.height}]}>
|
||||
<FileIcon
|
||||
failed={true}
|
||||
/>
|
||||
@@ -99,23 +120,23 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
|
||||
);
|
||||
}
|
||||
|
||||
// Note that the onPress prop of TouchableWithoutFeedback only works if its child is a View
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
style={[styles.imageContainer, {height: dimensions.height}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
style={[styles.image, {width: dimensions.width, height: dimensions.height}]}
|
||||
imageUri={imageUrl}
|
||||
resizeMode='contain'
|
||||
onError={onError}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={[styles, style.imageContainer, {height: dimensions.height}]}>
|
||||
<TapGestureHandler onGestureEvent={onGestureEvent}>
|
||||
<Animated.View testID={`ImagePreview-${fileId}`}>
|
||||
<ProgressiveImage
|
||||
forwardRef={ref}
|
||||
id={fileId}
|
||||
imageUri={imageUrl}
|
||||
onError={onError}
|
||||
resizeMode='contain'
|
||||
style={[style.image, {width: dimensions.width, height: dimensions.height}]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type ContentProps = {
|
||||
isReplyPost: boolean;
|
||||
location: string;
|
||||
post: PostModel;
|
||||
theme: Theme;
|
||||
}
|
||||
@@ -27,7 +28,7 @@ const contentType: Record<string, string> = {
|
||||
youtube: 'youtube',
|
||||
};
|
||||
|
||||
const Content = ({isReplyPost, post, theme}: ContentProps) => {
|
||||
const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
|
||||
let type: string = post.metadata?.embeds?.[0].type as string;
|
||||
if (!type && post.props?.attachments?.length) {
|
||||
type = contentType.app_bindings;
|
||||
@@ -42,6 +43,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
|
||||
return (
|
||||
<ImagePreview
|
||||
isReplyPost={isReplyPost}
|
||||
location={location}
|
||||
metadata={post.metadata!}
|
||||
postId={post.id}
|
||||
theme={theme}
|
||||
@@ -60,6 +62,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
|
||||
return (
|
||||
<Opengraph
|
||||
isReplyPost={isReplyPost}
|
||||
location={location}
|
||||
metadata={post.metadata!}
|
||||
postId={post.id}
|
||||
removeLinkPreview={post.props?.remove_link_preview === 'true'}
|
||||
@@ -71,6 +74,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => {
|
||||
return (
|
||||
<MessageAttachments
|
||||
attachments={post.props.attachments}
|
||||
location={location}
|
||||
metadata={post.metadata!}
|
||||
postId={post.id}
|
||||
theme={theme}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {tryOpenURL} from '@utils/url';
|
||||
@@ -68,13 +69,14 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
|
||||
/>
|
||||
}
|
||||
{Boolean(name) &&
|
||||
<Text
|
||||
key='author_name'
|
||||
style={[style.name, Boolean(link) && style.link]}
|
||||
onPress={openLink}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={openLink}>
|
||||
<Text
|
||||
key='author_name'
|
||||
style={[style.name, Boolean(link) && style.link]}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import {View} from 'react-native';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {isValidUrl} from '@utils/url';
|
||||
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
@@ -41,13 +45,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
export type Props = {
|
||||
imageMetadata: PostImage;
|
||||
imageUrl: string;
|
||||
location: string;
|
||||
postId: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
|
||||
const AttachmentImage = ({imageUrl, imageMetadata, location, postId, theme}: Props) => {
|
||||
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
|
||||
const [error, setError] = useState(false);
|
||||
const fileId = useRef(generateId()).current;
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const isTablet = useIsTablet();
|
||||
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, isTablet));
|
||||
const style = getStyleSheet(theme);
|
||||
@@ -56,9 +62,25 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
|
||||
setError(true);
|
||||
}, []);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
openGallerWithMockFile(imageUrl, postId, imageMetadata.height, imageMetadata.width);
|
||||
}, [imageUrl]);
|
||||
const onPress = () => {
|
||||
const item: GalleryItemType = {
|
||||
id: fileId,
|
||||
postId,
|
||||
uri: imageUrl,
|
||||
width: imageMetadata.width,
|
||||
height: imageMetadata.height,
|
||||
name: extractFilenameFromUrl(imageUrl) || 'attachmentImage.png',
|
||||
mime_type: lookupMimeType(imageUrl) || 'images/png',
|
||||
type: 'image',
|
||||
};
|
||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||
};
|
||||
|
||||
const {ref, onGestureEvent, styles} = useGalleryItem(
|
||||
galleryIdentifier,
|
||||
0,
|
||||
onPress,
|
||||
);
|
||||
|
||||
if (error || !isValidUrl(imageUrl) || isGifTooLarge(imageMetadata)) {
|
||||
return (
|
||||
@@ -73,24 +95,23 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
style={[style.container, {width}]}
|
||||
type={'none'}
|
||||
>
|
||||
<View
|
||||
style={[style.imageContainer, {width, height}]}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
imageStyle={style.attachmentMargin}
|
||||
imageUri={imageUrl}
|
||||
onError={onError}
|
||||
resizeMode='contain'
|
||||
style={{height, width}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={[styles, style.container, {width}]}>
|
||||
<TapGestureHandler onGestureEvent={onGestureEvent}>
|
||||
<Animated.View testID={`attachmentImage-${fileId}`}>
|
||||
<ProgressiveImage
|
||||
forwardRef={ref}
|
||||
id={fileId}
|
||||
imageStyle={style.attachmentMargin}
|
||||
imageUri={imageUrl}
|
||||
onError={onError}
|
||||
resizeMode='contain'
|
||||
style={{height, width}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
@@ -59,12 +60,11 @@ const AttachmentTitle = ({link, theme, value}: Props) => {
|
||||
let title;
|
||||
if (link) {
|
||||
title = (
|
||||
<Text
|
||||
style={[style.title, Boolean(link) && style.link]}
|
||||
onPress={openLink}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={openLink}>
|
||||
<Text style={[style.title, Boolean(link) && style.link]}>
|
||||
{value}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
title = (
|
||||
|
||||
@@ -8,8 +8,9 @@ import MessageAttachment from './message_attachment';
|
||||
|
||||
type Props = {
|
||||
attachments: MessageAttachment[];
|
||||
postId: string;
|
||||
location: string;
|
||||
metadata?: PostMetadata;
|
||||
postId: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
@@ -20,7 +21,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => {
|
||||
const MessageAttachments = ({attachments, location, metadata, postId, theme}: Props) => {
|
||||
const content = [] as React.ReactNode[];
|
||||
|
||||
attachments.forEach((attachment, i) => {
|
||||
@@ -28,6 +29,7 @@ const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => {
|
||||
<MessageAttachment
|
||||
attachment={attachment}
|
||||
key={'att_' + i.toString()}
|
||||
location={location}
|
||||
metadata={metadata}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
|
||||
@@ -21,6 +21,7 @@ import AttachmentTitle from './attachment_title';
|
||||
|
||||
type Props = {
|
||||
attachment: MessageAttachment;
|
||||
location: string;
|
||||
metadata?: PostMetadata;
|
||||
postId: string;
|
||||
theme: Theme;
|
||||
@@ -50,7 +51,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default function MessageAttachment({attachment, metadata, postId, theme}: Props) {
|
||||
export default function MessageAttachment({attachment, location, metadata, postId, theme}: Props) {
|
||||
const style = getStyleSheet(theme);
|
||||
const blockStyles = getMarkdownBlockStyles(theme);
|
||||
const textStyles = getMarkdownTextStyles(theme);
|
||||
@@ -132,6 +133,7 @@ export default function MessageAttachment({attachment, metadata, postId, theme}:
|
||||
<AttachmentImage
|
||||
imageUrl={attachment.image_url}
|
||||
imageMetadata={metadata!.images![attachment.image_url]}
|
||||
location={location}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,10 @@ import withObservables from '@nozbe/with-observables';
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Text, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {getPreferenceAsBool} from '@helpers/api/preference';
|
||||
@@ -25,6 +25,7 @@ import type SystemModel from '@typings/database/models/servers/system';
|
||||
|
||||
type OpengraphProps = {
|
||||
isReplyPost: boolean;
|
||||
location: string;
|
||||
metadata: PostMetadata;
|
||||
postId: string;
|
||||
showLinkPreviews: boolean;
|
||||
@@ -70,7 +71,7 @@ const selectOpenGraphData = (url: string, metadata: PostMetadata) => {
|
||||
})?.data;
|
||||
};
|
||||
|
||||
const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
|
||||
const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
|
||||
const intl = useIntl();
|
||||
const link = metadata.embeds![0]!.url;
|
||||
const openGraphData = selectOpenGraphData(link, metadata);
|
||||
@@ -123,10 +124,9 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
|
||||
if (title) {
|
||||
siteTitle = (
|
||||
<View style={style.wrapper}>
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
style={style.flex}
|
||||
onPress={goToLink}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text
|
||||
style={[style.siteTitle, {marginRight: isReplyPost ? 10 : 0}]}
|
||||
@@ -135,7 +135,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
|
||||
>
|
||||
{title as string}
|
||||
</Text>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -163,6 +163,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
|
||||
{hasImage &&
|
||||
<OpengraphImage
|
||||
isReplyPost={isReplyPost}
|
||||
location={location}
|
||||
openGraphImages={openGraphData.images as never[]}
|
||||
metadata={metadata}
|
||||
postId={postId}
|
||||
@@ -173,7 +174,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope
|
||||
);
|
||||
};
|
||||
|
||||
const withOpenGraphInput = withObservables(
|
||||
const enhanced = withObservables(
|
||||
['removeLinkPreview'], ({database, removeLinkPreview}: WithDatabaseArgs & {removeLinkPreview: boolean}) => {
|
||||
if (removeLinkPreview) {
|
||||
return {showLinkPreviews: of$(false)};
|
||||
@@ -198,4 +199,4 @@ const withOpenGraphInput = withObservables(
|
||||
return {showLinkPreviews};
|
||||
});
|
||||
|
||||
export default withDatabase(withOpenGraphInput(React.memo(Opengraph)));
|
||||
export default withDatabase(enhanced(React.memo(Opengraph)));
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {useWindowDimensions, View} from 'react-native';
|
||||
import React, {useMemo, useRef} from 'react';
|
||||
import {useWindowDimensions} from 'react-native';
|
||||
import FastImage, {Source} from 'react-native-fast-image';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Device as DeviceConstant, View as ViewConstants} from '@constants';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {BestImage, getNearestPoint} from '@utils/opengraph';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {isValidUrl} from '@utils/url';
|
||||
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
|
||||
|
||||
type OpengraphImageProps = {
|
||||
isReplyPost: boolean;
|
||||
location: string;
|
||||
metadata: PostMetadata;
|
||||
openGraphImages: never[];
|
||||
postId: string;
|
||||
@@ -49,14 +54,16 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt
|
||||
return viewPortWidth - tabletOffset;
|
||||
};
|
||||
|
||||
const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
|
||||
const fileId = useRef(generateId()).current;
|
||||
const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const dimensions = useWindowDimensions();
|
||||
const style = getStyleSheet(theme);
|
||||
const bestDimensions = {
|
||||
const galleryIdentifier = `${postId}-OpenGraphImage-${location}`;
|
||||
|
||||
const bestDimensions = useMemo(() => ({
|
||||
height: MAX_IMAGE_HEIGHT,
|
||||
width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
|
||||
};
|
||||
}), [isReplyPost, dimensions]);
|
||||
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage;
|
||||
const imageUrl = (bestImage.secure_url || bestImage.url)!;
|
||||
const imagesMetadata = metadata.images;
|
||||
@@ -81,30 +88,50 @@ const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}:
|
||||
imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width));
|
||||
}
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
openGallerWithMockFile(imageUrl, postId, imageDimensions.height, imageDimensions.width, fileId);
|
||||
}, []);
|
||||
const onPress = () => {
|
||||
const item: GalleryItemType = {
|
||||
id: fileId,
|
||||
postId,
|
||||
uri: imageUrl,
|
||||
width: imageDimensions.width,
|
||||
height: imageDimensions.height,
|
||||
name: extractFilenameFromUrl(imageUrl) || 'openGraph.png',
|
||||
mime_type: lookupMimeType(imageUrl) || 'images/png',
|
||||
type: 'image',
|
||||
};
|
||||
openGalleryAtIndex(galleryIdentifier, 0, [item]);
|
||||
};
|
||||
|
||||
const source: Source = {};
|
||||
if (isValidUrl(imageUrl)) {
|
||||
source.uri = imageUrl;
|
||||
}
|
||||
|
||||
const {ref, onGestureEvent, styles} = useGalleryItem(
|
||||
galleryIdentifier,
|
||||
0,
|
||||
onPress,
|
||||
);
|
||||
|
||||
const dimensionsStyle = {width: imageDimensions.width, height: imageDimensions.height};
|
||||
return (
|
||||
<View style={[style.imageContainer, dimensionsStyle]}>
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
type={'none'}
|
||||
>
|
||||
<FastImage
|
||||
style={[style.image, dimensionsStyle]}
|
||||
source={source}
|
||||
resizeMode='contain'
|
||||
nativeID={`image-${fileId}`}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={[styles, style.imageContainer, dimensionsStyle]}>
|
||||
<TapGestureHandler onGestureEvent={onGestureEvent}>
|
||||
<Animated.View testID={`OpenGraphImage-${fileId}`}>
|
||||
<FastImage
|
||||
style={[style.image, dimensionsStyle]}
|
||||
source={source}
|
||||
|
||||
// @ts-expect-error legacy ref
|
||||
ref={ref}
|
||||
resizeMode='contain'
|
||||
nativeID={`OpenGraphImage-${fileId}`}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, Image, Platform, StatusBar, StyleSheet} from 'react-native';
|
||||
import {Alert, Image, Platform, StatusBar, StyleSheet, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {emptyFunction} from '@utils/general';
|
||||
@@ -157,10 +157,9 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
style={[styles.imageContainer, {height: dimensions.height}]}
|
||||
onPress={playYouTubeVideo}
|
||||
type={'opacity'}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={imgUrl}
|
||||
@@ -170,15 +169,11 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
|
||||
resizeMode='cover'
|
||||
onError={emptyFunction}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
style={styles.playButton}
|
||||
onPress={playYouTubeVideo}
|
||||
type={'opacity'}
|
||||
>
|
||||
<View style={styles.playButton}>
|
||||
<Image source={require('@assets/images/icons/youtube-play-icon.png')}/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
</ProgressiveImage>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
@@ -75,17 +75,16 @@ const Failed = ({post, theme}: FailedProps) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={styles.retry}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
name='information-outline'
|
||||
size={26}
|
||||
color={theme.errorTextColor}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,16 +7,15 @@ import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Platform, StatusBar, StatusBarStyle, StyleSheet, View} from 'react-native';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Device} from '@constants';
|
||||
import {DOWNLOAD_TIMEOUT} from '@constants/network';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
|
||||
import {getLocalFilePathFromFile} from '@utils/file';
|
||||
import {fileExists, getLocalFilePathFromFile} from '@utils/file';
|
||||
|
||||
import FileIcon from './file_icon';
|
||||
|
||||
@@ -33,7 +32,6 @@ type DocumentFileProps = {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const {DOCUMENTS_PATH} = Device;
|
||||
const styles = StyleSheet.create({
|
||||
progress: {
|
||||
justifyContent: 'flex-end',
|
||||
@@ -67,20 +65,17 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
||||
};
|
||||
|
||||
const downloadAndPreviewFile = async () => {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
|
||||
setDidCancel(false);
|
||||
let path;
|
||||
|
||||
try {
|
||||
let exists = false;
|
||||
if (path) {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
exists = info.exists;
|
||||
}
|
||||
path = getLocalFilePathFromFile(serverUrl, file);
|
||||
const exists = await fileExists(path);
|
||||
if (exists) {
|
||||
openDocument();
|
||||
} else {
|
||||
setDownloading(true);
|
||||
downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!, {timeoutInterval: DOWNLOAD_TIMEOUT});
|
||||
downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT});
|
||||
downloadTask.current?.progress?.(setProgress);
|
||||
|
||||
await downloadTask.current;
|
||||
@@ -126,7 +121,7 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
||||
|
||||
const openDocument = () => {
|
||||
if (!didCancel && !preview) {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
|
||||
const path = getLocalFilePathFromFile(serverUrl, file);
|
||||
setPreview(true);
|
||||
setStatusBarColor('dark-content');
|
||||
FileViewer.open(path!, {
|
||||
@@ -190,12 +185,9 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<TouchableOpacity onPress={handlePreviewPress}>
|
||||
{fileAttachmentComponent}
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useRef} from 'react';
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {View} from 'react-native';
|
||||
import {TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {isDocument, isImage} from '@utils/file';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {isDocument, isImage, isVideo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import DocumentFile, {DocumentFileRef} from './document_file';
|
||||
@@ -13,17 +16,21 @@ import FileIcon from './file_icon';
|
||||
import FileInfo from './file_info';
|
||||
import ImageFile from './image_file';
|
||||
import ImageFileOverlay from './image_file_overlay';
|
||||
import VideoFile from './video_file';
|
||||
|
||||
type FileProps = {
|
||||
canDownloadFiles: boolean;
|
||||
file: FileInfo;
|
||||
galleryIdentifier: string;
|
||||
index: number;
|
||||
inViewPort: boolean;
|
||||
isSingleImage: boolean;
|
||||
nonVisibleImagesCount: number;
|
||||
onPress: (index: number) => void;
|
||||
publicLinkEnabled: boolean;
|
||||
theme: Theme;
|
||||
wrapperWidth?: number;
|
||||
updateFileForGallery: (idx: number, file: FileInfo) => void;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
@@ -46,44 +53,73 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
});
|
||||
|
||||
const File = ({
|
||||
canDownloadFiles, file, index = 0, inViewPort = false, isSingleImage = false,
|
||||
nonVisibleImagesCount = 0, onPress, theme, wrapperWidth = 300,
|
||||
canDownloadFiles, file, galleryIdentifier, index, inViewPort = false, isSingleImage = false,
|
||||
nonVisibleImagesCount = 0, onPress, publicLinkEnabled, theme, wrapperWidth = 300, updateFileForGallery,
|
||||
}: FileProps) => {
|
||||
const document = useRef<DocumentFileRef>(null);
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const handlePress = () => {
|
||||
onPress(index);
|
||||
};
|
||||
|
||||
const handlePreviewPress = () => {
|
||||
const handlePreviewPress = useCallback(() => {
|
||||
if (document.current) {
|
||||
document.current.handlePreviewPress();
|
||||
} else {
|
||||
handlePress();
|
||||
onPress(index);
|
||||
}
|
||||
};
|
||||
}, [index]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress);
|
||||
|
||||
if (isVideo(file) && publicLinkEnabled) {
|
||||
return (
|
||||
<TapGestureHandler
|
||||
onGestureEvent={onGestureEvent}
|
||||
shouldCancelWhenOutside={true}
|
||||
>
|
||||
<Animated.View style={[styles]}>
|
||||
<VideoFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
inViewPort={inViewPort}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={'cover'}
|
||||
wrapperWidth={wrapperWidth}
|
||||
updateFileForGallery={updateFileForGallery}
|
||||
index={index}
|
||||
/>
|
||||
{Boolean(nonVisibleImagesCount) &&
|
||||
<ImageFileOverlay
|
||||
theme={theme}
|
||||
value={nonVisibleImagesCount}
|
||||
/>
|
||||
}
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage(file)) {
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
type={'opacity'}
|
||||
<TapGestureHandler
|
||||
onGestureEvent={onGestureEvent}
|
||||
shouldCancelWhenOutside={true}
|
||||
>
|
||||
<ImageFile
|
||||
file={file}
|
||||
inViewPort={inViewPort}
|
||||
wrapperWidth={wrapperWidth}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={'cover'}
|
||||
/>
|
||||
{Boolean(nonVisibleImagesCount) &&
|
||||
<ImageFileOverlay
|
||||
theme={theme}
|
||||
value={nonVisibleImagesCount}
|
||||
/>
|
||||
}
|
||||
</TouchableWithFeedback>
|
||||
<Animated.View style={[styles]}>
|
||||
<ImageFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
inViewPort={inViewPort}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={'cover'}
|
||||
wrapperWidth={wrapperWidth}
|
||||
/>
|
||||
{Boolean(nonVisibleImagesCount) &&
|
||||
<ImageFileOverlay
|
||||
theme={theme}
|
||||
value={nonVisibleImagesCount}
|
||||
/>
|
||||
}
|
||||
</Animated.View>
|
||||
</TapGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {getFormattedFileSize} from '@utils/file';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
@@ -43,12 +43,8 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
type={'opacity'}
|
||||
style={style.attachmentContainer}
|
||||
>
|
||||
<>
|
||||
<View style={style.attachmentContainer}>
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
@@ -65,8 +61,8 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
|
||||
{`${getFormattedFileSize(file.size)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
import Animated, {useDerivedValue} from 'react-native-reanimated';
|
||||
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {isGif, isImage} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {isGif, isImage, isVideo} from '@utils/file';
|
||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {getViewPortWidth} from '@utils/images';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import File from './file';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type FileModel from '@typings/database/models/servers/file';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
|
||||
type FilesProps = {
|
||||
authorId: string;
|
||||
canDownloadFiles: boolean;
|
||||
failed?: boolean;
|
||||
files: FileModel[];
|
||||
location: string;
|
||||
isReplyPost: boolean;
|
||||
postId: string;
|
||||
publicLinkEnabled: boolean;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
@@ -53,62 +48,55 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme}: FilesProps) => {
|
||||
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location, postId, publicLinkEnabled, theme}: FilesProps) => {
|
||||
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
|
||||
const [inViewPort, setInViewPort] = useState(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const isTablet = useIsTablet();
|
||||
const imageAttachments = useRef<FileInfo[]>([]).current;
|
||||
const nonImageAttachments = useRef<FileInfo[]>([]).current;
|
||||
const filesInfo: FileInfo[] = useMemo(() => files.map((f) => ({
|
||||
id: f.id,
|
||||
user_id: authorId,
|
||||
post_id: postId,
|
||||
create_at: 0,
|
||||
delete_at: 0,
|
||||
update_at: 0,
|
||||
name: f.name,
|
||||
extension: f.extension,
|
||||
mini_preview: f.imageThumbnail,
|
||||
size: f.size,
|
||||
mime_type: f.mimeType,
|
||||
height: f.height,
|
||||
has_preview_image: Boolean(f.imageThumbnail),
|
||||
localPath: f.localPath,
|
||||
width: f.width,
|
||||
})), [files]);
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
const filesInfo: FileInfo[] = useMemo(() => files.map((f) => f.toFileInfo(authorId)), [authorId, files]);
|
||||
|
||||
if (!imageAttachments.length && !nonImageAttachments.length) {
|
||||
filesInfo.reduce((info, file) => {
|
||||
if (isImage(file)) {
|
||||
const {images: imageAttachments, nonImages: nonImageAttachments} = useMemo(() => {
|
||||
return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => {
|
||||
const imageFile = isImage(file);
|
||||
const videoFile = isVideo(file);
|
||||
if (imageFile || (videoFile && publicLinkEnabled)) {
|
||||
let uri;
|
||||
if (file.localPath) {
|
||||
uri = file.localPath;
|
||||
} else {
|
||||
uri = isGif(file) ? client?.getFileUrl(file.id!, 0) : client?.getFilePreviewUrl(file.id!, 0);
|
||||
uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!);
|
||||
}
|
||||
info.imageAttachments.push({...file, uri});
|
||||
images.push({...file, uri});
|
||||
} else {
|
||||
info.nonImageAttachments.push(file);
|
||||
}
|
||||
return info;
|
||||
}, {imageAttachments, nonImageAttachments});
|
||||
}
|
||||
if (videoFile) {
|
||||
// fallback if public links are not enabled
|
||||
file.uri = buildFileUrl(serverUrl, file.id!);
|
||||
}
|
||||
|
||||
nonImages.push(file);
|
||||
}
|
||||
return {images, nonImages};
|
||||
}, {images: [], nonImages: []});
|
||||
}, [files, publicLinkEnabled, serverUrl]);
|
||||
|
||||
const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments),
|
||||
[imageAttachments, nonImageAttachments]);
|
||||
|
||||
const filesForGallery = useRef<FileInfo[]>(imageAttachments.concat(nonImageAttachments)).current;
|
||||
const attachmentIndex = (fileId: string) => {
|
||||
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
|
||||
return filesForGallery.value.findIndex((file) => file.id === fileId) || 0;
|
||||
};
|
||||
|
||||
const handlePreviewPress = preventDoubleTap((idx: number) => {
|
||||
openGalleryAtIndex(idx, filesForGallery);
|
||||
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, authorId));
|
||||
openGalleryAtIndex(galleryIdentifier, idx, items);
|
||||
});
|
||||
|
||||
const updateFileForGallery = (idx: number, file: FileInfo) => {
|
||||
'worklet';
|
||||
|
||||
filesForGallery.value[idx] = file;
|
||||
};
|
||||
|
||||
const isSingleImage = () => (files.length === 1 && isImage(files[0]));
|
||||
|
||||
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
|
||||
@@ -125,13 +113,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
|
||||
if (idx !== 0 && includeGutter) {
|
||||
container = containerWithGutter;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={container}
|
||||
key={file.id}
|
||||
>
|
||||
<File
|
||||
galleryIdentifier={galleryIdentifier}
|
||||
key={file.id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
@@ -140,6 +128,8 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
|
||||
theme={theme}
|
||||
isSingleImage={singleImage}
|
||||
nonVisibleImagesCount={nonVisibleImagesCount}
|
||||
publicLinkEnabled={publicLinkEnabled}
|
||||
updateFileForGallery={updateFileForGallery}
|
||||
wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15}
|
||||
inViewPort={inViewPort}
|
||||
/>
|
||||
@@ -179,30 +169,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={[failed && styles.failed]}>
|
||||
{renderImageRow()}
|
||||
{renderItems(nonImageAttachments)}
|
||||
</View>
|
||||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={[failed && styles.failed]}>
|
||||
{renderImageRow()}
|
||||
{renderItems(nonImageAttachments)}
|
||||
</Animated.View>
|
||||
</GalleryInit>
|
||||
);
|
||||
};
|
||||
|
||||
const withCanDownload = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => {
|
||||
const enableMobileFileDownload = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
|
||||
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')),
|
||||
);
|
||||
const complianceDisabled = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
|
||||
switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')),
|
||||
);
|
||||
|
||||
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
|
||||
map(([download, compliance]) => compliance || download),
|
||||
);
|
||||
|
||||
return {
|
||||
authorId: of$(post.userId),
|
||||
canDownloadFiles,
|
||||
postId: of$(post.id),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withCanDownload(React.memo(Files)));
|
||||
export default React.memo(Files);
|
||||
@@ -1,51 +1,48 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import {buildFilePreviewUrl, buildFileThumbnailUrl} from '@actions/remote/file';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {isGif as isGifImage} from '@utils/file';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import FileIcon from './file_icon';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type {ResizeMode} from 'react-native-fast-image';
|
||||
|
||||
type ImageFileProps = {
|
||||
backgroundColor?: string;
|
||||
file: FileInfo;
|
||||
forwardRef: React.RefObject<unknown>;
|
||||
inViewPort?: boolean;
|
||||
isSingleImage?: boolean;
|
||||
resizeMode?: ResizeMode;
|
||||
wrapperWidth?: number;
|
||||
}
|
||||
|
||||
type ProgressiveImageProps = {
|
||||
defaultSource?: {uri: string};
|
||||
imageUri?: string;
|
||||
inViewPort?: boolean;
|
||||
thumbnailUri?: string;
|
||||
}
|
||||
|
||||
const SMALL_IMAGE_MAX_HEIGHT = 48;
|
||||
const SMALL_IMAGE_MAX_WIDTH = 48;
|
||||
const GRADIENT_COLORS = ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, .32)'];
|
||||
const GRADIENT_END = {x: 1, y: 1};
|
||||
const GRADIENT_LOCATIONS = [0.5, 1];
|
||||
const GRADIENT_START = {x: 0.5, y: 0.5};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
imagePreview: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
boxPlaceholder: {
|
||||
paddingBottom: '100%',
|
||||
},
|
||||
fileImageWrapper: {
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
boxPlaceholder: {
|
||||
paddingBottom: '100%',
|
||||
},
|
||||
failed: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -53,6 +50,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
},
|
||||
gifContainer: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 8,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
imagePreview: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
smallImageBorder: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
@@ -70,21 +76,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
}));
|
||||
|
||||
const ImageFile = ({
|
||||
backgroundColor, file, inViewPort, isSingleImage,
|
||||
backgroundColor, file, forwardRef, inViewPort, isSingleImage,
|
||||
resizeMode = 'cover', wrapperWidth,
|
||||
}: ImageFileProps) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
const [isGif, setIsGif] = useState(isGifImage(file));
|
||||
const [failed, setFailed] = useState(false);
|
||||
const style = getStyleSheet(theme);
|
||||
let image;
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const getImageDimensions = () => {
|
||||
if (isSingleImage) {
|
||||
@@ -108,14 +109,18 @@ const ImageFile = ({
|
||||
if (file.mini_preview && file.mime_type) {
|
||||
props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
|
||||
} else {
|
||||
props.thumbnailUri = client?.getFileThumbnailUrl(file.id, 0);
|
||||
props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id);
|
||||
}
|
||||
props.imageUri = client?.getFilePreviewUrl(file.id, 0);
|
||||
props.imageUri = buildFilePreviewUrl(serverUrl, file.id);
|
||||
props.inViewPort = inViewPort;
|
||||
}
|
||||
return props;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsGif(isGifImage(file));
|
||||
}, [file]);
|
||||
|
||||
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
|
||||
let wrapperStyle: StyleProp<ViewStyle> = style.fileImageWrapper;
|
||||
if (isSingleImage) {
|
||||
@@ -129,6 +134,7 @@ const ImageFile = ({
|
||||
image = (
|
||||
<ProgressiveImage
|
||||
id={file.id!}
|
||||
forwardRef={forwardRef}
|
||||
style={{height: file.height, width: file.width}}
|
||||
tintDefaultSource={!file.localPath && !failed}
|
||||
onError={handleError}
|
||||
@@ -170,6 +176,7 @@ const ImageFile = ({
|
||||
image = (
|
||||
<ProgressiveImage
|
||||
id={file.id!}
|
||||
forwardRef={forwardRef}
|
||||
style={[isSingleImage ? null : style.imagePreview, imageDimensions]}
|
||||
tintDefaultSource={!file.localPath && !failed}
|
||||
onError={handleError}
|
||||
@@ -190,12 +197,34 @@ const ImageFile = ({
|
||||
);
|
||||
}
|
||||
|
||||
let gifIndicator;
|
||||
if (isGif) {
|
||||
gifIndicator = (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<LinearGradient
|
||||
start={GRADIENT_START}
|
||||
end={GRADIENT_END}
|
||||
locations={GRADIENT_LOCATIONS}
|
||||
colors={GRADIENT_COLORS}
|
||||
style={[style.imagePreview, {...imageDimensions}]}
|
||||
/>
|
||||
<View style={[style.gifContainer, {...imageDimensions}]}>
|
||||
<CompassIcon
|
||||
name='file-gif'
|
||||
color='#FFF'
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={style.fileImageWrapper}
|
||||
>
|
||||
{!isSingleImage && <View style={style.boxPlaceholder}/>}
|
||||
{image}
|
||||
{gifIndicator}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useMemo} from 'react';
|
||||
import {PixelRatio, StyleSheet, Text, useWindowDimensions, View} from 'react-native';
|
||||
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type ImageFileOverlayProps = {
|
||||
@@ -11,37 +12,36 @@ type ImageFileOverlayProps = {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const getStyleSheet = (scale: number, th: Theme) => {
|
||||
const style = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
moreImagesWrapper: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 5,
|
||||
},
|
||||
moreImagesText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
|
||||
fontFamily: 'OpenSans',
|
||||
textAlign: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return style(th);
|
||||
};
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
moreImagesWrapper: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 5,
|
||||
},
|
||||
moreImagesText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontFamily: 'OpenSans',
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
const ImageFileOverlay = ({theme, value}: ImageFileOverlayProps) => {
|
||||
const dimensions = useWindowDimensions();
|
||||
const scale = dimensions.width / 320;
|
||||
const style = getStyleSheet(scale, theme);
|
||||
return null;
|
||||
const isTablet = useIsTablet();
|
||||
const style = getStyleSheet(theme);
|
||||
const textStyles = useMemo(() => {
|
||||
const scale = isTablet ? dimensions.scale : 1;
|
||||
return [
|
||||
style.moreImagesText,
|
||||
{fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale))},
|
||||
];
|
||||
}, [isTablet]);
|
||||
|
||||
return (
|
||||
<View style={style.moreImagesWrapper}>
|
||||
<Text style={style.moreImagesText}>
|
||||
<Text style={textStyles}>
|
||||
{`+${value}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
43
app/components/post_list/post/body/files/index.ts
Normal file
43
app/components/post_list/post/body/files/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
|
||||
import Files from './files';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
|
||||
const enhance = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => {
|
||||
const config = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
|
||||
const enableMobileFileDownload = config.pipe(
|
||||
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')),
|
||||
);
|
||||
|
||||
const publicLinkEnabled = config.pipe(
|
||||
switchMap(({value}: {value: ClientConfig}) => of$(value.EnablePublicLink !== 'false')),
|
||||
);
|
||||
|
||||
const complianceDisabled = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
|
||||
switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')),
|
||||
);
|
||||
|
||||
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
|
||||
map(([download, compliance]) => compliance || download),
|
||||
);
|
||||
|
||||
return {
|
||||
authorId: of$(post.userId),
|
||||
canDownloadFiles,
|
||||
postId: of$(post.id),
|
||||
publicLinkEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhance(Files));
|
||||
187
app/components/post_list/post/body/files/video_file.tsx
Normal file
187
app/components/post_list/post/body/files/video_file.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getThumbnailAsync} from 'expo-video-thumbnails';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native';
|
||||
|
||||
import {updateLocalFile} from '@actions/local/file';
|
||||
import {buildFilePreviewUrl, fetchPublicLink} from '@actions/remote/file';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {fileExists} from '@utils/file';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import FileIcon from './file_icon';
|
||||
|
||||
import type {ResizeMode} from 'react-native-fast-image';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
file: FileInfo;
|
||||
forwardRef: React.RefObject<unknown>;
|
||||
inViewPort?: boolean;
|
||||
isSingleImage?: boolean;
|
||||
resizeMode?: ResizeMode;
|
||||
wrapperWidth?: number;
|
||||
updateFileForGallery: (idx: number, file: FileInfo) => void;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
imagePreview: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
fileImageWrapper: {
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
boxPlaceholder: {
|
||||
paddingBottom: '100%',
|
||||
},
|
||||
failed: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
},
|
||||
playContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
play: {
|
||||
backgroundColor: changeOpacity('#000', 0.16),
|
||||
borderRadius: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const VideoFile = ({
|
||||
index, file, forwardRef, inViewPort, isSingleImage,
|
||||
resizeMode = 'cover', wrapperWidth, updateFileForGallery,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const mounted = useRef(false);
|
||||
const [video, setVideo] = useState({...file});
|
||||
|
||||
const imageDimensions = useMemo(() => {
|
||||
if (isSingleImage) {
|
||||
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
|
||||
return calculateDimensions(video.height, video.width, wrapperWidth, viewPortHeight);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth]);
|
||||
|
||||
const getThumbnail = async () => {
|
||||
const data = {...file};
|
||||
try {
|
||||
const exists = data.mini_preview ? await fileExists(data.mini_preview) : false;
|
||||
if (!data.mini_preview || !exists) {
|
||||
// We use the public link to avoid having to pass the token through a third party
|
||||
// library
|
||||
const publicUri = await fetchPublicLink(serverUrl, data.id!);
|
||||
if (('link') in publicUri) {
|
||||
const {uri, height, width} = await getThumbnailAsync(publicUri.link, {time: 2000});
|
||||
data.mini_preview = uri;
|
||||
data.height = height;
|
||||
data.width = width;
|
||||
updateLocalFile(serverUrl, data);
|
||||
if (mounted.current) {
|
||||
setVideo(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
data.mini_preview = buildFilePreviewUrl(serverUrl, data.id!);
|
||||
if (mounted.current) {
|
||||
setVideo(data);
|
||||
}
|
||||
} finally {
|
||||
const {width: tw, height: th} = calculateDimensions(
|
||||
data.height,
|
||||
data.width,
|
||||
dimensions.width - 60, // size of the gallery header probably best to set that as a constant
|
||||
dimensions.height,
|
||||
);
|
||||
data.height = th;
|
||||
data.width = tw;
|
||||
updateFileForGallery(index, data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewPort) {
|
||||
getThumbnail();
|
||||
}
|
||||
}, [file, inViewPort]);
|
||||
|
||||
const imageProps = () => {
|
||||
const props: ProgressiveImageProps = {
|
||||
imageUri: video.mini_preview,
|
||||
inViewPort,
|
||||
};
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
let thumbnail = (
|
||||
<ProgressiveImage
|
||||
id={file.id!}
|
||||
forwardRef={forwardRef}
|
||||
style={[isSingleImage ? null : style.imagePreview, imageDimensions]}
|
||||
onError={handleError}
|
||||
resizeMode={resizeMode}
|
||||
{...imageProps()}
|
||||
/>
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
thumbnail = (
|
||||
<View style={[isSingleImage ? null : style.imagePreview, style.failed, imageDimensions]}>
|
||||
<FileIcon
|
||||
failed={failed}
|
||||
file={file}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.fileImageWrapper}
|
||||
>
|
||||
{!isSingleImage && <View style={style.boxPlaceholder}/>}
|
||||
{thumbnail}
|
||||
<View style={style.playContainer}>
|
||||
<View style={style.play}>
|
||||
<CompassIcon
|
||||
color={changeOpacity('#fff', 0.8)}
|
||||
name='play'
|
||||
size={40}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoFile;
|
||||
@@ -151,6 +151,7 @@ const Body = ({
|
||||
{hasContent &&
|
||||
<Content
|
||||
isReplyPost={isReplyPost}
|
||||
location={location}
|
||||
post={post}
|
||||
theme={theme}
|
||||
/>
|
||||
@@ -159,6 +160,7 @@ const Body = ({
|
||||
<Files
|
||||
failed={post.props?.failed}
|
||||
files={files}
|
||||
location={location}
|
||||
post={post}
|
||||
isReplyPost={isReplyPost}
|
||||
theme={theme}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo, useState} from 'react';
|
||||
import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native';
|
||||
import {LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
@@ -87,6 +87,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo
|
||||
isEdited={isEdited}
|
||||
isReplyPost={isReplyPost}
|
||||
isSearchResult={location === SEARCH}
|
||||
location={location}
|
||||
postId={post.id}
|
||||
textStyles={textStyles}
|
||||
value={post.message}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type ShowMoreButtonProps = {
|
||||
@@ -100,10 +100,9 @@ const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreBu
|
||||
}
|
||||
<View style={style.container}>
|
||||
<View style={style.dividerLeft}/>
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={style.buttonContainer}
|
||||
type={'opacity'}
|
||||
>
|
||||
<View
|
||||
style={style.button}
|
||||
@@ -115,7 +114,7 @@ const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreBu
|
||||
style={style.sign}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
<View style={style.dividerRight}/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Platform, Text} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type ReactionProps = {
|
||||
@@ -49,12 +49,11 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
|
||||
}, [highlight]);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={350}
|
||||
style={[styles.reaction, (highlight && styles.highlight)]}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Emoji
|
||||
emojiName={emojiName}
|
||||
@@ -69,7 +68,7 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import React, {useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import {addReaction, removeReaction} from '@actions/remote/reactions';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
|
||||
@@ -131,18 +131,17 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
const {reactionsByName, highlightedReactions} = buildReactionsMap();
|
||||
if (!disabled && canAddReaction && reactionsByName.size < MAX_ALLOWED_REACTIONS) {
|
||||
addMoreReactions = (
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
key='addReaction'
|
||||
onPress={handleAddReaction}
|
||||
style={[styles.reaction]}
|
||||
type={'opacity'}
|
||||
style={styles.reaction}
|
||||
>
|
||||
<CompassIcon
|
||||
name='emoticon-plus-outline'
|
||||
size={24}
|
||||
style={styles.addReaction}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Text, useWindowDimensions, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {showModal} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
@@ -114,20 +114,18 @@ const HeaderDisplayName = ({
|
||||
);
|
||||
} else if (displayName) {
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
style={displayNameStyle}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
ellipsizeMode={'tail'}
|
||||
numberOfLines={1}
|
||||
testID='post_header.display_name'
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</TouchableWithFeedback>
|
||||
<View style={displayNameStyle}>
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
ellipsizeMode={'tail'}
|
||||
numberOfLines={1}
|
||||
testID='post_header.display_name'
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {TouchableOpacity} from 'react-native-gesture-handler';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {SEARCH} from '@constants/screens';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
@@ -57,10 +57,9 @@ const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) =>
|
||||
testID='post_header.reply'
|
||||
style={style.replyWrapper}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={style.replyIconContainer}
|
||||
type={'opacity'}
|
||||
>
|
||||
<CompassIcon
|
||||
name='reply-outline'
|
||||
@@ -75,7 +74,7 @@ const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) =>
|
||||
{commentCount}
|
||||
</Text>
|
||||
}
|
||||
</TouchableWithFeedback>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import React, {ReactNode, useMemo, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native';
|
||||
import {TouchableHighlight} from 'react-native-gesture-handler';
|
||||
|
||||
import {showPermalink} from '@actions/local/permalink';
|
||||
import {removePost} from '@actions/local/post';
|
||||
import SystemAvatar from '@components/system_avatar';
|
||||
import SystemHeader from '@components/system_header';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import * as Screens from '@constants/screens';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
@@ -64,7 +64,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
consecutivePostContainer: {
|
||||
marginBottom: 10,
|
||||
marginRight: 10,
|
||||
marginLeft: Platform.select({ios: 35, android: 34}),
|
||||
marginLeft: Platform.select({ios: 34, android: 33}),
|
||||
marginTop: 10,
|
||||
},
|
||||
container: {flexDirection: 'row'},
|
||||
@@ -270,13 +270,11 @@ const Post = ({
|
||||
testID={testID}
|
||||
style={[styles.postStyle, style, highlightedStyle]}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
<TouchableHighlight
|
||||
testID={itemTestID}
|
||||
onPress={handlePress}
|
||||
onLongPress={showPostOptions}
|
||||
delayLongPress={200}
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
|
||||
cancelTouchOnPanning={true}
|
||||
>
|
||||
<>
|
||||
<PreHeader
|
||||
@@ -294,7 +292,7 @@ const Post = ({
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,43 +12,61 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] =
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
updated the channel display name from: old displayname to: new displayname
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
updated the channel display name from: old displayname to: new displayname
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
@@ -65,43 +83,61 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
updated the channel header from: old header to: new header
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
updated the channel header from: old header to: new header
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
@@ -134,43 +170,61 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
joined the channel as a guest.
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
joined the channel as a guest.
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
@@ -187,66 +241,104 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@other.user
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
added to the channel as a guest by
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
@username.
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@other.user
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
added to the channel as a guest by
|
||||
</Text>
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username.
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
</View>
|
||||
`;
|
||||
|
||||
@@ -262,21 +354,19 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
archived the channel
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
archived the channel
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
@@ -293,43 +383,61 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
archived the channel
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
archived the channel
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
@@ -346,43 +454,61 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
<RNGestureHandlerButton
|
||||
collapsable={false}
|
||||
exclusive={true}
|
||||
onGestureEvent={[Function]}
|
||||
onGestureHandlerEvent={[Function]}
|
||||
onGestureHandlerStateChange={[Function]}
|
||||
onHandlerStateChange={[Function]}
|
||||
rippleColor={0}
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"opacity": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
Object {
|
||||
"opacity": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={Array []}
|
||||
>
|
||||
@username
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</RNGestureHandlerButton>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(63,67,80,0.6)",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
unarchived the channel
|
||||
</Text>
|
||||
}
|
||||
testID="markdown_text"
|
||||
>
|
||||
unarchived the channel
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactElement, useCallback, useEffect, useRef} from 'react';
|
||||
import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native';
|
||||
|
||||
import CombinedUserActivity from '@components/post_list/combined_user_activity';
|
||||
import DateSeparator from '@components/post_list/date_separator';
|
||||
import NewMessagesLine from '@components/post_list/new_message_line';
|
||||
import Post from '@components/post_list/post';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {emptyFunction} from '@utils/general';
|
||||
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type RefreshProps = {
|
||||
children: ReactElement;
|
||||
enabled: boolean;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
currentTimezone: string | null;
|
||||
currentUsername: string;
|
||||
isTimezoneEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
posts: PostModel[];
|
||||
shouldShowJoinLeaveMessages: boolean;
|
||||
footer?: ReactElement;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
type ViewableItemsChanged = {
|
||||
viewableItems: ViewToken[];
|
||||
changed: ViewToken[];
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
scaleY: -1,
|
||||
},
|
||||
scale: {
|
||||
...Platform.select({
|
||||
android: {
|
||||
scaleY: -1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export const VIEWABILITY_CONFIG = {
|
||||
itemVisiblePercentThreshold: 1,
|
||||
minimumViewTime: 100,
|
||||
};
|
||||
|
||||
const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => {
|
||||
const props = {
|
||||
onRefresh,
|
||||
refreshing,
|
||||
};
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
return (
|
||||
<RefreshControl
|
||||
{...props}
|
||||
enabled={enabled}
|
||||
style={style.container}
|
||||
>
|
||||
{children}
|
||||
</RefreshControl>
|
||||
);
|
||||
}
|
||||
|
||||
const refreshControl = <RefreshControl {...props}/>;
|
||||
|
||||
return React.cloneElement(
|
||||
children,
|
||||
{refreshControl, inverted: true},
|
||||
);
|
||||
};
|
||||
|
||||
const PostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, footer, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => {
|
||||
const listRef = useRef<FlatList>(null);
|
||||
const theme = useTheme();
|
||||
const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false});
|
||||
}, [channelId, listRef.current]);
|
||||
|
||||
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
|
||||
if (!viewableItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
|
||||
if (isViewable) {
|
||||
acc[item.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
DeviceEventEmitter.emit('scrolled', viewableItemsMap);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({item, index}) => {
|
||||
if (typeof item === 'string') {
|
||||
if (isStartOfNewMessages(item)) {
|
||||
// postIds includes a date item after the new message indicator so 2
|
||||
// needs to be added to the index for the length check to be correct.
|
||||
const moreNewMessages = orderedPosts.length === index + 2;
|
||||
|
||||
// The date line and new message line each count for a line. So the
|
||||
// goal of this is to check for the 3rd previous, which for the start
|
||||
// of a thread would be null as it doesn't exist.
|
||||
const checkForPostId = index < orderedPosts.length - 3;
|
||||
|
||||
return (
|
||||
<NewMessagesLine
|
||||
theme={theme}
|
||||
moreMessages={moreNewMessages && checkForPostId}
|
||||
testID={`${testID}.new_messages_line`}
|
||||
style={style.scale}
|
||||
/>
|
||||
);
|
||||
} else if (isDateLine(item)) {
|
||||
return (
|
||||
<DateSeparator
|
||||
date={getDateForDateLine(item)}
|
||||
theme={theme}
|
||||
style={style.scale}
|
||||
timezone={isTimezoneEnabled ? currentTimezone : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCombinedUserActivityPost(item)) {
|
||||
const postProps = {
|
||||
currentUsername,
|
||||
postId: item,
|
||||
style: Platform.OS === 'ios' ? style.scale : style.container,
|
||||
testID: `${testID}.combined_user_activity`,
|
||||
showJoinLeave: shouldShowJoinLeaveMessages,
|
||||
theme,
|
||||
};
|
||||
|
||||
return (<CombinedUserActivity {...postProps}/>);
|
||||
}
|
||||
}
|
||||
|
||||
let previousPost: PostModel|undefined;
|
||||
let nextPost: PostModel|undefined;
|
||||
if (index < posts.length - 1) {
|
||||
const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string');
|
||||
if (prev) {
|
||||
previousPost = prev as PostModel;
|
||||
}
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const next = orderedPosts.slice(0, index);
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
const v = next[i];
|
||||
if (typeof v !== 'string') {
|
||||
nextPost = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postProps = {
|
||||
highlightPinnedOrFlagged: true,
|
||||
location: 'Channel',
|
||||
nextPost,
|
||||
previousPost,
|
||||
shouldRenderReplyButton: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Post
|
||||
key={item.id}
|
||||
post={item}
|
||||
style={style.scale}
|
||||
testID={`${testID}.post`}
|
||||
{...postProps}
|
||||
/>
|
||||
);
|
||||
}, [orderedPosts, theme]);
|
||||
|
||||
return (
|
||||
<PostListRefreshControl
|
||||
enabled={false}
|
||||
refreshing={false}
|
||||
onRefresh={emptyFunction}
|
||||
>
|
||||
<FlatList
|
||||
contentContainerStyle={[styles.content, contentContainerStyle]}
|
||||
data={orderedPosts}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='handled'
|
||||
keyExtractor={keyExtractor}
|
||||
initialNumToRender={10}
|
||||
ListFooterComponent={footer}
|
||||
maxToRenderPerBatch={10}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
ref={listRef}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews={true}
|
||||
scrollEventThrottle={60}
|
||||
style={styles.flex}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
/>
|
||||
</PostListRefreshControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostList;
|
||||
@@ -1,9 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode, useEffect, useRef, useState} from 'react';
|
||||
import {Animated, ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import FastImage, {ImageStyle, ResizeMode, Source} from 'react-native-fast-image';
|
||||
import React, {ReactNode, useEffect, useState} from 'react';
|
||||
import {ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import FastImage, {ImageStyle, ResizeMode} from 'react-native-fast-image';
|
||||
import Animated, {interpolate, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
@@ -11,20 +12,19 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import Thumbnail from './thumbnail';
|
||||
|
||||
const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
|
||||
|
||||
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
|
||||
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
|
||||
|
||||
type ProgressiveImageProps = {
|
||||
type Props = ProgressiveImageProps & {
|
||||
children?: ReactNode | ReactNode[];
|
||||
defaultSource?: Source; // this should be provided by the component
|
||||
forwardRef?: React.RefObject<any>;
|
||||
id: string;
|
||||
imageStyle?: StyleProp<ImageStyle>;
|
||||
imageUri?: string;
|
||||
inViewPort?: boolean;
|
||||
isBackgroundImage?: boolean;
|
||||
onError: () => void;
|
||||
resizeMode?: ResizeMode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
thumbnailUri?: string;
|
||||
tintDefaultSource?: boolean;
|
||||
};
|
||||
|
||||
@@ -43,22 +43,34 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
});
|
||||
|
||||
const ProgressiveImage = ({
|
||||
children, defaultSource, id, imageStyle, imageUri, inViewPort, isBackgroundImage, onError, resizeMode = 'contain',
|
||||
style = {}, thumbnailUri, tintDefaultSource,
|
||||
}: ProgressiveImageProps) => {
|
||||
const intensity = useRef(new Animated.Value(0)).current;
|
||||
children, defaultSource, forwardRef, id, imageStyle, imageUri, inViewPort, isBackgroundImage,
|
||||
onError, resizeMode = 'contain', style = {}, thumbnailUri, tintDefaultSource,
|
||||
}: Props) => {
|
||||
const [showHighResImage, setShowHighResImage] = useState(false);
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const intensity = useSharedValue(0);
|
||||
|
||||
const defaultOpacity = useDerivedValue(() => (
|
||||
interpolate(
|
||||
intensity.value,
|
||||
[0, 100],
|
||||
[0.5, 0],
|
||||
)
|
||||
), []);
|
||||
|
||||
const onLoadImageEnd = () => {
|
||||
Animated.timing(intensity, {
|
||||
duration: 300,
|
||||
toValue: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
intensity.value = withTiming(100, {duration: 300});
|
||||
};
|
||||
|
||||
const animatedOpacity = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
intensity.value,
|
||||
[200, 100],
|
||||
[0.2, 1],
|
||||
),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewPort) {
|
||||
setShowHighResImage(true);
|
||||
@@ -84,6 +96,7 @@ const ProgressiveImage = ({
|
||||
return (
|
||||
<View style={[styles.defaultImageContainer, style]}>
|
||||
<AnimatedFastImage
|
||||
ref={forwardRef}
|
||||
source={defaultSource}
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
@@ -98,20 +111,14 @@ const ProgressiveImage = ({
|
||||
);
|
||||
}
|
||||
|
||||
const opacity = intensity.interpolate({
|
||||
inputRange: [20, 100],
|
||||
outputRange: [0.2, 1],
|
||||
});
|
||||
|
||||
const defaultOpacity = intensity.interpolate({inputRange: [0, 100], outputRange: [0.5, 0]});
|
||||
|
||||
const containerStyle = {backgroundColor: changeOpacity(theme.centerChannelColor, Number(defaultOpacity))};
|
||||
const containerStyle = {backgroundColor: changeOpacity(theme.centerChannelColor, Number(defaultOpacity.value))};
|
||||
|
||||
let image;
|
||||
if (thumbnailUri) {
|
||||
if (showHighResImage && imageUri) {
|
||||
image = (
|
||||
<AnimatedFastImage
|
||||
ref={forwardRef}
|
||||
nativeID={`image-${id}`}
|
||||
resizeMode={resizeMode}
|
||||
onError={onError}
|
||||
@@ -119,7 +126,7 @@ const ProgressiveImage = ({
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
imageStyle,
|
||||
{opacity},
|
||||
animatedOpacity,
|
||||
]}
|
||||
testID='progressive_image.highResImage'
|
||||
onLoadEnd={onLoadImageEnd}
|
||||
@@ -129,11 +136,12 @@ const ProgressiveImage = ({
|
||||
} else if (imageUri) {
|
||||
image = (
|
||||
<AnimatedFastImage
|
||||
ref={forwardRef}
|
||||
nativeID={`image-${id}`}
|
||||
resizeMode={resizeMode}
|
||||
onError={onError}
|
||||
source={{uri: imageUri}}
|
||||
style={[StyleSheet.absoluteFill, imageStyle, {opacity}]}
|
||||
style={[StyleSheet.absoluteFill, imageStyle, animatedOpacity]}
|
||||
onLoadEnd={onLoadImageEnd}
|
||||
testID='progressive_image.highResImage'
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Animated, StyleProp, StyleSheet} from 'react-native';
|
||||
import {StyleProp, StyleSheet} from 'react-native';
|
||||
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
|
||||
import Animated, {SharedValue} from 'react-native-reanimated';
|
||||
|
||||
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
|
||||
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
|
||||
|
||||
type ThumbnailProps = {
|
||||
onError: () => void;
|
||||
opacity?: number | Animated.AnimatedInterpolation | Animated.AnimatedValue;
|
||||
opacity?: SharedValue<number>;
|
||||
source?: Source;
|
||||
style: StyleProp<ImageStyle>;
|
||||
}
|
||||
@@ -34,7 +36,7 @@ const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => {
|
||||
resizeMode='contain'
|
||||
onError={onError}
|
||||
source={require('@assets/images/thumb.png')}
|
||||
style={[style, {opacity}]}
|
||||
style={[style, {opacity: opacity?.value}]}
|
||||
testID='progressive_image.thumbnail'
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
|
||||
82
app/components/toast/index.tsx
Normal file
82
app/components/toast/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {StyleProp, Text, useWindowDimensions, View, ViewStyle} from 'react-native';
|
||||
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
|
||||
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type ToastProps = {
|
||||
animatedStyle: AnimatedStyleProp<ViewStyle>;
|
||||
children?: React.ReactNode;
|
||||
iconName?: string;
|
||||
message?: string;
|
||||
style: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
center: {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
opacity: 0,
|
||||
},
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.onlineIndicator,
|
||||
borderRadius: 8,
|
||||
elevation: 6,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
height: 56,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 10,
|
||||
shadowColor: changeOpacity('#000', 0.12),
|
||||
shadowOffset: {width: 0, height: 4},
|
||||
shadowRadius: 6,
|
||||
},
|
||||
flex: {flex: 1},
|
||||
text: {
|
||||
color: theme.buttonColor,
|
||||
marginLeft: 10,
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
}));
|
||||
|
||||
const Toast = ({animatedStyle, children, style, iconName, message}: ToastProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const dim = useWindowDimensions();
|
||||
const containerStyle = useMemo(() => {
|
||||
const totalMargin = 40;
|
||||
const width = Math.min(dim.height, dim.width, 400) - totalMargin;
|
||||
|
||||
return [styles.container, {width}, style];
|
||||
}, [dim, styles.container, style]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.center, animatedStyle]}>
|
||||
<Animated.View style={containerStyle}>
|
||||
{Boolean(iconName) &&
|
||||
<CompassIcon
|
||||
color={theme.buttonColor}
|
||||
name={iconName!}
|
||||
size={18}
|
||||
/>
|
||||
}
|
||||
{Boolean(message) &&
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
</View>
|
||||
}
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
||||
Reference in New Issue
Block a user