[Gekidou - MM-44939] Search Screen - Add File Options Bottom Sheet (#6420)

* 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 implementation

* keep the bottomsheet open until successful download

* remove comment

* initial commit

* don't use raw object values

* channel_id will be defined

* remove shadows
update channel name text

* move file_options to it's own folder
add checks for enabled downloading and public links

* - when touching each menu item, extend the area horizontally so the edge
  of the icon has space between it and the touchable area.
- don't dismiss the modal.  allow the user to dismiss or choose another
  option.
- adjust the horizontal spacing of the toast

origin/MM-44939-file-options

* adjust margin so holding down on a menuItem spans the width of the
bottom sheet

* 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

* initial implementation

* keep the bottomsheet open until successful download

* remove comment

* move file_options to it's own folder
add checks for enabled downloading and public links

* remove shadows
update channel name text

* - when touching each menu item, extend the area horizontally so the edge
  of the icon has space between it and the touchable area.
- don't dismiss the modal.  allow the user to dismiss or choose another
  option.
- adjust the horizontal spacing of the toast

origin/MM-44939-file-options

* adjust margin so holding down on a menuItem spans the width of the
bottom sheet

* delete file

* - use OptionItem instead of MenuItem
- show the actual image for images and video

* update results to use sorted filesForGallery file_options click gets the
correct file

* use the results of the ordered useImageAttachements results as data for
File and Gallery

* remove unnecessary View

* - create snappoints based on number of OptionItems and the header height
- adjust the toast according to the number of items shown
- show and hide items when canDownload and publicLinksEnabled
- bottom sheet will not resize because it is a function, but will show
  and hide items when enabled/disabled

* results does not need to know about the toast margin in FileOption

* extend optionItem to extende to edges of the bottom screen

* When canDownloadFiles or publicLinksEnabled, reopen the bottom sheet
with the correct options available

* initialize lastViewedIndex to undefined with FileOptions first opens

* remove extra empty line

* open the permalink view when select Open in Channel

* 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

* - remove useMemo for renderIcon.  It only returns a component
- remove async from useCallback for handle functions

* - do not modify the file value directly
- handle the case where the only open the bottom sheet if it was open at
  the time of the dependencies changing

* use optional operator

* convert to switch statement

* let the parmalink handle what to do when called.

* just use the post id from fileInfo

* 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

* remove unused constant

* replace any with type

* use more specific dependency

* remove memoization.  fileInfo will not change because it is passed from
a server call and will not update unless a new search is created (with
this modal closed)

* no need to memoize. SetValue is already memoized because of useState

* use observeConfigBooleanValue to get config values

* add comment explaining the purpose of the useEffect

* Merge fixes and minor tweaks

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:
Jason Frerich
2022-07-15 04:44:46 -05:00
committed by GitHub
parent 431406f09b
commit e5273fc43b
9 changed files with 329 additions and 14 deletions

View File

@@ -21,7 +21,7 @@ import type {ResizeMode} from 'react-native-fast-image';
type ImageFileProps = {
backgroundColor?: string;
file: FileInfo;
forwardRef: React.RefObject<unknown>;
forwardRef?: React.RefObject<unknown>;
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;

View File

@@ -22,12 +22,12 @@ import type {ResizeMode} from 'react-native-fast-image';
type Props = {
index: number;
file: FileInfo;
forwardRef: React.RefObject<unknown>;
forwardRef?: React.RefObject<unknown>;
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
wrapperWidth: number;
updateFileForGallery: (idx: number, file: FileInfo) => void;
updateFileForGallery?: (idx: number, file: FileInfo) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@@ -118,7 +118,7 @@ const VideoFile = ({
);
data.height = th;
data.width = tw;
updateFileForGallery(index, data);
updateFileForGallery?.(index, data);
}
};

View File

@@ -28,7 +28,7 @@ export const VALID_IMAGE_MIME_TYPES = [
'application/x-win-bitmap',
] as const;
const Files: Record<string, string[]> = {
export const Files: Record<string, string[]> = {
AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'],
CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'ts', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'],
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif', 'svg', 'psd', 'xcf'],

View File

@@ -22,12 +22,13 @@ export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: bo
}
images.push({...file, uri});
} else {
let uri = file.uri;
if (videoFile) {
// fallback if public links are not enabled
file.uri = buildFileUrl(serverUrl, file.id!);
// fallback if public links are not enabled
uri = buildFileUrl(serverUrl, file.id!);
}
nonImages.push(file);
nonImages.push({...file, uri});
}
return {images, nonImages};
}, {images: [], nonImages: []});

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {View, StyleSheet} from 'react-native';
import {showPermalink} from '@actions/remote/permalink';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import CopyPublicLink from '@screens/gallery/footer/copy_public_link';
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
import Header from './header';
const styles = StyleSheet.create({
toast: {
marginTop: 100,
alignItems: 'center',
},
});
type Props = {
canDownloadFiles: boolean;
enablePublicLink: boolean;
fileInfo: FileInfo;
}
const FileOptions = ({fileInfo, canDownloadFiles, enablePublicLink}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [action, setAction] = useState<GalleryAction>('none');
const galleryItem = {...fileInfo, type: 'image'} as GalleryItemType;
const handleDownload = useCallback(() => {
setAction('downloading');
}, []);
const handleCopyLink = useCallback(() => {
setAction('copying');
}, []);
const handlePermalink = useCallback(() => {
showPermalink(serverUrl, '', fileInfo.post_id, intl);
}, [serverUrl, fileInfo.post_id, intl]);
return (
<View>
<Header fileInfo={fileInfo}/>
{canDownloadFiles &&
<OptionItem
action={handleDownload}
label={intl.formatMessage({id: 'screen.search.results.file_options.download', defaultMessage: 'Download'})}
icon={'download-outline'}
type='default'
/>
}
<OptionItem
action={handlePermalink}
label={intl.formatMessage({id: 'screen.search.results.file_options.open_in_channel', defaultMessage: 'Open in channel'})}
icon={'globe'}
type='default'
/>
{enablePublicLink &&
<OptionItem
action={handleCopyLink}
label={intl.formatMessage({id: 'screen.search.results.file_options.copy_link', defaultMessage: 'Copy link'})}
icon={'link-variant'}
type='default'
/>
}
<View style={styles.toast} >
{action === 'downloading' &&
<DownloadWithAction
action={action}
item={galleryItem}
setAction={setAction}
/>
}
{action === 'copying' &&
<CopyPublicLink
item={galleryItem}
setAction={setAction}
/>
}
</View>
</View>
);
};
export default FileOptions;

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, Text} from 'react-native';
import FormattedDate from '@components/formatted_date';
import {useTheme} from '@context/theme';
import {getFormattedFileSize} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Icon, {ICON_SIZE} from './icon';
const format = 'MMM DD YYYY HH:MM A';
const HEADER_MARGIN = 8;
const FILE_ICON_MARGIN = 8;
const INFO_MARGIN = 8;
export const HEADER_HEIGHT = HEADER_MARGIN +
ICON_SIZE +
FILE_ICON_MARGIN +
(28 * 2) + //400 line height times two lines
(INFO_MARGIN * 2) +
24; // 200 line height
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
headerContainer: {
marginBottom: HEADER_MARGIN,
},
fileIconContainer: {
marginBottom: FILE_ICON_MARGIN,
alignSelf: 'flex-start',
},
nameText: {
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},
infoContainer: {
marginVertical: INFO_MARGIN,
alignItems: 'center',
flexDirection: 'row',
},
infoText: {
flexDirection: 'row',
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 200, 'Regular'),
},
date: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 200, 'Regular'),
},
};
});
type Props = {
fileInfo: FileInfo;
}
const Header = ({fileInfo}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const size = getFormattedFileSize(fileInfo.size);
return (
<View style={style.headerContainer}>
<View style={style.fileIconContainer}>
<Icon fileInfo={fileInfo}/>
</View>
<Text
style={style.nameText}
numberOfLines={2}
ellipsizeMode={'tail'}
>
{fileInfo.name}
</Text>
<View style={style.infoContainer}>
<Text style={style.infoText}>{`${size}`}</Text>
<FormattedDate
style={style.date}
format={format}
value={fileInfo.create_at as number}
/>
</View>
</View>
);
};
export default Header;

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, StyleSheet} from 'react-native';
import FileIcon from '@components/files/file_icon';
import ImageFile from '@components/files/image_file';
import VideoFile from '@components/files/video_file';
import {isImage, isVideo} from '@utils/file';
export const ICON_SIZE = 72;
const styles = StyleSheet.create({
imageVideo: {
height: ICON_SIZE,
width: ICON_SIZE,
},
});
type Props = {
fileInfo: FileInfo;
}
const Icon = ({fileInfo}: Props) => {
switch (true) {
case isImage(fileInfo):
return (
<View style={styles.imageVideo}>
<ImageFile
file={fileInfo}
inViewPort={true}
resizeMode={'cover'}
/>
</View>
);
case isVideo(fileInfo):
return (
<View style={styles.imageVideo}>
<VideoFile
file={fileInfo}
resizeMode={'cover'}
inViewPort={true}
index={0}
wrapperWidth={78}
/>
</View>
);
default:
return (
<FileIcon
file={fileInfo}
iconSize={72}
/>
);
}
};
export default Icon;

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfigBooleanValue, observeLicense} from '@queries/servers/system';
import FileOptions from './file_options';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const license = observeLicense(database);
const enablePublicLink = observeConfigBooleanValue(database, 'EnablePublicLink');
const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload');
const complianceDisabled = license.pipe(switchMap((l) => of$(l?.IsLicensed === 'false' || l?.Compliance === 'false')));
const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe(
switchMap(([download, compliance]) => of$(compliance || download)),
);
return {
canDownloadFiles,
enablePublicLink,
};
});
export default withDatabase(enhanced(FileOptions));

View File

@@ -1,10 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {StyleSheet, FlatList, ListRenderItemInfo, StyleProp, View, ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {ITEM_HEIGHT} from '@app/components/option_item';
import File from '@components/files/file';
import NoResultsWithTerm from '@components/no_results_with_term';
import DateSeparator from '@components/post_list/date_separator';
@@ -13,13 +15,19 @@ import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useImageAttachments} from '@hooks/files';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isImage, isVideo} from '@utils/file';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {bottomSheetSnapPoint} from '@utils/helpers';
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 FileOptions from './file_options';
import {HEADER_HEIGHT} from './file_options/header';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
@@ -61,6 +69,9 @@ const SearchResults = ({
}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const [lastViewedIndex, setLastViewedIndex] = useState<number | undefined>(undefined);
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);
@@ -100,11 +111,49 @@ const SearchResults = ({
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 snapPoints = useMemo(() => {
let numberOptions = 1;
if (canDownloadFiles) {
numberOptions += 1;
}
if (publicLinkEnabled) {
numberOptions += 1;
}
return [bottomSheetSnapPoint(numberOptions, ITEM_HEIGHT, insets.bottom) + HEADER_HEIGHT, 10];
}, [canDownloadFiles, publicLinkEnabled]);
const handleOptionsPress = useCallback((item: number) => {
setLastViewedIndex(item);
const renderContent = () => {
return (
<FileOptions
fileInfo={orderedFilesForGallery[item]}
/>
);
};
bottomSheet({
closeButtonId: 'close-search-file-options',
renderContent,
snapPoints,
theme,
title: '',
});
}, [orderedFilesForGallery, snapPoints, theme]);
// This effect handles the case where a user has the FileOptions Modal
// open and the server changes the ability to download files or copy public
// links. Reopen the Bottom Sheet again so the new options are added or
// removed.
useEffect(() => {
if (lastViewedIndex === undefined) {
return;
}
if (NavigationStore.getNavigationTopComponentId() === 'BottomSheet') {
dismissBottomSheet().then(() => {
handleOptionsPress(lastViewedIndex);
});
}
}, [canDownloadFiles, publicLinkEnabled]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string|FileInfo | Post>) => {
if (typeof item === 'string') {