forked from Ivasoft/mattermost-mobile
[Gekidou - MM-44930] Use File component for showing files in search. (#6425)
* add observables for search component add loader screen add file client searches for files * hook up loader component for loading state * search results with found posts now working * get and store files when searching * query file results from the database display dummy file text for now * add filter screen and icon to the results header * needs some cleanup but functionally works - applied filters reduce files to subset of selectd types - no filters will show all files * update number files in parenthesis to match the filtered number of files (if filtered) * added the missing file extensions found in webapp added document_types which is a superset of other types * remove clear all text from filter and from bottom_sheet component * checkin before merge latest gekidou branch - change filters to use latest figma design - from multiselect to single select - revert changes to bottom sheet content that allowed adding a RHS title button - start of the file attachement cards show in file results * Cleanup and fixes * Remove nested scroll views * Address feedback * Address feedback * extract the fileInfos from the results object, from an array * add translations for filters * add translations * use Object values to determine if has file info results * Combine fetch recent mentions and search for posts * add search icon back to home screen * remove unused function import * fix formatting and add 3 dot onPress option * don't show search button * Add touchable opacity for pressing the card Add function for opening gallery Fix `...` so only clicking directly over it calls it's function. Everywhere else calls open gallery * place compassIcon in a touchable and add hitslop create individual objects for flex column and row * use one-liner for text move constant outside of the component * truncate filename if over max filename lenght and append ... fix style for filename * remove all commented filetype code. This will not be added to the card because of added length to second row. Feedback from UX discussion * remove trimFileName function and MAX_FILENAME_LENGTH constant make the textContainer grow in width. The other flex boxes are constant width align main container center and remove vertical margins * create TabTypes contant and TabType Type to replace all uses of 'messages' and 'files' * make padding adjustments based on the selected tab and if there are results. When no results are shown, we want the 'Check the spelling or try another search' text to not move or flicker when switching between files and messages * put the margin on the touchable container, not the compassicon so the hitslop is relative to the compassicon. * Add the channel name to the filecard. each fileInfo from the server contain a channel_id. Add to the reponse type * Move the channel name to a separate line * implement changes from PR. Shrink channel name when it doesn't fit * use a useMemo instead of useCallback * initial commit * working copy using the File component instead of creating a new FileCard component * add styhling for long channel name * update styling for info text * update styling * disable lint check for console statement until function is hooked up * fixt linting errors caused by api including channel_id. It needs to be optional or the model will complain * when a file is an image, show the image or video as an image instead of the generic file icon * make `asCard` File Prop optional * shift the image icon over 4px * tweaked styles * tweaked styles for file info * move files directory from inside the post_list/post/body/ folder to its own component because is it referenced from other screens and components including: app/components/post_draft/uploads/upload_item/index.tsx app/components/post_list/post/body/content/image_preview/image_preview.tsx app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx app/components/post_list/post/body/index.tsx app/screens/gallery/document_renderer/document_renderer.tsx app/screens/home/search/results/results.tsx * create useImageAttachments hook and share with files component and results * rename all renderXXXFile useMemo options to xxxFile. These return the actual component * use explicit Boolean(onOptionsPress) * isSingleInput does not need to be a function * use find instead of filter().map() * add dependencies and refactor to reduce some file dependency arrays * order files by reverse create_at date * remove console.log and leave as a comment for now * update styling so that the view wrapper has the borderRadius. Now android and ios get the correct borderRadius surrounding the channel name * use the results of the ordered useImageAttachements results as data for File and Gallery * remove extra empty line * PR feedback - rename capitalize const - add several useCallbacks - use typescript optional parareter instead of if statement * - remove useMemos that only return a component - fix bug - when channel name is not present, don't show the channel component. This happened when looking at posts in a channel because post comes from the model, which does not include the channelName. This is because in the channel view all images are in a specific channel and no need to store it * remove useMemo import * remove callback * - remote unused operator - nothing needed outside of try catch * remove unused Client import * s/xxxFile/renderXxxFile/ because theare are a function that returns a component * move constant above component * default to 0 instead of forcing to be defined * use observerConfigBoolean * import as type because not useing to construct as models * add links to Jira ticket and github PR * add line breaks and sort alphabetically * use ternary operator to reduce number of lines * move up as far as possible * remove unused style * sort props and input vars alphabetically * move higher in the component * return ealier * no need for useDerivedValue. useMemo instead * use useCallback * Minor tweaks and fixes * Remove floats from style Co-authored-by: Daniel Espino García <larkox@gmail.com> Co-authored-by: Elias Nahum <nahumhbl@gmail.com> Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
This commit is contained in:
@@ -12,23 +12,8 @@ import {logError} from '@utils/log';
|
||||
import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
type FileSearchRequest = {
|
||||
error?: unknown;
|
||||
file_infos?: {[id: string]: FileInfo};
|
||||
next_file_info_id?: string;
|
||||
order?: string[];
|
||||
prev_file_info_id?: string;
|
||||
}
|
||||
|
||||
type PostSearchRequest = {
|
||||
error?: unknown;
|
||||
order?: string[];
|
||||
posts?: Post[];
|
||||
}
|
||||
|
||||
export async function fetchRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
@@ -130,27 +115,21 @@ export const searchPosts = async (serverUrl: string, params: PostSearchParams):
|
||||
}
|
||||
};
|
||||
|
||||
export const searchFiles = async (serverUrl: string, teamId: string, params: FileSearchParams): Promise<FileSearchRequest> => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
export const searchFiles = async (serverUrl: string, teamId: string, params: FileSearchParams): Promise<{files?: FileInfo[]; channels?: string[]; error?: unknown}> => {
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await client.searchFiles(teamId, params.terms);
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const result = await client.searchFiles(teamId, params.terms);
|
||||
const files = result?.file_infos ? Object.values(result.file_infos) : [];
|
||||
const allChannelIds = files.reduce<string[]>((acc, f) => {
|
||||
if (f.channel_id) {
|
||||
acc.push(f.channel_id);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const channels = [...new Set(allChannelIds)];
|
||||
return {files, channels};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface ClientFilesMix {
|
||||
onError: (response: ClientResponseError) => void,
|
||||
skipBytes?: number,
|
||||
) => () => void;
|
||||
searchFiles: (teamId: string, terms: string) => Promise<any>;
|
||||
searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise<any>;
|
||||
searchFiles: (teamId: string, terms: string) => Promise<FileSearchRequest>;
|
||||
searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise<FileSearchRequest>;
|
||||
}
|
||||
|
||||
const ClientFiles = (superclass: any) => class extends superclass {
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import CompasIcon from '@components/compass_icon';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import Footer from '@components/settings/footer';
|
||||
import Label from '@components/settings/label';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
@@ -210,7 +210,7 @@ function AutoCompleteSelector({
|
||||
>
|
||||
{itemText || title}
|
||||
</Text>
|
||||
<CompasIcon
|
||||
<CompassIcon
|
||||
name='chevron-down'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
style={style.icon}
|
||||
|
||||
215
app/components/files/file.tsx
Normal file
215
app/components/files/file.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {View, TouchableWithoutFeedback} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {isDocument, isImage, isVideo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import DocumentFile, {DocumentFileRef} from './document_file';
|
||||
import FileIcon from './file_icon';
|
||||
import FileInfo from './file_info';
|
||||
import FileOptionsIcon from './file_options_icon';
|
||||
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;
|
||||
channelName?: string;
|
||||
onOptionsPress?: (index: number) => void;
|
||||
theme: Theme;
|
||||
wrapperWidth?: number;
|
||||
showDate?: boolean;
|
||||
updateFileForGallery: (idx: number, file: FileInfo) => void;
|
||||
asCard?: boolean;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
fileWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.24),
|
||||
borderRadius: 4,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginTop: 8,
|
||||
marginRight: 7,
|
||||
marginBottom: 8,
|
||||
marginLeft: 6,
|
||||
},
|
||||
imageVideo: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
margin: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const File = ({
|
||||
asCard = false,
|
||||
canDownloadFiles,
|
||||
channelName,
|
||||
file,
|
||||
galleryIdentifier,
|
||||
inViewPort = false,
|
||||
index,
|
||||
isSingleImage = false,
|
||||
nonVisibleImagesCount = 0,
|
||||
onOptionsPress,
|
||||
onPress,
|
||||
publicLinkEnabled,
|
||||
showDate = false,
|
||||
theme,
|
||||
updateFileForGallery,
|
||||
wrapperWidth = 300,
|
||||
}: FileProps) => {
|
||||
const document = useRef<DocumentFileRef>(null);
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const handlePreviewPress = useCallback(() => {
|
||||
if (document.current) {
|
||||
document.current.handlePreviewPress();
|
||||
} else {
|
||||
onPress(index);
|
||||
}
|
||||
}, [index]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress);
|
||||
|
||||
const handleOnOptionsPress = useCallback(() => {
|
||||
onOptionsPress?.(index);
|
||||
}, [index, onOptionsPress]);
|
||||
|
||||
const renderOptionsButton = () => {
|
||||
if (onOptionsPress) {
|
||||
return (
|
||||
<FileOptionsIcon
|
||||
onPress={handleOnOptionsPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fileInfo = (
|
||||
<FileInfo
|
||||
file={file}
|
||||
showDate={showDate}
|
||||
channelName={channelName}
|
||||
onPress={handlePreviewPress}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderImageFileOverlay = (
|
||||
<ImageFileOverlay
|
||||
theme={theme}
|
||||
value={nonVisibleImagesCount}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderImageFile = (
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<Animated.View style={[styles, asCard ? style.imageVideo : null]}>
|
||||
<ImageFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
inViewPort={inViewPort}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={'cover'}
|
||||
wrapperWidth={wrapperWidth}
|
||||
/>
|
||||
{Boolean(nonVisibleImagesCount) && renderImageFileOverlay}
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
|
||||
const renderVideoFile = (
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<Animated.View style={[styles, asCard ? style.imageVideo : null]}>
|
||||
<VideoFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
inViewPort={inViewPort}
|
||||
isSingleImage={isSingleImage}
|
||||
resizeMode={'cover'}
|
||||
wrapperWidth={wrapperWidth}
|
||||
updateFileForGallery={updateFileForGallery}
|
||||
index={index}
|
||||
/>
|
||||
{Boolean(nonVisibleImagesCount) && renderImageFileOverlay}
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
|
||||
const renderDocumentFile = (
|
||||
<View style={style.iconWrapper}>
|
||||
<DocumentFile
|
||||
ref={document}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderCardWithImage = (fileIcon: JSX.Element) => {
|
||||
return (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
{fileIcon}
|
||||
</View>
|
||||
{fileInfo}
|
||||
{renderOptionsButton()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
let fileComponent;
|
||||
if (isVideo(file) && publicLinkEnabled) {
|
||||
fileComponent = asCard ? renderCardWithImage(renderVideoFile) : renderVideoFile;
|
||||
} else if (isImage(file)) {
|
||||
fileComponent = asCard ? renderCardWithImage(renderImageFile) : renderImageFile;
|
||||
} else if (isDocument(file)) {
|
||||
fileComponent = (
|
||||
<View style={[style.fileWrapper]}>
|
||||
{renderDocumentFile}
|
||||
{fileInfo}
|
||||
{renderOptionsButton()}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
const touchableWithPreview = (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileIcon
|
||||
file={file}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
|
||||
fileComponent = renderCardWithImage(touchableWithPreview);
|
||||
}
|
||||
return fileComponent;
|
||||
};
|
||||
|
||||
export default File;
|
||||
102
app/components/files/file_info.tsx
Normal file
102
app/components/files/file_info.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import FormattedDate from '@components/formatted_date';
|
||||
import {getFormattedFileSize} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type FileInfoProps = {
|
||||
file: FileInfo;
|
||||
showDate: boolean;
|
||||
channelName?: string ;
|
||||
onPress: () => void;
|
||||
theme: Theme;
|
||||
}
|
||||
const FORMAT = ' • MMM DD HH:MM A';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
attachmentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fileDownloadContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 3,
|
||||
},
|
||||
fileStatsContainer: {
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
infoText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
fileName: {
|
||||
marginTop: -4,
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
color: theme.centerChannelColor,
|
||||
paddingRight: 10,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
channelWrapper: {
|
||||
flexShrink: 1,
|
||||
marginRight: 4,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
channelText: {
|
||||
...typography('Body', 50, 'SemiBold'),
|
||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const FileInfo = ({file, channelName, showDate, onPress, theme}: FileInfoProps) => {
|
||||
const style = getStyleSheet(theme);
|
||||
return (
|
||||
<View style={style.attachmentContainer}>
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileName}
|
||||
>
|
||||
{file.name.trim()}
|
||||
</Text>
|
||||
<View style={style.fileDownloadContainer}>
|
||||
{channelName &&
|
||||
<View style={style.channelWrapper}>
|
||||
<Text
|
||||
style={style.channelText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{channelName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
<View style={style.fileStatsContainer}>
|
||||
<Text style={style.infoText}>
|
||||
{`${getFormattedFileSize(file.size)}`}
|
||||
</Text>
|
||||
{showDate &&
|
||||
<FormattedDate
|
||||
style={style.infoText}
|
||||
format={FORMAT}
|
||||
value={file.create_at as number}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInfo;
|
||||
39
app/components/files/file_options_icon.tsx
Normal file
39
app/components/files/file_options_icon.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {TouchableOpacity, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
threeDotContainer: {
|
||||
alignItems: 'flex-end',
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const hitSlop = {top: 5, bottom: 5, left: 5, right: 5};
|
||||
|
||||
export default function FileOptionsIcon({onPress}: Props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={styles.threeDotContainer}
|
||||
hitSlop={hitSlop}
|
||||
>
|
||||
<CompassIcon
|
||||
name='dots-horizontal'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
size={18}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import Animated, {useDerivedValue} from 'react-native-reanimated';
|
||||
|
||||
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
|
||||
import {Events} from '@constants';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {isGif, isImage, isVideo} from '@utils/file';
|
||||
import {useImageAttachments} from '@hooks/files';
|
||||
import {isImage, isVideo} from '@utils/file';
|
||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {getViewPortWidth} from '@utils/images';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
@@ -50,32 +49,9 @@ const styles = StyleSheet.create({
|
||||
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => {
|
||||
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
|
||||
const [inViewPort, setInViewPort] = useState(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
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) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!);
|
||||
}
|
||||
images.push({...file, uri});
|
||||
} else {
|
||||
if (videoFile) {
|
||||
// fallback if public links are not enabled
|
||||
file.uri = buildFileUrl(serverUrl, file.id!);
|
||||
}
|
||||
|
||||
nonImages.push(file);
|
||||
}
|
||||
return {images, nonImages};
|
||||
}, {images: [], nonImages: []});
|
||||
}, [filesInfo, publicLinkEnabled, serverUrl]);
|
||||
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo, publicLinkEnabled);
|
||||
|
||||
const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments),
|
||||
[imageAttachments, nonImageAttachments]);
|
||||
@@ -7,6 +7,7 @@ import {View} from 'react-native';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {TabTypes, TabType} from '@utils/search';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
@@ -15,7 +16,7 @@ import SearchIllustration from './search_illustration';
|
||||
|
||||
type Props = {
|
||||
term: string;
|
||||
type?: 'default' | 'messages' | 'files';
|
||||
type?: TabType;
|
||||
};
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
@@ -45,18 +46,13 @@ const NoResultsWithTerm = ({term, type}: Props) => {
|
||||
const [defaultMessage, setDefaultMessage] = useState('No results for “{term}”');
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'files') {
|
||||
setTitleId(t('mobile.no_results_with_term.files'));
|
||||
setDefaultMessage('No files matching “{term}”');
|
||||
} else if (type === 'messages') {
|
||||
setTitleId(t('mobile.no_results_with_term.messages'));
|
||||
setDefaultMessage('No matches found for “{term}”');
|
||||
}
|
||||
setTitleId(type === TabTypes.FILES ? t('mobile.no_results_with_term.files') : t('mobile.no_results_with_term.messages'));
|
||||
setDefaultMessage(type === TabTypes.FILES ? 'No files matching “{term}”' : 'No matches found for “{term}”');
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{type === 'files' ? <SearchFilesIllustration/> : <SearchIllustration/>}
|
||||
{type === TabTypes.FILES ? <SearchFilesIllustration/> : <SearchIllustration/>}
|
||||
<FormattedText
|
||||
id={titleId}
|
||||
defaultMessage={defaultMessage}
|
||||
|
||||
@@ -6,8 +6,8 @@ import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import {updateDraftFile} from '@actions/local/draft';
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import ImageFile from '@components/post_list/post/body/files/image_file';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ImageFile from '@components/files/image_file';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
||||
@@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Animated, StyleSheet, TouchableWithoutFeedback, View} from 'react-native';
|
||||
|
||||
import {getRedirectLocation} from '@actions/remote/general';
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
|
||||
@@ -5,7 +5,7 @@ import React, {useCallback, useRef, useState} from 'react';
|
||||
import {TouchableWithoutFeedback, View} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {View, TouchableWithoutFeedback} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {isDocument, isImage, isVideo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import DocumentFile, {DocumentFileRef} from './document_file';
|
||||
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) => {
|
||||
return {
|
||||
fileWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
borderRadius: 5,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginTop: 7.8,
|
||||
marginRight: 6,
|
||||
marginBottom: 8.2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const File = ({
|
||||
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 handlePreviewPress = useCallback(() => {
|
||||
if (document.current) {
|
||||
document.current.handlePreviewPress();
|
||||
} else {
|
||||
onPress(index);
|
||||
}
|
||||
}, [index]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress);
|
||||
|
||||
if (isVideo(file) && publicLinkEnabled) {
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<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>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage(file)) {
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<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>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDocument(file)) {
|
||||
return (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<DocumentFile
|
||||
ref={document}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
<FileInfo
|
||||
file={file}
|
||||
onPress={handlePreviewPress}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[style.fileWrapper]}>
|
||||
<View style={style.iconWrapper}>
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileIcon
|
||||
file={file}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
<FileInfo
|
||||
file={file}
|
||||
onPress={handlePreviewPress}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import {getFormattedFileSize} from '@utils/file';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type FileInfoProps = {
|
||||
file: FileInfo;
|
||||
onPress: () => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
attachmentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fileDownloadContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 3,
|
||||
},
|
||||
fileInfo: {
|
||||
fontSize: 14,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
fileName: {
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 14,
|
||||
fontFamily: 'OpenSans-SemiBold',
|
||||
color: theme.centerChannelColor,
|
||||
paddingRight: 10,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={style.attachmentContainer}>
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileName}
|
||||
>
|
||||
{file.name.trim()}
|
||||
</Text>
|
||||
<View style={style.fileDownloadContainer}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.fileInfo}
|
||||
>
|
||||
{`${getFormattedFileSize(file.size)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInfo;
|
||||
@@ -4,6 +4,7 @@
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native';
|
||||
|
||||
import Files from '@components/files';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import JumboEmoji from '@components/jumbo_emoji';
|
||||
import {Screens} from '@constants';
|
||||
@@ -14,7 +15,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import AddMembers from './add_members';
|
||||
import Content from './content';
|
||||
import Failed from './failed';
|
||||
import Files from './files';
|
||||
import Message from './message';
|
||||
import Reactions from './reactions';
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type TeamModel from '@typings/database/models/servers/team';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
@@ -37,7 +36,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
|
||||
type Props = {
|
||||
channelName: ChannelModel['displayName'];
|
||||
post: PostModel;
|
||||
teamName: TeamModel['displayName'];
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
36
app/hooks/files.ts
Normal file
36
app/hooks/files.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useMemo} from 'react';
|
||||
|
||||
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {isGif, isImage, isVideo} from '@utils/file';
|
||||
|
||||
export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: boolean) => {
|
||||
const serverUrl = useServerUrl();
|
||||
return 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) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!);
|
||||
}
|
||||
images.push({...file, uri});
|
||||
} else {
|
||||
if (videoFile) {
|
||||
// fallback if public links are not enabled
|
||||
file.uri = buildFileUrl(serverUrl, file.id!);
|
||||
}
|
||||
|
||||
nonImages.push(file);
|
||||
}
|
||||
return {images, nonImages};
|
||||
}, {images: [], nonImages: []});
|
||||
}, [filesInfo, publicLinkEnabled]);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native';
|
||||
import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import {Events, Preferences} from '@constants';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {isDocument} from '@utils/file';
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import FileIcon from '@components/post_list/post/body/files/file_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type Props = {
|
||||
fileInfo: FileInfo;
|
||||
layoutWidth?: number;
|
||||
location?: string;
|
||||
metadata?: PostMetadata;
|
||||
postId?: string;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderBottomWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderTopWidth: 1,
|
||||
marginTop: 5,
|
||||
padding: 12,
|
||||
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 100, 'Regular'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function FileCard({fileInfo}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
return (
|
||||
<View style={[style.container, style.border]}>
|
||||
<Text style={style.message}>{'To be implemented'}</Text>
|
||||
<Text style={style.message}>{`Name: ${fileInfo.name}`}</Text>
|
||||
<Text style={style.message}>{`Size: ${fileInfo.size}`}</Text>
|
||||
<FileIcon/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -13,19 +13,18 @@ import {useIsTablet} from '@hooks/device';
|
||||
import {SEPARATOR_MARGIN, SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content';
|
||||
import {bottomSheet} from '@screens/navigation';
|
||||
import {FileFilter, FileFilters} from '@utils/file';
|
||||
import {TabTypes, TabType} from '@utils/search';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from './filter';
|
||||
import SelectButton from './header_button';
|
||||
|
||||
export type SelectTab = 'files' | 'messages'
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
const HEADER_HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
onTabSelect: (tab: SelectTab) => void;
|
||||
onTabSelect: (tab: TabType) => void;
|
||||
onFilterChanged: (filter: FileFilter) => void;
|
||||
selectedTab: SelectTab;
|
||||
selectedTab: TabType;
|
||||
selectedFilter: FileFilter;
|
||||
numberMessages: number;
|
||||
numberFiles: number;
|
||||
@@ -73,15 +72,15 @@ const Header = ({
|
||||
const messagesText = intl.formatMessage({id: 'screen.search.header.messages', defaultMessage: 'Messages'});
|
||||
const filesText = intl.formatMessage({id: 'screen.search.header.files', defaultMessage: 'Files'});
|
||||
|
||||
const showFilterIcon = selectedTab === 'files';
|
||||
const showFilterIcon = selectedTab === TabTypes.FILES;
|
||||
const hasFilters = selectedFilter !== FileFilters.ALL;
|
||||
|
||||
const handleMessagesPress = useCallback(() => {
|
||||
onTabSelect('messages');
|
||||
onTabSelect(TabTypes.MESSAGES);
|
||||
}, [onTabSelect]);
|
||||
|
||||
const handleFilesPress = useCallback(() => {
|
||||
onTabSelect('files');
|
||||
onTabSelect(TabTypes.FILES);
|
||||
}, [onTabSelect]);
|
||||
|
||||
const snapPoints = useMemo(() => {
|
||||
@@ -116,12 +115,12 @@ const Header = ({
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<SelectButton
|
||||
selected={selectedTab === 'messages'}
|
||||
selected={selectedTab === TabTypes.MESSAGES}
|
||||
onPress={handleMessagesPress}
|
||||
text={`${messagesText} (${numberMessages})`}
|
||||
/>
|
||||
<SelectButton
|
||||
selected={selectedTab === 'files'}
|
||||
selected={selectedTab === TabTypes.FILES}
|
||||
onPress={handleFilesPress}
|
||||
text={`${filesText} (${numberFiles})`}
|
||||
/>
|
||||
|
||||
@@ -4,29 +4,51 @@
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import compose from 'lodash/fp/compose';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {queryChannelsById} from '@queries/servers/channel';
|
||||
import {queryPostsById} from '@queries/servers/post';
|
||||
import {observeConfigBooleanValue} from '@queries/servers/system';
|
||||
import {observeLicense, observeConfigBooleanValue} from '@queries/servers/system';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {getTimezone} from '@utils/user';
|
||||
|
||||
import Results from './results';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type enhancedProps = WithDatabaseArgs & {
|
||||
postIds: string[];
|
||||
fileChannelIds: string[];
|
||||
}
|
||||
|
||||
const enhance = withObservables(['postIds'], ({database, postIds}: enhancedProps) => {
|
||||
const posts = queryPostsById(database, postIds).observe();
|
||||
const sortPosts = (a: PostModel, b: PostModel) => a.createAt - b.createAt;
|
||||
|
||||
const enhance = withObservables(['postIds', 'fileChannelIds'], ({database, postIds, fileChannelIds}: enhancedProps) => {
|
||||
const posts = queryPostsById(database, postIds).observeWithColumns(['type', 'createAt']).pipe(
|
||||
switchMap((pp) => of$(pp.sort(sortPosts))),
|
||||
);
|
||||
const fileChannels = queryChannelsById(database, fileChannelIds).observeWithColumns(['displayName']);
|
||||
const currentUser = observeCurrentUser(database);
|
||||
|
||||
const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload');
|
||||
|
||||
const complianceDisabled = observeLicense(database).pipe(
|
||||
switchMap((lcs) => of$(lcs?.IsLicensed === 'false' || lcs?.Compliance === 'false')),
|
||||
);
|
||||
|
||||
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
|
||||
map(([download, compliance]) => compliance || download),
|
||||
);
|
||||
|
||||
return {
|
||||
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
|
||||
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone))))),
|
||||
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
|
||||
posts,
|
||||
fileChannels,
|
||||
canDownloadFiles,
|
||||
publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,4 +56,3 @@ export default compose(
|
||||
withDatabase,
|
||||
enhance,
|
||||
)(Results);
|
||||
|
||||
|
||||
@@ -2,65 +2,118 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, Text, View} from 'react-native';
|
||||
import {StyleSheet, FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, StyleProp, View, ViewStyle} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import File from '@components/files/file';
|
||||
import NoResultsWithTerm from '@components/no_results_with_term';
|
||||
import DateSeparator from '@components/post_list/date_separator';
|
||||
import PostWithChannelInfo from '@components/post_with_channel_info';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {PostModel} from '@database/models/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useImageAttachments} from '@hooks/files';
|
||||
import {isImage, isVideo} from '@utils/file';
|
||||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {getViewPortWidth} from '@utils/images';
|
||||
import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list';
|
||||
import {TabTypes, TabType} from '@utils/search';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import FileCard from './fileCard';
|
||||
import Loader from './loader';
|
||||
|
||||
const notImplementedComponent = (
|
||||
<View
|
||||
style={{
|
||||
height: 800,
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{fontSize: 28, color: '#000'}}>{'Not Implemented'}</Text>
|
||||
</View>
|
||||
);
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
|
||||
type Props = {
|
||||
searchValue: string;
|
||||
selectedTab: 'messages' | 'files';
|
||||
canDownloadFiles: boolean;
|
||||
currentTimezone: string;
|
||||
isTimezoneEnabled: boolean;
|
||||
posts: PostModel[];
|
||||
fileChannels: ChannelModel[];
|
||||
fileInfos: FileInfo[];
|
||||
scrollRef: React.RefObject<FlatList>;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
scrollPaddingTop: number;
|
||||
isTimezoneEnabled: boolean;
|
||||
loading: boolean;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
posts: PostModel[];
|
||||
publicLinkEnabled: boolean;
|
||||
scrollPaddingTop: number;
|
||||
scrollRef: React.RefObject<FlatList>;
|
||||
searchValue: string;
|
||||
selectedTab: TabType;
|
||||
}
|
||||
|
||||
const emptyList: FileInfo[] | Array<string | PostModel> = [];
|
||||
const galleryIdentifier = 'search-files-location';
|
||||
|
||||
const SearchResults = ({
|
||||
const Results = ({
|
||||
canDownloadFiles,
|
||||
currentTimezone,
|
||||
fileChannels,
|
||||
fileInfos,
|
||||
isTimezoneEnabled,
|
||||
loading,
|
||||
onScroll,
|
||||
posts,
|
||||
publicLinkEnabled,
|
||||
scrollPaddingTop,
|
||||
scrollRef,
|
||||
searchValue,
|
||||
selectedTab,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
scrollPaddingTop,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]);
|
||||
|
||||
const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]);
|
||||
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos, publicLinkEnabled);
|
||||
const channelNames = useMemo(() => fileChannels.reduce<{[id: string]: string | undefined}>((acc, v) => {
|
||||
acc[v.id] = v.displayName;
|
||||
return acc;
|
||||
}, {}), [fileChannels]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
let padding = 0;
|
||||
if (selectedTab === TabTypes.MESSAGES) {
|
||||
padding = posts.length ? 4 : 8;
|
||||
} else {
|
||||
padding = fileInfos.length ? 8 : 0;
|
||||
}
|
||||
return {top: padding};
|
||||
}, [selectedTab, posts, fileInfos]);
|
||||
|
||||
const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments),
|
||||
[imageAttachments, nonImageAttachments]);
|
||||
|
||||
const orderedFilesForGallery = useMemo(() => (
|
||||
filesForGallery.sort((a: FileInfo, b: FileInfo) => {
|
||||
return (b.create_at || 0) - (a.create_at || 0);
|
||||
})
|
||||
), [filesForGallery]);
|
||||
|
||||
const filesForGalleryIndexes = useMemo(() => orderedFilesForGallery.reduce<{[id: string]: number | undefined}>((acc, v, idx) => {
|
||||
if (v.id) {
|
||||
acc[v.id] = idx;
|
||||
}
|
||||
return acc;
|
||||
}, {}), [orderedFilesForGallery]);
|
||||
|
||||
const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => {
|
||||
const items = orderedFilesForGallery.map((f) => fileToGalleryItem(f, f.user_id));
|
||||
openGalleryAtIndex(galleryIdentifier, idx, items);
|
||||
}), [orderedFilesForGallery]);
|
||||
|
||||
const handleOptionsPress = useCallback(preventDoubleTap(() => {
|
||||
// hook up in another PR
|
||||
// https://github.com/mattermost/mattermost-mobile/pull/6420
|
||||
// https://mattermost.atlassian.net/browse/MM-44939
|
||||
}), []);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<string|FileInfo | Post>) => {
|
||||
if (typeof item === 'string') {
|
||||
@@ -86,37 +139,73 @@ const SearchResults = ({
|
||||
);
|
||||
}
|
||||
|
||||
const updateFileForGallery = (idx: number, file: FileInfo) => {
|
||||
'worklet';
|
||||
orderedFilesForGallery[idx] = file;
|
||||
};
|
||||
|
||||
const container: StyleProp<ViewStyle> = fileInfos.length > 1 ? styles.container : undefined;
|
||||
const isSingleImage = orderedFilesForGallery.length === 1 && (isImage(orderedFilesForGallery[0]) || isVideo(orderedFilesForGallery[0]));
|
||||
const isReplyPost = false;
|
||||
|
||||
return (
|
||||
<FileCard
|
||||
fileInfo={item}
|
||||
<View
|
||||
style={container}
|
||||
key={item.id}
|
||||
/>
|
||||
>
|
||||
<File
|
||||
channelName={channelNames[item.channel_id!]}
|
||||
galleryIdentifier={galleryIdentifier}
|
||||
key={item.id}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={item}
|
||||
index={filesForGalleryIndexes[item.id!] || 0}
|
||||
onPress={handlePreviewPress}
|
||||
onOptionsPress={handleOptionsPress}
|
||||
theme={theme}
|
||||
isSingleImage={isSingleImage}
|
||||
showDate={true}
|
||||
publicLinkEnabled={publicLinkEnabled}
|
||||
updateFileForGallery={updateFileForGallery}
|
||||
inViewPort={true}
|
||||
wrapperWidth={(getViewPortWidth(isReplyPost, isTablet) - 6)}
|
||||
nonVisibleImagesCount={0}
|
||||
asCard={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [theme]);
|
||||
}, [
|
||||
theme,
|
||||
(orderedFilesForGallery.length === 1) && orderedFilesForGallery[0].mime_type,
|
||||
handleOptionsPress,
|
||||
channelNames,
|
||||
filesForGalleryIndexes,
|
||||
canDownloadFiles,
|
||||
handlePreviewPress,
|
||||
publicLinkEnabled,
|
||||
isTablet,
|
||||
fileInfos.length > 1,
|
||||
]);
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
if (searchValue) {
|
||||
if (loading) {
|
||||
return (<Loader/>);
|
||||
}
|
||||
return (
|
||||
<NoResultsWithTerm
|
||||
term={searchValue}
|
||||
type={selectedTab}
|
||||
/>
|
||||
);
|
||||
if (loading) {
|
||||
return (<Loader/>);
|
||||
}
|
||||
|
||||
return notImplementedComponent;
|
||||
return (
|
||||
<NoResultsWithTerm
|
||||
term={searchValue}
|
||||
type={selectedTab}
|
||||
/>
|
||||
);
|
||||
}, [searchValue, loading, selectedTab]);
|
||||
|
||||
let data;
|
||||
if (loading || !searchValue) {
|
||||
data = emptyList;
|
||||
} else if (selectedTab === 'messages') {
|
||||
} else if (selectedTab === TabTypes.MESSAGES) {
|
||||
data = orderedPosts;
|
||||
} else {
|
||||
data = fileInfos;
|
||||
data = orderedFilesForGallery;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,9 +223,10 @@ const SearchResults = ({
|
||||
onScroll={onScroll}
|
||||
removeClippedSubviews={true}
|
||||
ref={scrollRef}
|
||||
style={containerStyle}
|
||||
testID='search_results.post_list.flat_list'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
export default Results;
|
||||
|
||||
@@ -16,15 +16,17 @@ import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useCollapsibleHeader} from '@hooks/header';
|
||||
import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
|
||||
import {TabTypes, TabType} from '@utils/search';
|
||||
|
||||
import Modifiers from './modifiers';
|
||||
import Results from './results';
|
||||
import Header, {SelectTab} from './results/header';
|
||||
import Header from './results/header';
|
||||
|
||||
const EDGES: Edge[] = ['bottom', 'left', 'right'];
|
||||
|
||||
const emptyFileResults: FileInfo[] = [];
|
||||
const emptyPostResults: string[] = [];
|
||||
const emptyChannelIds: string[] = [];
|
||||
|
||||
type Props = {
|
||||
teamId: string;
|
||||
@@ -46,7 +48,7 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
const {searchTerm} = nav.getState().routes[stateIndex].params;
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>(searchTerm);
|
||||
const [selectedTab, setSelectedTab] = useState<SelectTab>('messages');
|
||||
const [selectedTab, setSelectedTab] = useState<TabType>(TabTypes.MESSAGES);
|
||||
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
@@ -55,6 +57,7 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
|
||||
const [postIds, setPostIds] = useState<string[]>(emptyPostResults);
|
||||
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
|
||||
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
|
||||
|
||||
const getSearchParams = useCallback((filterValue?: FileFilter) => {
|
||||
const terms = filterValue ? lastSearchedValue : searchValue;
|
||||
@@ -72,21 +75,24 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
// - add recent if doesn't exist
|
||||
// - updated recent createdAt if exists??
|
||||
|
||||
const searchParams = getSearchParams();
|
||||
if (!searchParams.terms) {
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setShowResults(true);
|
||||
setFilter(FileFilters.ALL);
|
||||
setLastSearchedValue(searchValue);
|
||||
const searchParams = getSearchParams();
|
||||
const [postResults, fileResults] = await Promise.all([
|
||||
const [postResults, {files, channels}] = await Promise.all([
|
||||
searchPosts(serverUrl, searchParams),
|
||||
searchFiles(serverUrl, teamId, searchParams),
|
||||
]);
|
||||
|
||||
const fileInfosResult = fileResults?.file_infos && Object.values(fileResults?.file_infos);
|
||||
setFileInfos(fileInfosResult?.length ? fileInfosResult : emptyFileResults);
|
||||
setFileInfos(files?.length ? files : emptyFileResults);
|
||||
setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults);
|
||||
|
||||
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
|
||||
setLoading(false);
|
||||
setShowResults(true);
|
||||
})), [searchValue]);
|
||||
|
||||
const onSnap = (offset: number) => {
|
||||
@@ -97,9 +103,9 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
setLoading(true);
|
||||
setFilter(filterValue);
|
||||
const searchParams = getSearchParams(filterValue);
|
||||
const fileResults = await searchFiles(serverUrl, teamId, searchParams);
|
||||
const fileInfosResult = fileResults?.file_infos && Object.values(fileResults?.file_infos);
|
||||
setFileInfos(fileInfosResult?.length ? fileInfosResult : emptyFileResults);
|
||||
const {files, channels} = await searchFiles(serverUrl, teamId, searchParams);
|
||||
setFileInfos(files?.length ? files : emptyFileResults);
|
||||
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
|
||||
|
||||
setLoading(false);
|
||||
}, [lastSearchedValue]);
|
||||
@@ -191,6 +197,7 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
selectedTab={selectedTab}
|
||||
searchValue={lastSearchedValue}
|
||||
postIds={postIds}
|
||||
fileChannelIds={fileChannelIds}
|
||||
fileInfos={fileInfos}
|
||||
scrollRef={scrollRef}
|
||||
onScroll={onScroll}
|
||||
|
||||
10
app/utils/search/index.ts
Normal file
10
app/utils/search/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import keyMirror from '@utils/key_mirror';
|
||||
export const TabTypes = keyMirror({
|
||||
MESSAGES: null,
|
||||
FILES: null,
|
||||
});
|
||||
|
||||
export type TabType = keyof typeof TabTypes;
|
||||
1
types/api/files.d.ts
vendored
1
types/api/files.d.ts
vendored
@@ -4,6 +4,7 @@
|
||||
type FileInfo = {
|
||||
id?: string;
|
||||
bytesRead?: number;
|
||||
channel_id?: string;
|
||||
clientId?: string;
|
||||
create_at?: number;
|
||||
delete_at?: number;
|
||||
|
||||
16
types/api/search.d.ts
vendored
Normal file
16
types/api/search.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
type FileSearchRequest = {
|
||||
error?: unknown;
|
||||
file_infos?: {[id: string]: FileInfo};
|
||||
next_file_info_id?: string;
|
||||
order?: string[];
|
||||
prev_file_info_id?: string;
|
||||
}
|
||||
|
||||
type PostSearchRequest = {
|
||||
error?: unknown;
|
||||
order?: string[];
|
||||
posts?: Post[];
|
||||
}
|
||||
Reference in New Issue
Block a user