forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
f815f6b3e5
commit
55324127e1
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
app/components/post_draft/archived/index.tsx
Normal file
103
app/components/post_draft/archived/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/post_draft/cursor_position_handler/index.tsx
Normal file
37
app/components/post_draft/cursor_position_handler/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
app/components/post_draft/draft_handler/draft_handler.tsx
Normal file
139
app/components/post_draft/draft_handler/draft_handler.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
app/components/post_draft/draft_handler/index.ts
Normal file
68
app/components/post_draft/draft_handler/index.ts
Normal 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));
|
||||
174
app/components/post_draft/draft_input/index.tsx
Normal file
174
app/components/post_draft/draft_input/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
app/components/post_draft/index.ts
Normal file
81
app/components/post_draft/index.ts
Normal 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));
|
||||
107
app/components/post_draft/post_draft.tsx
Normal file
107
app/components/post_draft/post_draft.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/post_draft/post_input/index.ts
Normal file
67
app/components/post_draft/post_input/index.ts
Normal 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));
|
||||
319
app/components/post_draft/post_input/post_input.tsx
Normal file
319
app/components/post_draft/post_input/post_input.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
45
app/components/post_draft/quick_actions/index.ts
Normal file
45
app/components/post_draft/quick_actions/index.ts
Normal 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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
101
app/components/post_draft/quick_actions/quick_actions.tsx
Normal file
101
app/components/post_draft/quick_actions/quick_actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
app/components/post_draft/read_only/index.tsx
Normal file
72
app/components/post_draft/read_only/index.tsx
Normal 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;
|
||||
75
app/components/post_draft/send_action/index.tsx
Normal file
75
app/components/post_draft/send_action/index.tsx
Normal 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;
|
||||
120
app/components/post_draft/send_handler/index.ts
Normal file
120
app/components/post_draft/send_handler/index.ts
Normal 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));
|
||||
286
app/components/post_draft/send_handler/send_handler.tsx
Normal file
286
app/components/post_draft/send_handler/send_handler.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
app/components/post_draft/typing/index.tsx
Normal file
157
app/components/post_draft/typing/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
app/components/post_draft/uploads/index.tsx
Normal file
163
app/components/post_draft/uploads/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
app/components/post_draft/uploads/upload_item/index.tsx
Normal file
155
app/components/post_draft/uploads/upload_item/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -66,7 +66,6 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
|
||||
<View style={[style.image, {width, height}]}>
|
||||
<FileIcon
|
||||
failed={true}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -171,7 +171,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
||||
<FileIcon
|
||||
backgroundColor={backgroundColor}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user