[Gekidou] Post input (#5844)

* Initial commit post input

* Fix message posting, add create direct channel and minor fixes

* Fix "is typing" and "react to last post" behaviour

* Some reordering, better handling of upload error, properly clear draft on send message, and fix minor progress bar misbehavior

* Add keyboard listener for shift-enter, add selection between video or photo while attaching, add alert when trying to attach more than you are allowed, add paste functionality, minor fixes and reordering

* Add library patch

* Fix lint

* Address feedback

* Address feedback

* Add missing negation

* Check for group name and fix typo on draft comparisons

* Address feedback

* Address feedback

* Address feedback

* Address feedback

* Fix several bugs

* Remove @app imports

* Address feedback

* fix post list & post draft layout on iOS

* Fix post draft cursor position

* Fix file upload route

* Allow to pick multiple images using the image picker

* accurately get the channel member count

* remove android cursor workaround

* Remove local const INPUT_LINE_HEIGHT

* move getPlaceHolder out of the component

* use substring instead of legacy substr for hardward keyboard

* Move onAppStateChange above the effects

* Fix camera action bottom sheet

* no need to memo SendButton

* properly use memberCount in sender handler

* Refactor how to get memberCount

* Fix queryRecentPostsInThread

* Remove unused isDirectChannelVisible && isGroupChannelVisible util functions

* rename errorBadUser to errorUnkownUser

* extract localized strings

* use ClientErrorProps instead of ClientError

* Minor improvements

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-02-03 12:59:15 +01:00
committed by GitHub
parent f815f6b3e5
commit 55324127e1
100 changed files with 4232 additions and 243 deletions

View File

@@ -6,7 +6,7 @@ import React from 'react';
import {Preferences} from '@constants';
import ErrorText from './index';
import ErrorTextComponent from './index';
describe('ErrorText', () => {
const baseProps = {
@@ -21,7 +21,7 @@ describe('ErrorText', () => {
test('should match snapshot', () => {
const wrapper = render(
<ErrorText {...baseProps}/>,
<ErrorTextComponent {...baseProps}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();

View File

@@ -7,16 +7,14 @@ import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {ErrorText as ErrorType} from '@typings/utils/file';
type ErrorProps = {
error: ErrorType;
error: ErrorText;
testID?: string;
textStyle?: StyleProp<ViewStyle> | StyleProp<TextStyle>;
theme: Theme;
}
const ErrorText = ({error, testID, textStyle, theme}: ErrorProps) => {
const ErrorTextComponent = ({error, testID, textStyle, theme}: ErrorProps) => {
const style = getStyleSheet(theme);
const message = typeof (error) === 'string' ? error : error.message;
@@ -55,4 +53,4 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default ErrorText;
export default ErrorTextComponent;

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import Button from 'react-native-button';
import {switchToPenultimateChannel} from '@actions/remote/channel';
import FormattedMarkdownText from '@components/formatted_markdown_text';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import {popToRoot} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID?: string;
deactivated?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
archivedWrapper: {
paddingHorizontal: 20,
paddingVertical: 10,
borderTopWidth: 1,
backgroundColor: theme.centerChannelBg,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
},
archivedText: {
textAlign: 'center',
color: theme.centerChannelColor,
},
closeButton: {
backgroundColor: theme.buttonBg,
alignItems: 'center',
paddingVertical: 5,
borderRadius: 4,
marginTop: 10,
height: 40,
},
closeButtonText: {
marginTop: 7,
color: 'white',
fontWeight: 'bold',
},
}));
export default function Archived({
testID,
deactivated,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const onCloseChannelPress = useCallback(() => {
if (isTablet) {
switchToPenultimateChannel(serverUrl);
} else {
popToRoot();
}
}, [serverUrl, isTablet]);
let message = {
id: t('archivedChannelMessage'),
defaultMessage: 'You are viewing an **archived channel**. New messages cannot be posted.',
};
if (deactivated) {
// only applies to DM's when the user was deactivated
message = {
id: t('create_post.deactivated'),
defaultMessage: 'You are viewing an archived channel with a deactivated user.',
};
}
return (
<View
testID={testID}
style={style.archivedWrapper}
>
<FormattedMarkdownText
{...message}
style={style.archivedText}
baseTextStyle={style.baseTextStyle}
textStyles={style.textStyles}
/>
<Button
containerStyle={style.closeButton}
onPress={onCloseChannelPress}
>
<FormattedText
id='center_panel.archived.closeChannel'
defaultMessage='Close Channel'
style={style.closeButtonText}
/>
</Button>
</View>
);
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import DraftInput from '../draft_input';
type Props = {
testID?: string;
channelId: string;
rootId: string;
// Send Handler
sendMessage: () => void;
maxMessageLength: number;
canSend: boolean;
// Draft Handler
value: string;
uploadFileError: React.ReactNode;
files: FileInfo[];
clearDraft: () => void;
updateValue: (value: string) => void;
addFiles: (files: FileInfo[]) => void;
}
export default function CursorPositionHandler(props: Props) {
const [pos, setCursorPosition] = useState(0);
return (
<DraftInput
{...props}
cursorPosition={pos}
updateCursorPosition={setCursorPosition}
/>
);
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {addFilesToDraft, removeDraft} from '@actions/local/draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@init/draft_upload_manager';
import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/file';
import SendHandler from '../send_handler';
type Props = {
testID?: string;
channelId: string;
rootId?: string;
files?: FileInfo[];
message?: string;
maxFileSize: number;
maxFileCount: number;
canUploadFiles: boolean;
}
const emptyFileList: FileInfo[] = [];
const UPLOAD_ERROR_SHOW_INTERVAL = 5000;
type ErrorHandlers = {
[clientId: string]: (() => void) | null;
}
export default function DraftHandler(props: Props) {
const {
testID,
channelId,
rootId = '',
files,
message,
maxFileSize,
maxFileCount,
canUploadFiles,
} = props;
const serverUrl = useServerUrl();
const intl = useIntl();
const [currentValue, setCurrentValue] = useState(message || '');
const [uploadError, setUploadError] = useState<React.ReactNode>(null);
const uploadErrorTimeout = useRef<NodeJS.Timeout>();
const uploadErrorHandlers = useRef<ErrorHandlers>({});
const clearDraft = useCallback(() => {
removeDraft(serverUrl, channelId, rootId);
setCurrentValue('');
}, [serverUrl, channelId, rootId]);
const newUploadError = useCallback((error: React.ReactNode) => {
if (uploadErrorTimeout.current) {
clearTimeout(uploadErrorTimeout.current);
}
setUploadError(error);
uploadErrorTimeout.current = setTimeout(() => {
setUploadError(null);
}, UPLOAD_ERROR_SHOW_INTERVAL);
}, []);
const addFiles = useCallback((newFiles: FileInfo[]) => {
if (!newFiles.length) {
return;
}
if (!canUploadFiles) {
newUploadError(uploadDisabledWarning(intl));
return;
}
const currentFileCount = files?.length || 0;
const availableCount = maxFileCount - currentFileCount;
if (newFiles.length > availableCount) {
newUploadError(fileMaxWarning(intl, maxFileCount));
return;
}
const largeFile = newFiles.find((file) => file.size > maxFileSize);
if (largeFile) {
newUploadError(fileSizeWarning(intl, maxFileSize));
return;
}
addFilesToDraft(serverUrl, channelId, rootId, newFiles);
for (const file of newFiles) {
DraftUploadManager.prepareUpload(serverUrl, file, channelId, rootId);
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
newUploadError(null);
}, [intl, newUploadError, maxFileCount, maxFileSize, serverUrl, files?.length, channelId, rootId]);
// This effect mainly handles keeping clean the uploadErrorHandlers, and
// reinstantiate them on component mount and file retry.
useEffect(() => {
let loadingFiles: FileInfo[] = [];
if (files) {
loadingFiles = files.filter((v) => v.clientId && DraftUploadManager.isUploading(v.clientId));
}
for (const key of Object.keys(uploadErrorHandlers.current)) {
if (!loadingFiles.find((v) => v.clientId === key)) {
uploadErrorHandlers.current[key]?.();
delete (uploadErrorHandlers.current[key]);
}
}
for (const file of loadingFiles) {
if (!uploadErrorHandlers.current[file.clientId!]) {
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
}
}, [files]);
return (
<SendHandler
testID={testID}
channelId={channelId}
rootId={rootId}
// From draft handler
value={currentValue}
files={files || emptyFileList}
clearDraft={clearDraft}
updateValue={setCurrentValue}
addFiles={addFiles}
uploadFileError={uploadError}
/>
);
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft';
import {isMinimumServerVersion} from '@utils/helpers';
import DraftHandler from './draft_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
import type DraftModel from '@typings/database/models/servers/draft';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, DRAFT}} = MM_TABLES;
type OwnProps = {
channelId: string;
rootId?: string;
}
const enhanced = withObservables([], ({database, channelId, rootId = ''}: WithDatabaseArgs & OwnProps) => {
const draft = database.get<DraftModel>(DRAFT).query(
Q.where('channel_id', channelId),
Q.where('root_id', rootId),
).observeWithColumns(['message', 'files']).pipe(switchMap((v) => of$(v[0])));
const files = draft.pipe(switchMap((d) => of$(d?.files)));
const message = draft.pipe(switchMap((d) => of$(d?.message)));
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c.EnableFileAttachments !== 'false' &&
(l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'),
),
),
);
const maxFileSize = config.pipe(
switchMap((cfg) => of$(parseInt(cfg.MaxFileSize || '0', 10) || DEFAULT_SERVER_MAX_FILE_SIZE)),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)),
);
return {
files,
message,
maxFileSize,
maxFileCount,
canUploadFiles,
};
});
export default withDatabase(enhanced(DraftHandler));

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, ScrollView, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input';
import QuickActions from '../quick_actions';
import SendAction from '../send_action';
import Typing from '../typing';
import Uploads from '../uploads';
type Props = {
testID?: string;
channelId: string;
rootId?: string;
// Cursor Position Handler
updateCursorPosition: (pos: number) => void;
cursorPosition: number;
// Send Handler
sendMessage: () => void;
canSend: boolean;
maxMessageLength: number;
// Draft Handler
files: FileInfo[];
value: string;
uploadFileError: React.ReactNode;
updateValue: (value: string) => void;
addFiles: (files: FileInfo[]) => void;
}
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
actionsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
inputContainer: {
flex: 1,
flexDirection: 'column',
},
inputContentContainer: {
alignItems: 'stretch',
paddingTop: Platform.select({
ios: 7,
android: 0,
}),
},
inputWrapper: {
alignItems: 'flex-end',
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: 2,
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
},
};
});
export default function DraftInput({
testID,
channelId,
files,
maxMessageLength,
rootId = '',
value,
uploadFileError,
sendMessage,
canSend,
updateValue,
addFiles,
updateCursorPosition,
cursorPosition,
}: Props) {
const theme = useTheme();
// const [top, setTop] = useState(0);
// const handleLayout = useCallback((e: LayoutChangeEvent) => {
// setTop(e.nativeEvent.layout.y);
// }, []);
// Render
const postInputTestID = `${testID}.post.input`;
const quickActionsTestID = `${testID}.quick_actions`;
const sendActionTestID = `${testID}.send_action`;
const style = getStyleSheet(theme);
return (
<>
<Typing
channelId={channelId}
rootId={rootId}
/>
{/* {Platform.OS === 'android' &&
<Autocomplete
maxHeight={Math.min(top - AUTOCOMPLETE_MARGIN, DEVICE.AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={handleInputQuickAction}
rootId={rootId}
channelId={channelId}
offsetY={0}
/>
} */}
<SafeAreaView
edges={SAFE_AREA_VIEW_EDGES}
// onLayout={handleLayout}
style={style.inputWrapper}
testID={testID}
>
<ScrollView
style={style.inputContainer}
contentContainerStyle={style.inputContentContainer}
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
pinchGestureEnabled={false}
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
<PostInput
testID={postInputTestID}
channelId={channelId}
maxMessageLength={maxMessageLength}
rootId={rootId}
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}
updateValue={updateValue}
value={value}
addFiles={addFiles}
sendMessage={sendMessage}
/>
<Uploads
files={files}
uploadFileError={uploadFileError}
channelId={channelId}
rootId={rootId}
/>
<View style={style.actionsContainer}>
<QuickActions
testID={quickActionsTestID}
fileCount={files.length}
addFiles={addFiles}
updateValue={updateValue}
value={value}
/>
<SendAction
testID={sendActionTestID}
disabled={!canSend}
sendMessage={sendMessage}
/>
</View>
</ScrollView>
</SafeAreaView>
</>
);
}

View File

@@ -0,0 +1,81 @@
// 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 as from$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {hasPermissionForChannel} from '@utils/role';
import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user';
import PostDraft from './post_draft';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, USER, CHANNEL}} = MM_TABLES;
type OwnProps = {
channelId?: string;
channelIsArchived?: boolean;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => database.get<UserModel>(USER).findAndObserve(value)),
);
let channelId = of$(ownProps.channelId);
if (!ownProps.channelId) {
channelId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => of$(t.value)),
);
}
const channel = channelId.pipe(
switchMap((id) => database.get<ChannelModel>(CHANNEL).findAndObserve(id!)),
);
const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => from$(hasPermissionForChannel(c, u, Permissions.CREATE_POST, false))));
let channelIsArchived = of$(ownProps.channelIsArchived);
if (!channelIsArchived) {
channelIsArchived = channel.pipe(switchMap((c) => of$(c.deleteAt !== 0)));
}
const experimentalTownSquareIsReadOnly = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.ExperimentalTownSquareIsReadOnly === 'true')),
);
const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe(
switchMap(([u, c, readOnly]) => of$(c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u.roles) && readOnly)),
);
const deactivatedChannel = combineLatest([currentUser, channel]).pipe(
switchMap(([u, c]) => {
if (c.type !== General.DM_CHANNEL) {
return of$(false);
}
const teammateId = getUserIdFromChannelName(u.id, c.name);
if (teammateId) {
return database.get<UserModel>(USER).findAndObserve(teammateId).pipe(
switchMap((u2) => of$(Boolean(u2.deleteAt))), // eslint-disable-line max-nested-callbacks
);
}
return of$(true);
}),
);
return {
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
};
});
export default withDatabase(enhanced(PostDraft));

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef} from 'react';
import {DeviceEventEmitter, Platform} from 'react-native';
import {KeyboardTrackingView, KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
import {PostDraft as PostDraftConstants, View as ViewConstants} from '@constants';
import {useIsTablet} from '@hooks/device';
import Archived from './archived';
import DraftHandler from './draft_handler';
import ReadOnly from './read_only';
type Props = {
testID?: string;
accessoriesContainerID?: string;
canPost: boolean;
channelId: string;
channelIsArchived?: boolean;
channelIsReadOnly: boolean;
deactivatedChannel: boolean;
rootId?: string;
scrollViewNativeID?: string;
}
export default function PostDraft({
testID,
accessoriesContainerID,
canPost,
channelId,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
rootId,
scrollViewNativeID,
}: Props) {
const keyboardTracker = useRef<KeyboardTrackingViewRef>(null);
const resetScrollViewAnimationFrame = useRef<number>();
const isTablet = useIsTablet();
const updateNativeScrollView = useCallback((scrollViewNativeIDToUpdate: string) => {
if (keyboardTracker?.current && scrollViewNativeID === scrollViewNativeIDToUpdate) {
resetScrollViewAnimationFrame.current = requestAnimationFrame(() => {
keyboardTracker.current?.resetScrollView(scrollViewNativeIDToUpdate);
if (resetScrollViewAnimationFrame.current != null) {
cancelAnimationFrame(resetScrollViewAnimationFrame.current);
}
resetScrollViewAnimationFrame.current = undefined;
});
}
}, [scrollViewNativeID]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(PostDraftConstants.UPDATE_NATIVE_SCROLLVIEW, updateNativeScrollView);
return () => {
listener.remove();
if (resetScrollViewAnimationFrame.current) {
cancelAnimationFrame(resetScrollViewAnimationFrame.current);
}
};
}, [updateNativeScrollView]);
if (channelIsArchived || deactivatedChannel) {
const archivedTestID = `${testID}.archived`;
return (
<Archived
testID={archivedTestID}
deactivated={deactivatedChannel}
/>
);
}
if (channelIsReadOnly || !canPost) {
const readOnlyTestID = `${testID}.read_only`;
return (
<ReadOnly
testID={readOnlyTestID}
/>
);
}
const draftHandler = (
<DraftHandler
testID={testID}
channelId={channelId}
rootId={rootId}
/>
);
if (Platform.OS === 'android') {
return draftHandler;
}
return (
<KeyboardTrackingView
accessoriesContainerID={accessoriesContainerID}
ref={keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
viewInitialOffsetY={isTablet ? ViewConstants.BOTTOM_TAB_HEIGHT : 0}
>
{draftHandler}
</KeyboardTrackingView>
);
}

View File

@@ -0,0 +1,67 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import PostInput from './post_input';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, CHANNEL}} = MM_TABLES;
type OwnProps = {
channelId: string;
rootId?: string;
}
const enhanced = withObservables([], ({database, channelId, rootId}: WithDatabaseArgs & OwnProps) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const timeBetweenUserTypingUpdatesMilliseconds = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.TimeBetweenUserTypingUpdatesMilliseconds, 10))),
);
const enableUserTypingMessage = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableUserTypingMessages === 'true')),
);
const maxNotificationsPerChannel = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.MaxNotificationsPerChannel, 10))),
);
let channel;
if (rootId) {
channel = database.get<ChannelModel>(CHANNEL).findAndObserve(channelId);
} else {
channel = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => database.get<ChannelModel>(CHANNEL).findAndObserve(t.value)),
);
}
const channelDisplayName = channel.pipe(
switchMap((c) => of$(c.displayName)),
);
const channelInfo = channel.pipe(switchMap((c) => c.info.observe()));
const membersInChannel = channelInfo.pipe(
switchMap((i: ChannelInfoModel) => of$(i.memberCount)),
);
return {
timeBetweenUserTypingUpdatesMilliseconds,
enableUserTypingMessage,
maxNotificationsPerChannel,
channelDisplayName,
membersInChannel,
};
});
export default withDatabase(enhanced(PostInput));

View File

@@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import PasteableTextInput, {PastedFile, PasteInputRef} from '@mattermost/react-native-paste-input';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {
Alert, AppState, AppStateStatus, EmitterSubscription, Keyboard,
KeyboardTypeOptions, NativeSyntheticEvent, Platform, TextInputSelectionChangeEventData,
} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {t} from '@i18n';
import {extractFileInfo} from '@utils/file';
import {switchKeyboardForCodeBlocks} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme} from '@utils/theme';
const HW_EVENT_IN_SCREEN = ['Channel', 'Thread'];
type Props = {
testID?: string;
channelDisplayName?: string;
channelId: string;
maxMessageLength: number;
rootId: string;
timeBetweenUserTypingUpdatesMilliseconds: number;
maxNotificationsPerChannel: number;
enableUserTypingMessage: boolean;
membersInChannel: number;
value: string;
updateValue: (value: string) => void;
addFiles: (files: ExtractedFileInfo[]) => void;
cursorPosition: number;
updateCursorPosition: (pos: number) => void;
sendMessage: () => void;
}
const showPasteFilesErrorDialog = (intl: IntlShape) => {
Alert.alert(
intl.formatMessage({
id: 'mobile.files_paste.error_title',
defaultMessage: 'Paste failed',
}),
intl.formatMessage({
id: 'mobile.files_paste.error_description',
defaultMessage: 'An error occurred while pasting the file(s). Please try again.',
}),
[
{
text: intl.formatMessage({
id: 'mobile.files_paste.error_dismiss',
defaultMessage: 'Dismiss',
}),
},
],
);
};
const getPlaceHolder = (rootId?: string) => {
let placeholder;
if (rootId) {
placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'};
} else {
placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'};
}
return placeholder;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
input: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
paddingHorizontal: 12,
paddingTop: Platform.select({
ios: 6,
android: 8,
}),
paddingBottom: Platform.select({
ios: 6,
android: 2,
}),
minHeight: 30,
},
}));
export default function PostInput({
testID,
channelDisplayName,
channelId,
maxMessageLength,
rootId,
timeBetweenUserTypingUpdatesMilliseconds,
maxNotificationsPerChannel,
enableUserTypingMessage,
membersInChannel,
value,
updateValue,
addFiles,
cursorPosition,
updateCursorPosition,
sendMessage,
}: Props) {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig();
const lastTypingEventSent = useRef(0);
const input = useRef<PasteInputRef>();
const lastNativeValue = useRef('');
const previousAppState = useRef(AppState.currentState);
const [keyboardType, setKeyboardType] = useState<KeyboardTypeOptions>('default');
const [longMessageAlertShown, setLongMessageAlertShown] = useState(false);
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const maxHeight = isTablet ? 150 : 88;
const pasteInputStyle = useMemo(() => {
return {...style.input, maxHeight};
}, [maxHeight]);
const blur = () => {
input.current?.blur();
};
const handleAndroidKeyboard = () => {
blur();
};
const onBlur = useCallback(() => {
updateDraftMessage(serverUrl, channelId, rootId, value);
}, [channelId, rootId, value]);
const checkMessageLength = useCallback((newValue: string) => {
const valueLength = newValue.trim().length;
if (valueLength > maxMessageLength) {
// Check if component is already aware message is too long
if (!longMessageAlertShown) {
Alert.alert(
intl.formatMessage({
id: 'mobile.message_length.title',
defaultMessage: 'Message Length',
}),
intl.formatMessage({
id: 'mobile.message_length.message',
defaultMessage: 'Your current message is too long. Current character count: {count}/{max}',
}, {
max: maxMessageLength,
count: valueLength,
}),
);
setLongMessageAlertShown(true);
}
} else if (longMessageAlertShown) {
setLongMessageAlertShown(false);
}
}, [intl, longMessageAlertShown, maxMessageLength]);
const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent<TextInputSelectionChangeEventData> | null, fromHandleTextChange = false) => {
const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end;
if (Platform.OS === 'ios') {
const newKeyboardType = switchKeyboardForCodeBlocks(value, cp);
setKeyboardType(newKeyboardType);
}
updateCursorPosition(cp);
}, [updateCursorPosition, cursorPosition]);
const handleTextChange = useCallback((newValue: string) => {
updateValue(newValue);
lastNativeValue.current = newValue;
checkMessageLength(newValue);
// Workaround to avoid iOS emdash autocorrect in Code Blocks
if (Platform.OS === 'ios') {
handlePostDraftSelectionChanged(null, true);
}
if (
newValue &&
lastTypingEventSent.current + timeBetweenUserTypingUpdatesMilliseconds < Date.now() &&
membersInChannel < maxNotificationsPerChannel &&
enableUserTypingMessage
) {
userTyping(serverUrl, channelId, rootId);
lastTypingEventSent.current = Date.now();
}
}, [
updateValue,
checkMessageLength,
handlePostDraftSelectionChanged,
timeBetweenUserTypingUpdatesMilliseconds,
channelId,
rootId,
(membersInChannel < maxNotificationsPerChannel) && enableUserTypingMessage,
]);
const onPaste = useCallback(async (error: string | null | undefined, files: PastedFile[]) => {
if (error) {
showPasteFilesErrorDialog(intl);
}
addFiles(await extractFileInfo(files));
}, [addFiles, intl]);
const handleHardwareEnterPress = useCallback((keyEvent: {pressedKey: string}) => {
if (HW_EVENT_IN_SCREEN.includes(rootId ? Screens.THREAD : Screens.CHANNEL)) {
switch (keyEvent.pressedKey) {
case 'enter':
sendMessage();
break;
case 'shift-enter':
updateValue(value.substring(0, cursorPosition) + '\n' + value.substring(cursorPosition));
updateCursorPosition(cursorPosition + 1);
break;
}
}
}, [sendMessage, updateValue, value, cursorPosition]);
const onAppStateChange = useCallback((appState: AppStateStatus) => {
if (appState !== 'active' && previousAppState.current === 'active') {
updateDraftMessage(serverUrl, channelId, rootId, value);
}
previousAppState.current = appState;
}, [serverUrl, channelId, rootId, value]);
useEffect(() => {
let keyboardListener: EmitterSubscription | undefined;
if (Platform.OS === 'android') {
keyboardListener = Keyboard.addListener('keyboardDidHide', handleAndroidKeyboard);
}
return (() => {
keyboardListener?.remove();
});
}, []);
useEffect(() => {
const listener = AppState.addEventListener('change', onAppStateChange);
return () => {
listener.remove();
};
}, [onAppStateChange]);
useEffect(() => {
if (value !== lastNativeValue.current) {
// May change when we implement Fabric
input.current?.setNativeProps({
text: value,
selection: {start: cursorPosition},
});
lastNativeValue.current = value;
}
}, [value]);
useEffect(() => {
HWKeyboardEvent.onHWKeyPressed(handleHardwareEnterPress);
return () => {
HWKeyboardEvent.removeOnHWKeyPressed();
};
}, [handleHardwareEnterPress]);
useDidUpdate(() => {
if (!value) {
if (Platform.OS === 'android') {
// Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages
// are typed successively without blurring the input
setKeyboardType('email-address');
}
}
}, [value]);
useDidUpdate(() => {
if (Platform.OS === 'android' && keyboardType === 'email-address') {
setKeyboardType('default');
}
}, [keyboardType]);
return (
<PasteableTextInput
allowFontScaling={true}
testID={testID}
ref={input}
disableCopyPaste={disableCopyAndPaste}
style={pasteInputStyle}
onChangeText={handleTextChange}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
multiline={true}
onBlur={onBlur}
blurOnSubmit={false}
underlineColorAndroid='transparent'
keyboardType={keyboardType}
onPaste={onPaste}
disableFullscreenUI={true}
textContentType='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
);
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, DeviceEventEmitter} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Navigation} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
onPress: (options: CameraOptions) => void;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
center: {
alignItems: 'center',
},
container: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 200,
paddingVertical: 10,
},
flex: {
flex: 1,
},
options: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
justifyContent: 'space-evenly',
width: '100%',
marginBottom: 50,
},
optionContainer: {
alignItems: 'flex-start',
},
title: {
color: theme.centerChannelColor,
fontSize: 18,
fontWeight: 'bold',
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
},
}));
const CameraType = ({onPress}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
const onPhoto = () => {
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
onPress(options);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
};
const onVideo = () => {
const options: CameraOptions = {
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
onPress(options);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
};
return (
<View style={style.container}>
{!isTablet &&
<FormattedText
id='camera_type.title'
defaultMessage='Choose an action'
style={style.title}
/>
}
<View style={style.options}>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onPhoto}
testID='camera_type.photo'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='camera-outline'
size={38}
/>
<FormattedText
id='camera_type.photo.option'
defaultMessage='Capture Photo'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onVideo}
testID='camera_type.video'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='video-outline'
size={38}
/>
<FormattedText
id='camera_type.video.option'
defaultMessage='Record Video'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
</View>
</View>
);
};
export default CameraType;

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {bottomSheet} from '@screens/navigation';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import CameraType from './camera_type';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function CameraQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID,
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback((options: CameraOptions) => {
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromCamera(options);
}, [intl, onUploadFiles]);
const renderContent = useCallback(() => {
return (
<CameraType
onPress={handleButtonPress}
/>
);
}, [handleButtonPress]);
const openSelectorModal = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
bottomSheet({
title: intl.formatMessage({id: 'camera_type.title', defaultMessage: 'Choose an action'}),
renderContent,
snapPoints: [200, 10],
theme,
closeButtonId: 'camera-close-id',
});
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openSelectorModal}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='camera-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function FileQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromFiles();
}, [onUploadFiles]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='file-generic-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function ImageQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromPhotoGallery(maxFileCount - fileCount);
}, [onUploadFiles, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='image-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {isMinimumServerVersion} from '@utils/helpers';
import QuickActions from './quick_actions';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c.EnableFileAttachments !== 'false' &&
(l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'),
),
),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)),
);
return {
canUploadFiles,
maxFileCount,
};
});
export default withDatabase(enhanced(QuickActions));

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID?: string;
disabled?: boolean;
inputType: 'at' | 'slash';
onTextChange: (value: string) => void;
value: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disabled: {
tintColor: changeOpacity(theme.centerChannelColor, 0.16),
},
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
};
});
export default function InputQuickAction({
testID,
disabled,
inputType,
onTextChange,
value,
}: Props) {
const theme = useTheme();
const onPress = useCallback(() => {
let newValue = '/';
if (inputType === 'at') {
newValue = `${value}@`;
}
onTextChange(newValue);
}, [value, inputType]);
const actionTestID = disabled ?
`${testID}.disabled` :
testID;
const style = getStyleSheet(theme);
const iconName = inputType === 'at' ? inputType : 'slash-forward-box-outline';
const iconColor = disabled ?
changeOpacity(theme.centerChannelColor, 0.16) :
changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={onPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
name={iconName}
color={iconColor}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import CameraAction from './camera_quick_action';
import FileAction from './file_quick_action';
import ImageAction from './image_quick_action';
import InputAction from './input_quick_action';
type Props = {
testID?: string;
canUploadFiles: boolean;
fileCount: number;
maxFileCount: number;
// Draft Handler
value: string;
updateValue: (value: string) => void;
addFiles: (file: FileInfo[]) => void;
}
const style = StyleSheet.create({
container: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
quickActionsContainer: {
display: 'flex',
flexDirection: 'row',
height: 44,
},
});
export default function QuickActions({
testID,
canUploadFiles,
value,
fileCount,
maxFileCount,
updateValue,
addFiles,
}: Props) {
const atDisabled = value[value.length - 1] === '@';
const slashDisabled = value.length > 0;
const atInputActionTestID = `${testID}.at_input_action`;
const slashInputActionTestID = `${testID}.slash_input_action`;
const fileActionTestID = `${testID}.file_action`;
const imageActionTestID = `${testID}.image_action`;
const cameraActionTestID = `${testID}.camera_action`;
const uploadProps = {
disabled: !canUploadFiles,
fileCount,
maxFileCount,
maxFilesReached: fileCount >= maxFileCount,
onUploadFiles: addFiles,
};
return (
<View
testID={testID}
style={style.quickActionsContainer}
>
<InputAction
testID={atInputActionTestID}
disabled={atDisabled}
inputType='at'
onTextChange={updateValue}
value={value}
/>
<InputAction
testID={slashInputActionTestID}
disabled={slashDisabled}
inputType='slash'
onTextChange={updateValue}
value={''} // Only enabled when value == ''
/>
<FileAction
testID={fileActionTestID}
{...uploadProps}
/>
<ImageAction
testID={imageActionTestID}
{...uploadProps}
/>
<CameraAction
testID={cameraActionTestID}
{...uploadProps}
/>
</View>
);
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ReadOnlyProps {
testID?: string;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
background: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
container: {
alignItems: 'center',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
borderTopWidth: 1,
flexDirection: 'row',
height: 50,
paddingHorizontal: 12,
},
icon: {
fontSize: 20,
lineHeight: 22,
opacity: 0.56,
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
marginLeft: 9,
opacity: 0.56,
},
}));
const safeAreaEdges = ['bottom' as const];
const ReadOnlyChannnel = ({testID}: ReadOnlyProps) => {
const theme = useTheme();
const style = getStyle(theme);
return (
<SafeAreaView
edges={safeAreaEdges}
style={style.background}
>
<View
testID={testID}
style={style.container}
>
<CompassIcon
name='glasses'
style={style.icon}
color={theme.centerChannelColor}
/>
<FormattedText
id='mobile.create_post.read_only'
defaultMessage='This channel is read-only.'
style={style.text}
/>
</View>
</SafeAreaView>
);
};
export default ReadOnlyChannnel;

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID: string;
disabled: boolean;
sendMessage: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
sendButtonContainer: {
justifyContent: 'flex-end',
paddingRight: 8,
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 4,
height: 32,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
};
});
function SendButton({
testID,
disabled,
sendMessage,
}: Props) {
const theme = useTheme();
const sendButtonTestID = `${testID}.send.button`;
const style = getStyleSheet(theme);
const viewStyle = useMemo(() => {
if (disabled) {
return [style.sendButton, style.disableButton];
}
return style.sendButton;
}, [disabled, style]);
const buttonColor = disabled ? changeOpacity(theme.buttonColor, 0.5) : theme.buttonColor;
return (
<TouchableWithFeedback
testID={sendButtonTestID}
onPress={sendMessage}
style={style.sendButtonContainer}
type={'opacity'}
disabled={disabled}
>
<View style={viewStyle}>
<CompassIcon
name='send'
size={24}
color={buttonColor}
/>
</View>
</TouchableWithFeedback>
);
}
export default SendButton;

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$, from as from$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {hasPermissionForChannel} from '@utils/role';
import SendHandler from './send_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type GroupModel from '@typings/database/models/servers/group';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, USER, CHANNEL, GROUP, GROUPS_TEAM, GROUPS_CHANNEL, CUSTOM_EMOJI}} = MM_TABLES;
type OwnProps = {
rootId: string;
channelId: string;
channelIsArchived?: boolean;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const {rootId, channelId} = ownProps;
let channel;
if (rootId) {
channel = database.get<ChannelModel>(CHANNEL).findAndObserve(channelId);
} else {
channel = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => database.get<ChannelModel>(CHANNEL).findAndObserve(t.value)),
);
}
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const currentUser = currentUserId.pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id)),
);
const userIsOutOfOffice = currentUser.pipe(
switchMap((u) => of$(u.status === General.OUT_OF_OFFICE)),
);
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const enableConfirmNotificationsToChannel = config.pipe(
switchMap((cfg) => of$(Boolean(cfg.EnableConfirmNotificationsToChannel === 'true'))),
);
const isTimezoneEnabled = config.pipe(
switchMap((cfg) => of$(Boolean(cfg.ExperimentalTimezone === 'true'))),
);
const maxMessageLength = config.pipe(
switchMap((cfg) => of$(parseInt(cfg.MaxPostSize || '0', 10) || MAX_MESSAGE_LENGTH_FALLBACK)),
);
const useChannelMentions = combineLatest([channel, currentUser]).pipe(
switchMap(([c, u]) => {
if (!c) {
return of$(true);
}
return from$(hasPermissionForChannel(c, u, Permissions.USE_CHANNEL_MENTIONS, false));
}),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const useGroupMentions = combineLatest([channel, currentUser, license]).pipe(
switchMap(([c, u, l]) => {
if (!c || l?.IsLicensed !== 'true') {
return of$(false);
}
return from$(hasPermissionForChannel(c, u, Permissions.USE_GROUP_MENTIONS, true));
}),
);
const groupsWithAllowReference = channel.pipe(switchMap(
(c) => database.get<GroupModel>(GROUP).query(
Q.experimentalJoinTables([GROUPS_TEAM, GROUPS_CHANNEL]),
Q.or(Q.on(GROUPS_TEAM, 'team_id', c.teamId), Q.on(GROUPS_CHANNEL, 'channel_id', c.id)),
).observeWithColumns(['name'])),
);
const channelInfo = channel.pipe(switchMap((c) => c.info.observe()));
const membersCount = channelInfo.pipe(
switchMap((i: ChannelInfoModel) => of$(i.memberCount)),
);
const customEmojis = database.get<CustomEmojiModel>(CUSTOM_EMOJI).query().observe();
return {
currentUserId,
enableConfirmNotificationsToChannel,
isTimezoneEnabled,
maxMessageLength,
membersCount,
userIsOutOfOffice,
useChannelMentions,
useGroupMentions,
groupsWithAllowReference,
customEmojis,
};
});
export default withDatabase(enhanced(SendHandler));

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {getChannelMemberCountsByGroup, getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
import {handleReactionToLatestPost} from '@actions/remote/reactions';
import {setStatus} from '@actions/remote/user';
import {Events, Screens} from '@constants';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@init/draft_upload_manager';
import * as DraftUtils from '@utils/draft';
import {isReactionMatch} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {confirmOutOfOfficeDisabled} from '@utils/user';
import CursorPositionHandler from '../cursor_position_handler';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type GroupModel from '@typings/database/models/servers/group';
type Props = {
testID?: string;
channelId: string;
rootId: string;
// From database
currentUserId: string;
enableConfirmNotificationsToChannel?: boolean;
isTimezoneEnabled: boolean;
maxMessageLength: number;
membersCount?: number;
useChannelMentions: boolean;
userIsOutOfOffice: boolean;
useGroupMentions: boolean;
groupsWithAllowReference: GroupModel[];
customEmojis: CustomEmojiModel[];
// DRAFT Handler
value: string;
files: FileInfo[];
clearDraft: () => void;
updateValue: (message: string) => void;
addFiles: (file: FileInfo[]) => void;
uploadFileError: React.ReactNode;
}
export default function SendHandler({
testID,
channelId,
currentUserId,
enableConfirmNotificationsToChannel,
files,
isTimezoneEnabled,
maxMessageLength,
membersCount = 0,
rootId,
useChannelMentions,
userIsOutOfOffice,
customEmojis,
value,
useGroupMentions,
groupsWithAllowReference,
clearDraft,
updateValue,
addFiles,
uploadFileError,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
const [sendingMessage, setSendingMessage] = useState(false);
const [channelMemberCountsByGroup, setChannelMemberCountsByGroup] = useState<ChannelMemberCountByGroup[]>([]);
const canSend = useCallback(() => {
if (sendingMessage) {
return false;
}
const messageLength = value.trim().length;
if (messageLength > maxMessageLength) {
return false;
}
if (files.length) {
const loadingComplete = !files.some((file) => DraftUploadManager.isUploading(file.clientId!));
return loadingComplete;
}
return messageLength > 0;
}, [sendingMessage, value, files, maxMessageLength]);
const handleReaction = useCallback((emoji: string, add: boolean) => {
handleReactionToLatestPost(serverUrl, emoji, add, rootId);
clearDraft();
setSendingMessage(false);
}, [serverUrl, rootId, clearDraft]);
const doSubmitMessage = useCallback(() => {
const postFiles = files.filter((f) => !f.failed);
const post = {
user_id: currentUserId,
channel_id: channelId,
root_id: rootId,
message: value,
};
createPost(serverUrl, post, postFiles);
clearDraft();
setSendingMessage(false);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
}, [files, currentUserId, channelId, rootId, value, clearDraft]);
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, Boolean(isTimezoneEnabled), channelTimezoneCount, atHere);
const cancel = () => {
setSendingMessage(false);
};
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
}, [intl, isTimezoneEnabled, channelTimezoneCount, doSubmitMessage]);
const showSendToGroupsAlert = useCallback((groupMentions: string[], memberNotifyCount: number, calculatedChannelTimezoneCount: number) => {
const notifyAllMessage = DraftUtils.buildGroupMentionsMessage(intl, groupMentions, memberNotifyCount, calculatedChannelTimezoneCount);
const cancel = () => {
setSendingMessage(false);
};
DraftUtils.alertSendToGroups(intl, notifyAllMessage, doSubmitMessage, cancel);
}, [intl, doSubmitMessage]);
const sendCommand = useCallback(async () => {
const status = DraftUtils.getStatusFromSlashCommand(value);
if (userIsOutOfOffice && status) {
const updateStatus = (newStatus: string) => {
setStatus(serverUrl, {
status: newStatus,
last_activity_at: Date.now(),
manual: true,
user_id: currentUserId,
});
};
confirmOutOfOfficeDisabled(intl, status, updateStatus);
setSendingMessage(false);
return;
}
const {data, error} = await executeCommand(serverUrl, intl, value, channelId, rootId);
setSendingMessage(false);
if (error) {
const errorMessage = typeof (error) === 'string' ? error : error.message;
DraftUtils.alertSlashCommandFailed(intl, errorMessage);
return;
}
clearDraft();
// TODO Apps related https://mattermost.atlassian.net/browse/MM-41233
// if (data?.form) {
// showAppForm(data.form, data.call, theme);
// }
if (data?.goto_location) {
handleGotoLocation(serverUrl, intl, data.goto_location);
}
}, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId]);
const sendMessage = useCallback(() => {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const toHere = DraftUtils.textContainsAtHere(value);
const groupMentions = (!toAllOrChannel && !toHere && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
if (value.indexOf('/') === 0) {
sendCommand();
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel);
} else if (groupMentions.length > 0) {
const {
groupMentionsSet,
memberNotifyCount,
channelTimezoneCount: calculatedChannelTimezoneCount,
} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions);
if (memberNotifyCount > 0) {
showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, calculatedChannelTimezoneCount);
} else {
doSubmitMessage();
}
} else {
doSubmitMessage();
}
}, [
enableConfirmNotificationsToChannel,
useChannelMentions,
useGroupMentions,
value,
groupsWithAllowReference,
channelTimezoneCount,
channelMemberCountsByGroup,
sendCommand,
showSendToAllOrChannelOrHereAlert,
showSendToGroupsAlert,
doSubmitMessage,
]);
const handleSendMessage = useCallback(preventDoubleTap(() => {
if (!canSend()) {
return;
}
setSendingMessage(true);
const match = isReactionMatch(value, customEmojis);
if (match && !files.length) {
handleReaction(match.emoji, match.add);
return;
}
const hasFailedAttachments = files.some((f) => f.failed);
if (hasFailedAttachments) {
const cancel = () => {
setSendingMessage(false);
};
const accept = () => {
// Files are filtered on doSubmitMessage
sendMessage();
};
DraftUtils.alertAttachmentFail(intl, accept, cancel);
} else {
sendMessage();
}
}), [canSend, value, handleReaction, files, sendMessage, customEmojis]);
useEffect(() => {
if (useGroupMentions) {
getChannelMemberCountsByGroup(serverUrl, channelId, isTimezoneEnabled).then((resp) => {
if (resp.error) {
return;
}
const received = resp.channelMemberCountsByGroup || [];
if (received.length || channelMemberCountsByGroup.length) {
setChannelMemberCountsByGroup(received);
}
});
}
}, [useGroupMentions, channelId, isTimezoneEnabled, channelMemberCountsByGroup.length]);
useEffect(() => {
getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => {
setChannelTimezoneCount(channelTimezones?.length || 0);
});
}, [serverUrl, channelId]);
return (
<CursorPositionHandler
testID={testID}
channelId={channelId}
rootId={rootId}
// From draft handler
value={value}
files={files}
clearDraft={clearDraft}
updateValue={updateValue}
addFiles={addFiles}
uploadFileError={uploadFileError}
// From send handler
sendMessage={handleSendMessage}
canSend={canSend()}
maxMessageLength={maxMessageLength}
/>
);
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {
DeviceEventEmitter,
Platform,
Text,
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import {Events} from '@constants';
import {TYPING_HEIGHT} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
channelId: string;
rootId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
typing: {
position: 'absolute',
paddingLeft: 10,
paddingTop: 3,
fontSize: 11,
...Platform.select({
android: {
marginBottom: 5,
},
ios: {
marginBottom: 2,
},
}),
color: theme.centerChannelColor,
backgroundColor: 'transparent',
},
};
});
export default function Typing({
channelId,
rootId,
}: Props) {
const typingHeight = useSharedValue(0);
const typing = useRef<Array<{id: string; now: number; username: string}>>([]);
const [refresh, setRefresh] = useState(0);
const theme = useTheme();
const style = getStyleSheet(theme);
// This moves the list of post up. This may be rethought by UX in https://mattermost.atlassian.net/browse/MM-39681
const typingAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(typingHeight.value),
};
});
const onUserStartTyping = useCallback((msg: any) => {
if (channelId !== msg.channelId) {
return;
}
const msgRootId = msg.parentId || '';
if (rootId !== msgRootId) {
return;
}
typing.current = typing.current.filter(({id}) => id !== msg.userId);
typing.current.push({id: msg.userId, now: msg.now, username: msg.username});
setRefresh(Date.now());
}, [channelId, rootId]);
const onUserStopTyping = useCallback((msg: any) => {
if (channelId !== msg.channelId) {
return;
}
const msgRootId = msg.parentId || '';
if (rootId !== msgRootId) {
return;
}
typing.current = typing.current.filter(({id, now}) => id !== msg.userId && now !== msg.now);
setRefresh(Date.now());
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.USER_TYPING, onUserStartTyping);
return () => {
listener.remove();
};
}, [onUserStartTyping]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.USER_STOP_TYPING, onUserStopTyping);
return () => {
listener.remove();
};
}, [onUserStopTyping]);
useEffect(() => {
typingHeight.value = typing.current.length ? TYPING_HEIGHT : 0;
}, [refresh]);
const renderTyping = () => {
const nextTyping = typing.current.map(({username}) => username);
// Max three names
nextTyping.splice(3);
const numUsers = nextTyping.length;
switch (numUsers) {
case 0:
return null;
case 1:
return (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: nextTyping[0],
}}
/>
);
default: {
const last = nextTyping.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (nextTyping.join(', ')),
last,
}}
/>
);
}
}
};
return (
<Animated.View style={typingAnimatedStyle}>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
>
{renderTyping()}
</Text>
</Animated.View>
);
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {
ScrollView,
Text,
View,
Platform,
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@init/draft_upload_manager';
import {openGalleryAtIndex} from '@utils/gallery';
import {makeStyleSheetFromTheme} from '@utils/theme';
import UploadItem from './upload_item';
const CONTAINER_HEIGHT_MAX = 67;
const CONATINER_HEIGHT_MIN = 0;
const ERROR_HEIGHT_MAX = 20;
const ERROR_HEIGHT_MIN = 0;
type Props = {
files: FileInfo[];
uploadFileError: React.ReactNode;
channelId: string;
rootId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
previewContainer: {
display: 'flex',
flexDirection: 'column',
},
fileContainer: {
display: 'flex',
flexDirection: 'row',
height: 0,
},
errorContainer: {
height: 0,
},
errorTextContainer: {
marginTop: Platform.select({
ios: 4,
android: 2,
}),
marginHorizontal: 12,
flex: 1,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
alignItems: 'flex-end',
paddingRight: 12,
},
warning: {
color: theme.errorTextColor,
flex: 1,
flexWrap: 'wrap',
},
};
});
export default function Uploads({
files,
uploadFileError,
channelId,
rootId,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const errorHeight = useSharedValue(ERROR_HEIGHT_MIN);
const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX);
const errorAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(errorHeight.value),
};
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(containerHeight.value),
};
});
const fileContainerStyle = {
paddingBottom: files.length ? 5 : 0,
};
useEffect(() => {
if (uploadFileError) {
errorHeight.value = ERROR_HEIGHT_MAX;
} else {
errorHeight.value = ERROR_HEIGHT_MIN;
}
}, [uploadFileError]);
useEffect(() => {
if (files.length) {
containerHeight.value = CONTAINER_HEIGHT_MAX;
return;
}
containerHeight.value = CONATINER_HEIGHT_MIN;
}, [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 buildFilePreviews = () => {
return files.map((file) => {
return (
<UploadItem
key={file.clientId}
file={file}
openGallery={openGallery}
channelId={channelId}
rootId={rootId}
/>
);
});
};
return (
<View style={style.previewContainer}>
<Animated.View
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
>
<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}>
<Text style={style.warning}>
{uploadFileError}
</Text>
</View>
}
</Animated.View>
</View>
);
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
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 ProgressBar from '@components/progress_bar';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import DraftUploadManager from '@init/draft_upload_manager';
import {isImage} from '@utils/file';
import {changeOpacity} from '@utils/theme';
import UploadRemove from './upload_remove';
import UploadRetry from './upload_retry';
type Props = {
file: FileInfo;
channelId: string;
rootId: string;
openGallery: (file: FileInfo) => void;
}
const styles = StyleSheet.create({
preview: {
paddingTop: 5,
marginLeft: 12,
},
previewContainer: {
height: 56,
width: 56,
borderRadius: 4,
},
progress: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
height: 53,
width: 53,
justifyContent: 'flex-end',
position: 'absolute',
borderRadius: 4,
paddingLeft: 3,
},
filePreview: {
width: 56,
height: 56,
},
});
export default function UploadItem({
file,
channelId,
rootId,
openGallery,
}: Props) {
const theme = useTheme();
const serverUrl = useServerUrl();
const removeCallback = useRef<(() => void)|null>(null);
const [progress, setProgress] = useState(0);
const loading = DraftUploadManager.isUploading(file.clientId!);
const handlePress = useCallback(() => {
openGallery(file);
}, [openGallery, file]);
useEffect(() => {
if (file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
};
}, []);
useDidUpdate(() => {
if (loading && file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
};
}, [file.failed, file.id]);
const retryFileUpload = useCallback(() => {
if (!file.failed) {
return;
}
const newFile = {...file};
newFile.failed = false;
updateDraftFile(serverUrl, channelId, rootId, newFile);
DraftUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
}, [serverUrl, channelId, rootId, file]);
const filePreviewComponent = useMemo(() => {
if (isImage(file)) {
return (
<ImageFile
file={file}
resizeMode='cover'
/>
);
}
return (
<FileIcon
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
iconSize={60}
file={file}
/>
);
}, [file]);
return (
<View
key={file.clientId}
style={styles.preview}
>
<View style={styles.previewContainer}>
<TouchableOpacity onPress={handlePress}>
<View style={styles.filePreview}>
{filePreviewComponent}
</View>
</TouchableOpacity>
{file.failed &&
<UploadRetry
onPress={retryFileUpload}
/>
}
{loading && !file.failed &&
<View style={styles.progress}>
<ProgressBar
progress={progress || 0}
color={theme.buttonBg}
/>
</View>
}
</View>
<UploadRemove
clientId={file.clientId!}
channelId={channelId}
rootId={rootId}
/>
</View>
);
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, Platform} from 'react-native';
import {removeDraftFile} from '@actions/local/draft';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@init/draft_upload_manager';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
type Props = {
channelId: string;
rootId: string;
clientId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
tappableContainer: {
position: 'absolute',
elevation: 11,
top: -7,
right: -8,
width: 24,
height: 24,
},
removeButton: {
borderRadius: 12,
alignSelf: 'center',
marginTop: Platform.select({
ios: 5.4,
android: 4.75,
}),
backgroundColor: theme.centerChannelBg,
width: 24,
height: 25,
},
};
});
export default function UploadRemove({
channelId,
rootId,
clientId,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = () => {
DraftUploadManager.cancel(clientId);
removeDraftFile(serverUrl, channelId, rootId, clientId);
};
return (
<TouchableWithFeedback
style={style.tappableContainer}
onPress={onPress}
type={'opacity'}
>
<View style={style.removeButton}>
<CompassIcon
name='close-circle'
color={changeOpacity(theme.centerChannelColor, 0.64)}
size={24}
style={style.removeIcon}
/>
</View>
</TouchableWithFeedback>
);
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
type Props = {
onPress: () => void;
}
const style = StyleSheet.create({
failed: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
},
});
export default function UploadRetry({
onPress,
}: Props) {
return (
<TouchableWithFeedback
style={style.failed}
onPress={onPress}
type='opacity'
>
<CompassIcon
name='refresh'
size={25}
color='#fff'
/>
</TouchableWithFeedback>
);
}

View File

@@ -11,7 +11,7 @@ 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 {Screens} from '@constants';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list';
@@ -126,7 +126,7 @@ const PostList = ({
}
};
const scrollBottomListener = DeviceEventEmitter.addListener('scroll-to-bottom', scrollToBottom);
const scrollBottomListener = DeviceEventEmitter.addListener(Events.POST_LIST_SCROLL_TO_BOTTOM, scrollToBottom);
return () => {
scrollBottomListener.remove();

View File

@@ -93,7 +93,6 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
<View style={[styles.image, {width: dimensions.width, height: dimensions.height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>

View File

@@ -66,7 +66,6 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
<View style={[style.image, {width, height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>

View File

@@ -171,7 +171,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
<FileIcon
backgroundColor={backgroundColor}
file={file}
theme={theme}
/>
);

View File

@@ -76,7 +76,6 @@ const File = ({
wrapperWidth={wrapperWidth}
isSingleImage={isSingleImage}
resizeMode={'cover'}
theme={theme}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
@@ -117,7 +116,6 @@ const File = ({
>
<FileIcon
file={file}
theme={theme}
/>
</TouchableWithFeedback>
</View>

View File

@@ -5,6 +5,7 @@ import React from 'react';
import {View, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {getFileType} from '@utils/file';
type FileIconProps = {
@@ -15,7 +16,6 @@ type FileIconProps = {
iconColor?: string;
iconSize?: number;
smallImage?: boolean;
theme: Theme;
}
const BLUE_ICON = '#338AFF';
@@ -49,8 +49,9 @@ const styles = StyleSheet.create({
const FileIcon = ({
backgroundColor, defaultImage = false, failed = false, file,
iconColor, iconSize = 48, smallImage = false, theme,
iconColor, iconSize = 48, smallImage = false,
}: FileIconProps) => {
const theme = useTheme();
const getFileIconNameAndColor = () => {
if (failed) {
return FAILED_ICON_NAME_AND_COLOR;

View File

@@ -6,6 +6,7 @@ import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -21,7 +22,6 @@ type ImageFileProps = {
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
theme: Theme;
wrapperWidth?: number;
}
@@ -71,11 +71,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ImageFile = ({
backgroundColor, file, inViewPort, isSingleImage,
resizeMode = 'cover', theme, wrapperWidth,
resizeMode = 'cover', wrapperWidth,
}: ImageFileProps) => {
const serverUrl = useServerUrl();
const [failed, setFailed] = useState(false);
const dimensions = useWindowDimensions();
const theme = useTheme();
const style = getStyleSheet(theme);
let image;
let client: Client | undefined;
@@ -142,7 +143,6 @@ const ImageFile = ({
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
);
}
@@ -185,7 +185,6 @@ const ImageFile = ({
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
</View>
);

View File

@@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Animated, LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native';
import React, {useCallback, useEffect, useState} from 'react';
import {LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
type ProgressBarProps = {
color: string;
@@ -24,35 +25,27 @@ const styles = StyleSheet.create({
});
const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
const timer = useRef(new Animated.Value(progress)).current;
const [width, setWidth] = useState(0);
const progressValue = useSharedValue(progress);
const progressAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{translateX: withTiming(((progressValue.value * 0.5) - 0.5) * width, {duration: 200})},
{scaleX: withTiming(progressValue.value ? progressValue.value : 0.0001, {duration: 200})},
],
};
}, [width]);
useEffect(() => {
const animation = Animated.timing(timer, {
duration: 200,
useNativeDriver: true,
isInteraction: false,
toValue: progress,
});
animation.start();
return animation.stop;
progressValue.value = progress;
}, [progress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setWidth(e.nativeEvent.layout.width);
}, []);
const translateX = timer.interpolate({
inputRange: [0, 1],
outputRange: [(-0.5 * width), 0],
});
const scaleX = timer.interpolate({
inputRange: [0, 1],
outputRange: [0.0001, 1],
});
return (
<View
onLayout={onLayout}
@@ -60,14 +53,12 @@ const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
>
<Animated.View
style={[
styles.progressBar, {
styles.progressBar,
{
backgroundColor: color,
width,
transform: [
{translateX},
{scaleX},
],
},
progressAnimatedStyle,
]}
/>
</View>