Mobile drafts (#8280)
* refactor: started with draft, done until new tabs for draft * refactor: change the query and added the screen for draft * added condition for fetching draft for channel delete or not * refactor: added draft screen * linter fixes * Added draft post component * added avatar and header display name for the draft post list * added channel info component * channel info completed * proper naming * added image file markdown acknowledgement support * draft actions * Fix the draft receiver in drafts * separated send message handler * Done with send drafts * done with delete drafts * change save to send draft * handle lengthy message with show more button * done with persistent message edit, send and delete drafts * added alert for sending message * added update at time for the drafts * en.json extract fix * Updated dependencies for useCallback * refactor: added drafts list to animated list * added swipeable component and delete conformation for drafts * done with rendering of images in markdown for drafts * en.json issue fixed * fix en.json issue * refactor: en.json fix * addressed review comments * updated image metadata handling code * linter fixes * added the empty draft screen * linter fix * style fix * back button an android takes to the channel list page * en.json fix * draft actions theme compatible * CSS fix for draft channel_info and avatar component * removed the badge icon and change font style drafts * fix send alert sender name for GMs * updated snapshot * added testId to the drafts components * updated send draft test id * clicking on draft takes to the channel * Added toptip for draft tours * intl extract * Rebase to main and reverted local testing changes * Added tooltip for drafts * addressed review comments * reset navigation when click on a draft in draft tabs * fix the theme issue and navigation issue * reverted back the draft click navigation changes * observing draft when hitting back button * removed the unwanted animiation * updated regex for parsing markdown * removed unnecessary checks and change folder name * removed react memo and merge unwanted observes function * removed unnecessary comments * changed the name for observing and querying draft function * removed memo from component level * Text to FormattedText component * Text to formatted text, change image name * added confirmation modal for deleting draft from bottomsheet * using common send_handler for both draft and post * removed magic number for tooltip and bottomsheet * renamed channel_info to draft_post_header * text to formattedText for Edit drafts * removed unnecessary changes * minor fixes * mounting draft only when there is draft * map to reduce * renamed SwipeableDraft to DraftSwipeAction * name fixes * isValidUrl to isParsableUrl and added test * added test and addressed minor review comments * added inline component for the duplicate code * inlt fixes * clearDraft is not optional * optimised categories_list.tsx component * Swipeable to ReanimatedSwipeable, TouchableWithoutFeedback to Pressable and folder name changes * Added comment and disabled eslint rule for showing warning * fixed component file name * minor' * Removed deprecated Animated createAnimatedComponent flatlist * added test for missing protocol check * import change for SwipeableMethod * active tab for tablet view * Updated the drafts icons * Updated compass-icon version to v0.1.48 --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
ffbca2889a
commit
84eded1bde
53 changed files with 2768 additions and 306 deletions
|
|
@ -40,6 +40,10 @@ export const storeSkinEmojiSelectorTutorial = async (prepareRecordsOnly = false)
|
|||
return storeGlobal(Tutorial.EMOJI_SKIN_SELECTOR, 'true', prepareRecordsOnly);
|
||||
};
|
||||
|
||||
export const storeDraftsTutorial = async () => {
|
||||
return storeGlobal(Tutorial.DRAFTS, 'true', false);
|
||||
};
|
||||
|
||||
export const storeDontAskForReview = async (prepareRecordsOnly = false) => {
|
||||
return storeGlobal(GLOBAL_IDENTIFIERS.DONT_ASK_FOR_REVIEW, 'true', prepareRecordsOnly);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
addFilesToDraft,
|
||||
removeDraft,
|
||||
updateDraftPriority,
|
||||
updateDraftMarkdownImageMetadata,
|
||||
} from './draft';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
|
|
@ -242,3 +243,40 @@ describe('updateDraftPriority', () => {
|
|||
expect(result.draft.metadata?.priority?.priority).toBe(postPriority.priority);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDraftMarkdownImageMetadata', () => {
|
||||
const postImageData: PostImage = {
|
||||
height: 1080,
|
||||
width: 1920,
|
||||
format: 'jpg',
|
||||
frame_count: undefined,
|
||||
};
|
||||
|
||||
it('handle not found database', async () => {
|
||||
const result = await updateDraftMarkdownImageMetadata({
|
||||
serverUrl: 'foo',
|
||||
channelId,
|
||||
rootId: '',
|
||||
imageMetadata: {
|
||||
image1: postImageData,
|
||||
},
|
||||
}) as {draft: unknown; error: unknown};
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.draft).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handle update image metadata', async () => {
|
||||
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||
const result = await updateDraftMarkdownImageMetadata({
|
||||
serverUrl,
|
||||
channelId,
|
||||
rootId: '',
|
||||
imageMetadata: {
|
||||
image1: postImageData,
|
||||
},
|
||||
}) as {draft: DraftModel; error: unknown};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.draft).toBeDefined();
|
||||
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DeviceEventEmitter, Image} from 'react-native';
|
||||
|
||||
import {Navigation, Screens} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getDraft} from '@queries/servers/drafts';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {logError} from '@utils/log';
|
||||
import {isParsableUrl} from '@utils/url';
|
||||
|
||||
export const switchToGlobalDrafts = async () => {
|
||||
const isTablelDevice = isTablet();
|
||||
if (isTablelDevice) {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS);
|
||||
} else {
|
||||
goToScreen(Screens.GLOBAL_DRAFTS, '', {}, {topBar: {visible: false}});
|
||||
}
|
||||
};
|
||||
|
||||
export async function updateDraftFile(serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) {
|
||||
try {
|
||||
|
|
@ -197,3 +212,105 @@ export async function updateDraftPriority(serverUrl: string, channelId: string,
|
|||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDraftMarkdownImageMetadata({
|
||||
serverUrl,
|
||||
channelId,
|
||||
rootId,
|
||||
imageMetadata,
|
||||
prepareRecordsOnly = false,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
imageMetadata: Dictionary<PostImage | undefined>;
|
||||
prepareRecordsOnly?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const draft = await getDraft(database, channelId, rootId);
|
||||
if (draft) {
|
||||
draft.prepareUpdate((d) => {
|
||||
d.metadata = {
|
||||
...d.metadata,
|
||||
images: imageMetadata,
|
||||
};
|
||||
d.updateAt = Date.now();
|
||||
});
|
||||
if (!prepareRecordsOnly) {
|
||||
await operator.batchRecords([draft], 'updateDraftImageMetadata');
|
||||
}
|
||||
}
|
||||
return {draft};
|
||||
} catch (error) {
|
||||
logError('Failed updateDraftMarkdownImageMetadata', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageMetadata(url: string) {
|
||||
let height = 0;
|
||||
let width = 0;
|
||||
let format;
|
||||
await new Promise((resolve) => {
|
||||
Image.getSize(
|
||||
url,
|
||||
(imageWidth, imageHeight) => {
|
||||
width = imageWidth;
|
||||
height = imageHeight;
|
||||
resolve(null);
|
||||
},
|
||||
(error) => {
|
||||
logError('Failed getImageMetadata to get image size', error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regex Explanation:
|
||||
* \. - Matches a literal period (e.g., before "jpg").
|
||||
* (\w+) - Captures the file extension (letters, digits, or underscores).
|
||||
* (?=\?|$) - Ensures the extension is followed by "?" or the end of the URL.
|
||||
*
|
||||
* * Example Matches:
|
||||
* "https://example.com/image.jpg" -> Matches "jpg"
|
||||
* "https://example.com/image.png?size=1" -> Matches "png"
|
||||
* "https://example.com/file" -> No match (no file extension).
|
||||
*/
|
||||
const match = url.match(/\.(\w+)(?=\?|$)/);
|
||||
if (match) {
|
||||
format = match[1];
|
||||
}
|
||||
return {
|
||||
height,
|
||||
width,
|
||||
format,
|
||||
frame_count: 1,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseMarkdownImages(markdown: string, imageMetadata: Dictionary<PostImage | undefined>) {
|
||||
// Regex break down
|
||||
// ([a-zA-Z][a-zA-Z\d+\-.]*):\/\/ - Matches any valid scheme (protocol), such as http, https, ftp, mailto, file, etc.
|
||||
// [^\s()<>]+ - Matches the main part of the URL, excluding spaces, parentheses, and angle brackets.
|
||||
// (?:\([^\s()<>]+\))* - Allows balanced parentheses inside the URL path or query parameters.
|
||||
// !\[.*?\]\((...)\) - Matches an image markdown syntax 
|
||||
const imageRegex = /!\[.*?\]\((([a-zA-Z][a-zA-Z\d+\-.]*):\/\/[^\s()<>]+(?:\([^\s()<>]+\))*)\)/g;
|
||||
const matches = Array.from(markdown.matchAll(imageRegex));
|
||||
|
||||
const promises = matches.reduce<Array<Promise<PostImage & {url: string}>>>((result, match) => {
|
||||
const imageUrl = match[1];
|
||||
if (isParsableUrl(imageUrl)) {
|
||||
result.push(getImageMetadata(imageUrl));
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const metadataArray = await Promise.all(promises);
|
||||
metadataArray.forEach((metadata) => {
|
||||
if (metadata) {
|
||||
imageMetadata[metadata.url] = metadata;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,11 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
null,
|
||||
|
|
@ -213,7 +217,11 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
null,
|
||||
|
|
@ -251,7 +259,11 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
null,
|
||||
|
|
@ -361,7 +373,11 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
marginRight: 12,
|
||||
},
|
||||
text: {
|
||||
color: changeOpacity(theme.sidebarText, 0.72),
|
||||
color: theme.sidebarText,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
highlight: {
|
||||
color: theme.sidebarUnreadText,
|
||||
|
|
|
|||
124
app/components/draft/draft.tsx
Normal file
124
app/components/draft/draft.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// 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 {Keyboard, TouchableHighlight, View} from 'react-native';
|
||||
|
||||
import {switchToThread} from '@actions/local/thread';
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import DraftPost from '@components/draft/draft_post';
|
||||
import DraftPostHeader from '@components/draft_post_header';
|
||||
import Header from '@components/post_draft/draft_input/header';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_options';
|
||||
import {openAsBottomSheet} from '@screens/navigation';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
location: string;
|
||||
draftReceiverUser?: UserModel;
|
||||
draft: DraftModel;
|
||||
layoutWidth: number;
|
||||
isPostPriorityEnabled: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
width: '100%',
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
pressInContainer: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
},
|
||||
postPriority: {
|
||||
marginTop: 10,
|
||||
marginLeft: -12,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const Draft: React.FC<Props> = ({
|
||||
channel,
|
||||
location,
|
||||
draft,
|
||||
draftReceiverUser,
|
||||
layoutWidth,
|
||||
isPostPriorityEnabled,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const isTablet = useIsTablet();
|
||||
const serverUrl = useServerUrl();
|
||||
const showPostPriority = Boolean(isPostPriorityEnabled && draft.metadata?.priority && draft.metadata?.priority?.priority);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
const title = isTablet ? intl.formatMessage({id: 'draft.options.title', defaultMessage: 'Draft Options'}) : 'Draft Options';
|
||||
openAsBottomSheet({
|
||||
closeButtonId: DRAFT_OPTIONS_BUTTON,
|
||||
screen: Screens.DRAFT_OPTIONS,
|
||||
theme,
|
||||
title,
|
||||
props: {channel, rootId: draft.rootId, draft, draftReceiverUserName: draftReceiverUser?.username},
|
||||
});
|
||||
}, [channel, draft, draftReceiverUser?.username, intl, isTablet, theme]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
if (draft.rootId) {
|
||||
switchToThread(serverUrl, draft.rootId, false);
|
||||
return;
|
||||
}
|
||||
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
|
||||
}, [channel.id, channel.teamId, draft.rootId, serverUrl]);
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
onLongPress={onLongPress}
|
||||
onPress={onPress}
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
|
||||
testID='draft_post'
|
||||
>
|
||||
<View
|
||||
style={style.container}
|
||||
>
|
||||
<DraftPostHeader
|
||||
channel={channel}
|
||||
draftReceiverUser={draftReceiverUser}
|
||||
rootId={draft.rootId}
|
||||
testID='draft_post.channel_info'
|
||||
updateAt={draft.updateAt}
|
||||
/>
|
||||
{showPostPriority && draft.metadata?.priority &&
|
||||
<View style={style.postPriority}>
|
||||
<Header
|
||||
noMentionsError={false}
|
||||
postPriority={draft.metadata?.priority}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
<DraftPost
|
||||
draft={draft}
|
||||
location={location}
|
||||
layoutWidth={layoutWidth}
|
||||
/>
|
||||
</View>
|
||||
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
|
||||
export default Draft;
|
||||
18
app/components/draft/draft_post/draft_files/index.ts
Normal file
18
app/components/draft/draft_post/draft_files/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
|
||||
import Files from '@components/files/files';
|
||||
import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
return {
|
||||
canDownloadFiles: observeCanDownloadFiles(database),
|
||||
publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhance(Files));
|
||||
108
app/components/draft/draft_post/draft_message.tsx
Normal file
108
app/components/draft/draft_post/draft_message.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {ScrollView, View, useWindowDimensions, type LayoutChangeEvent} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import ShowMoreButton from '@components/post_list/post/body/message/show_more_button';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
|
||||
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
import type {UserMentionKey} from '@typings/global/markdown';
|
||||
|
||||
type Props = {
|
||||
draft: DraftModel;
|
||||
layoutWidth: number;
|
||||
location: string;
|
||||
}
|
||||
|
||||
const EMPTY_MENTION_KEYS: UserMentionKey[] = [];
|
||||
const SHOW_MORE_HEIGHT = 54;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
messageContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
reply: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
pendingPost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const DraftMessage: React.FC<Props> = ({
|
||||
draft,
|
||||
layoutWidth,
|
||||
location,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const blockStyles = getMarkdownBlockStyles(theme);
|
||||
const textStyles = getMarkdownTextStyles(theme);
|
||||
const [height, setHeight] = useState<number|undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
const maxHeight = Math.round((dimensions.height * 0.5) + SHOW_MORE_HEIGHT);
|
||||
const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open);
|
||||
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
|
||||
const onPress = useCallback(() => setOpen(!open), [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
style={animatedStyle}
|
||||
testID='draft_message'
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps={'always'}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={[style.messageContainer]}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<Markdown
|
||||
baseTextStyle={style.message}
|
||||
blockStyles={blockStyles}
|
||||
channelId={draft.channelId}
|
||||
layoutWidth={layoutWidth}
|
||||
location={location}
|
||||
postId={draft.id}
|
||||
textStyles={textStyles}
|
||||
value={draft.message}
|
||||
mentionKeys={EMPTY_MENTION_KEYS}
|
||||
theme={theme}
|
||||
imagesMetadata={draft.metadata?.images}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
{(height || 0) > maxHeight &&
|
||||
<ShowMoreButton
|
||||
highlight={false}
|
||||
theme={theme}
|
||||
showMore={!open}
|
||||
onPress={onPress}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftMessage;
|
||||
72
app/components/draft/draft_post/index.tsx
Normal file
72
app/components/draft/draft_post/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 DraftMessage from '@components/draft/draft_post/draft_message';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import DraftFiles from './draft_files';
|
||||
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
type Props = {
|
||||
draft: DraftModel;
|
||||
location: string;
|
||||
layoutWidth: number;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: 12,
|
||||
},
|
||||
acknowledgementContainer: {
|
||||
marginTop: 8,
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
backgroundColor: changeOpacity(theme.onlineIndicator, 0.12),
|
||||
flexDirection: 'row',
|
||||
height: 32,
|
||||
width: 42,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const DraftPost: React.FC<Props> = ({
|
||||
draft,
|
||||
location,
|
||||
layoutWidth,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const hasFiles = draft.files.length > 0;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.container}
|
||||
testID='draft_post_with_message_and_file'
|
||||
>
|
||||
<DraftMessage
|
||||
layoutWidth={layoutWidth}
|
||||
location={location}
|
||||
draft={draft}
|
||||
/>
|
||||
{
|
||||
hasFiles &&
|
||||
<DraftFiles
|
||||
filesInfo={draft.files}
|
||||
isReplyPost={false}
|
||||
location={location}
|
||||
layoutWidth={layoutWidth}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftPost;
|
||||
97
app/components/draft/index.tsx
Normal file
97
app/components/draft/index.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import React from 'react';
|
||||
import {switchMap, of} from 'rxjs';
|
||||
|
||||
import {observeChannel, observeChannelMembers} from '@queries/servers/channel';
|
||||
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
|
||||
import {observeCurrentUser, observeUser} from '@queries/servers/user';
|
||||
|
||||
import Drafts from './draft';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
currentUser?: UserModel;
|
||||
members?: ChannelMembershipModel[];
|
||||
channel?: ChannelModel;
|
||||
draft: DraftModel;
|
||||
} & WithDatabaseArgs;
|
||||
|
||||
const withCurrentUser = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
currentUser: observeCurrentUser(database),
|
||||
}));
|
||||
|
||||
const withChannel = withObservables(['channelId'], ({channelId, database}: Props) => ({
|
||||
channel: observeChannel(database, channelId),
|
||||
}));
|
||||
|
||||
const withChannelMembers = withObservables(['channelId'], ({channelId, database}: Props) => {
|
||||
const channel = observeChannel(database, channelId);
|
||||
|
||||
const members = channel.pipe(
|
||||
switchMap((channelData) => {
|
||||
if (channelData?.type === 'D') {
|
||||
return observeChannelMembers(database, channelId);
|
||||
}
|
||||
return of(undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
};
|
||||
});
|
||||
|
||||
const observeDraftReceiverUser = ({
|
||||
members,
|
||||
database,
|
||||
channelData,
|
||||
currentUser,
|
||||
}: {
|
||||
members?: ChannelMembershipModel[];
|
||||
database: WithDatabaseArgs['database'];
|
||||
channelData?: ChannelModel;
|
||||
currentUser?: UserModel;
|
||||
}) => {
|
||||
if (channelData?.type === 'D') {
|
||||
if (members && members.length > 0) {
|
||||
const validMember = members.find((member) => member.userId !== currentUser?.id);
|
||||
if (validMember) {
|
||||
return observeUser(database, validMember.userId);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
return of(undefined);
|
||||
};
|
||||
|
||||
const enhance = withObservables(['channel', 'members', 'draft'], ({channel, database, currentUser, members, draft}: Props) => {
|
||||
const draftReceiverUser = observeDraftReceiverUser({members, database, channelData: channel, currentUser});
|
||||
return {
|
||||
draft: draft.observe(),
|
||||
channel,
|
||||
draftReceiverUser,
|
||||
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
|
||||
};
|
||||
});
|
||||
|
||||
export default React.memo(
|
||||
withDatabase(
|
||||
withChannel(
|
||||
withCurrentUser(
|
||||
withChannelMembers(
|
||||
enhance(Drafts),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
159
app/components/draft_post_header/draft_post_header.tsx
Normal file
159
app/components/draft_post_header/draft_post_header.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProfileAvatar from '@components/draft_post_header/profile_avatar';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import FormattedTime from '@components/formatted_time';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {getUserTimezone} from '@utils/user';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
draftReceiverUser?: UserModel;
|
||||
updateAt: number;
|
||||
rootId?: PostModel['rootId'];
|
||||
testID?: string;
|
||||
currentUser?: UserModel;
|
||||
isMilitaryTime: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
channelInfo: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
category: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryIconContainer: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
padding: 4,
|
||||
borderRadius: 555,
|
||||
},
|
||||
profileComponentContainer: {
|
||||
marginRight: 6,
|
||||
},
|
||||
displayName: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
},
|
||||
time: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const DraftPostHeader: React.FC<Props> = ({
|
||||
channel,
|
||||
draftReceiverUser,
|
||||
updateAt,
|
||||
rootId,
|
||||
testID,
|
||||
currentUser,
|
||||
isMilitaryTime,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const isChannelTypeDM = channel.type === General.DM_CHANNEL;
|
||||
|
||||
const ChannelInfo = ({id, defaultMessage}: {id: string; defaultMessage: string}) => (
|
||||
<View style={style.channelInfo}>
|
||||
<FormattedText
|
||||
id={id}
|
||||
defaultMessage={defaultMessage}
|
||||
style={style.category}
|
||||
/>
|
||||
<View style={style.profileComponentContainer}>
|
||||
{draftReceiverUser ? (
|
||||
<ProfileAvatar author={draftReceiverUser}/>
|
||||
) : (
|
||||
<View style={style.categoryIconContainer}>
|
||||
<CompassIcon
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
name='globe'
|
||||
size={16}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
let headerComponent: ReactNode = null;
|
||||
|
||||
if (rootId) {
|
||||
headerComponent = (
|
||||
<ChannelInfo
|
||||
id='channel_info.thread_in'
|
||||
defaultMessage='Thread in:'
|
||||
/>
|
||||
);
|
||||
} else if (isChannelTypeDM) {
|
||||
headerComponent = (
|
||||
<ChannelInfo
|
||||
id='channel_info.draft_to_user'
|
||||
defaultMessage='To:'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
headerComponent = (
|
||||
<ChannelInfo
|
||||
id='channel_info.draft_in_channel'
|
||||
defaultMessage='In:'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<View
|
||||
style={style.container}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={style.infoContainer}>
|
||||
{headerComponent}
|
||||
<Text style={style.displayName}>
|
||||
{channel.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<FormattedTime
|
||||
timezone={getUserTimezone(currentUser)}
|
||||
isMilitaryTime={isMilitaryTime}
|
||||
value={updateAt}
|
||||
style={style.time}
|
||||
testID='post_header.date_time'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftPostHeader;
|
||||
25
app/components/draft_post_header/index.tsx
Normal file
25
app/components/draft_post_header/index.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
|
||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
|
||||
import DraftPostHeader from './draft_post_header';
|
||||
|
||||
const enhance = withObservables([], ({database}) => {
|
||||
const currentUser = observeCurrentUser(database);
|
||||
const preferences = queryDisplayNamePreferences(database).
|
||||
observeWithColumns(['value']);
|
||||
const isMilitaryTime = preferences.pipe(map((prefs) => getDisplayNamePreferenceAsBool(prefs, 'use_military_time')));
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
isMilitaryTime,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhance(DraftPostHeader));
|
||||
68
app/components/draft_post_header/profile_avatar.tsx
Normal file
68
app/components/draft_post_header/profile_avatar.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React, {useMemo} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||
import {buildProfileImageUrlFromUser} from '@actions/remote/user';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
author: UserModel;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatarContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
avatar: {
|
||||
height: 24,
|
||||
width: 24,
|
||||
},
|
||||
avatarRadius: {
|
||||
borderRadius: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const ProfileAvatar = ({
|
||||
author,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const uri = useMemo(() => buildProfileImageUrlFromUser(serverUrl, author), [serverUrl, author]);
|
||||
|
||||
let picture;
|
||||
if (uri) {
|
||||
picture = (
|
||||
<Image
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
||||
style={[styles.avatar, styles.avatarRadius]}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
picture = (
|
||||
<CompassIcon
|
||||
name='account-outline'
|
||||
size={22}
|
||||
color={changeOpacity('#fff', 0.48)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.avatarContainer, styles.avatarRadius]}>
|
||||
{picture}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileAvatar;
|
||||
|
||||
129
app/components/drafts_buttton/drafts_button.tsx
Normal file
129
app/components/drafts_buttton/drafts_button.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {DeviceEventEmitter, Text, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import {switchToGlobalDrafts} from '@actions/local/draft';
|
||||
import {
|
||||
getStyleSheet as getChannelItemStyleSheet,
|
||||
ROW_HEIGHT,
|
||||
} from '@components/channel_item/channel_item';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Events} from '@constants';
|
||||
import {DRAFT} from '@constants/screens';
|
||||
import {HOME_PADDING} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type DraftListProps = {
|
||||
shouldHighlightActive?: boolean;
|
||||
draftsCount: number;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
icon: {
|
||||
color: changeOpacity(theme.sidebarText, 0.5),
|
||||
fontSize: 24,
|
||||
marginRight: 12,
|
||||
},
|
||||
iconActive: {
|
||||
color: theme.sidebarText,
|
||||
},
|
||||
iconInfo: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
},
|
||||
countContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
count: {
|
||||
color: theme.sidebarText,
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
opacity: 0.64,
|
||||
},
|
||||
opacity: {
|
||||
opacity: 0.56,
|
||||
},
|
||||
}));
|
||||
|
||||
const DraftsButton: React.FC<DraftListProps> = ({
|
||||
shouldHighlightActive = false,
|
||||
draftsCount,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getChannelItemStyleSheet(theme);
|
||||
const customStyles = getStyleSheet(theme);
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const handlePress = useCallback(preventDoubleTap(() => {
|
||||
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, DRAFT);
|
||||
switchToGlobalDrafts();
|
||||
}), []);
|
||||
|
||||
const isActive = isTablet && shouldHighlightActive;
|
||||
|
||||
const [containerStyle, iconStyle, textStyle] = useMemo(() => {
|
||||
const container = [
|
||||
styles.container,
|
||||
HOME_PADDING,
|
||||
isActive && styles.activeItem,
|
||||
isActive && {
|
||||
paddingLeft: HOME_PADDING.paddingLeft - styles.activeItem.borderLeftWidth,
|
||||
},
|
||||
{minHeight: ROW_HEIGHT},
|
||||
];
|
||||
|
||||
const icon = [
|
||||
customStyles.icon,
|
||||
isActive && customStyles.iconActive,
|
||||
];
|
||||
|
||||
const text = [
|
||||
customStyles.text,
|
||||
styles.text,
|
||||
isActive && styles.textActive,
|
||||
];
|
||||
|
||||
return [container, icon, text];
|
||||
}, [customStyles, isActive, styles]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
testID='channel_list.drafts.button'
|
||||
>
|
||||
<View style={containerStyle}>
|
||||
<CompassIcon
|
||||
name='send-outline'
|
||||
style={iconStyle}
|
||||
/>
|
||||
<FormattedText
|
||||
id='drafts'
|
||||
defaultMessage='Drafts'
|
||||
style={textStyle}
|
||||
/>
|
||||
<View style={customStyles.countContainer}>
|
||||
<CompassIcon
|
||||
name='pencil-outline'
|
||||
size={14}
|
||||
color={theme.sidebarText}
|
||||
style={customStyles.opacity}
|
||||
/>
|
||||
<Text style={customStyles.count}>{draftsCount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftsButton;
|
||||
|
|
@ -23,8 +23,8 @@ type FilesProps = {
|
|||
layoutWidth?: number;
|
||||
location: string;
|
||||
isReplyPost: boolean;
|
||||
postId: string;
|
||||
postProps: Record<string, unknown>;
|
||||
postId?: string;
|
||||
postProps?: Record<string, unknown>;
|
||||
publicLinkEnabled: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo, useRef} from 'react';
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
|
||||
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {General} from '@constants';
|
||||
import {MENTIONS_REGEX} from '@constants/autocomplete';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {usePersistentNotificationProps} from '@hooks/persistent_notification_props';
|
||||
import {persistentNotificationsConfirmation} from '@utils/post';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
|
|
@ -149,20 +147,11 @@ export default function DraftInput({
|
|||
const sendActionTestID = `${testID}.send_action`;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const persistentNotificationsEnabled = postPriority.persistent_notifications && postPriority.priority === PostPriorityType.URGENT;
|
||||
const {noMentionsError, mentionsList} = useMemo(() => {
|
||||
let error = false;
|
||||
let mentions: string[] = [];
|
||||
if (
|
||||
channelType !== General.DM_CHANNEL &&
|
||||
persistentNotificationsEnabled
|
||||
) {
|
||||
mentions = (value.match(MENTIONS_REGEX) || []);
|
||||
error = mentions.length === 0;
|
||||
}
|
||||
|
||||
return {noMentionsError: error, mentionsList: mentions};
|
||||
}, [channelType, persistentNotificationsEnabled, value]);
|
||||
const {persistentNotificationsEnabled, noMentionsError, mentionsList} = usePersistentNotificationProps({
|
||||
value,
|
||||
channelType,
|
||||
postPriority,
|
||||
});
|
||||
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
if (persistentNotificationsEnabled) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {useIsTablet} from '@hooks/device';
|
|||
import {useInputPropagation} from '@hooks/input';
|
||||
import {t} from '@i18n';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {handleDraftUpdate} from '@utils/draft';
|
||||
import {extractFileInfo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme} from '@utils/theme';
|
||||
|
||||
|
|
@ -148,7 +149,12 @@ export default function PostInput({
|
|||
|
||||
const onBlur = useCallback(() => {
|
||||
keyboardContext?.registerTextInputBlur();
|
||||
updateDraftMessage(serverUrl, channelId, rootId, value);
|
||||
handleDraftUpdate({
|
||||
serverUrl,
|
||||
channelId,
|
||||
rootId,
|
||||
value,
|
||||
});
|
||||
setIsFocused(false);
|
||||
}, [keyboardContext, serverUrl, channelId, rootId, value, setIsFocused]);
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
|
|||
const channelInfo = channel.pipe(switchMap((c) => (c ? observeChannelInfo(database, c.id) : of$(undefined))));
|
||||
const channelType = channel.pipe(switchMap((c) => of$(c?.type)));
|
||||
const channelName = channel.pipe(switchMap((c) => of$(c?.name)));
|
||||
const channelDisplayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
|
||||
const membersCount = channelInfo.pipe(
|
||||
switchMap((i) => (i ? of$(i.memberCount) : of$(0))),
|
||||
);
|
||||
|
|
@ -81,6 +82,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
|
|||
channelType,
|
||||
channelName,
|
||||
currentUserId,
|
||||
channelDisplayName,
|
||||
enableConfirmNotificationsToChannel,
|
||||
maxMessageLength,
|
||||
membersCount,
|
||||
|
|
|
|||
|
|
@ -1,31 +1,18 @@
|
|||
// 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 React, {useCallback} from 'react';
|
||||
|
||||
import {updateDraftPriority} from '@actions/local/draft';
|
||||
import {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 {handleCallsSlashCommand} from '@calls/actions/calls';
|
||||
import {Events, Screens} from '@constants';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import DraftUploadManager from '@managers/draft_upload_manager';
|
||||
import * as DraftUtils from '@utils/draft';
|
||||
import {isReactionMatch} from '@utils/emoji/helpers';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {confirmOutOfOfficeDisabled} from '@utils/user';
|
||||
import {useHandleSendMessage} from '@hooks/handle_send_message';
|
||||
import SendDraft from '@screens/draft_options/send_draft';
|
||||
|
||||
import DraftInput from '../draft_input';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
testID?: string;
|
||||
|
|
@ -58,6 +45,11 @@ type Props = {
|
|||
persistentNotificationInterval: number;
|
||||
persistentNotificationMaxRecipients: number;
|
||||
postPriority: PostPriority;
|
||||
|
||||
bottomSheetId?: AvailableScreens;
|
||||
channelDisplayName?: string;
|
||||
isFromDraftView?: boolean;
|
||||
draftReceiverUserName?: string;
|
||||
}
|
||||
|
||||
export const INITIAL_PRIORITY = {
|
||||
|
|
@ -71,6 +63,7 @@ export default function SendHandler({
|
|||
channelId,
|
||||
channelType,
|
||||
channelName,
|
||||
channelDisplayName,
|
||||
currentUserId,
|
||||
enableConfirmNotificationsToChannel,
|
||||
files,
|
||||
|
|
@ -93,179 +86,58 @@ export default function SendHandler({
|
|||
persistentNotificationInterval,
|
||||
persistentNotificationMaxRecipients,
|
||||
postPriority,
|
||||
bottomSheetId,
|
||||
draftReceiverUserName,
|
||||
isFromDraftView,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
|
||||
const [sendingMessage, setSendingMessage] = useState(false);
|
||||
|
||||
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 handlePostPriority = useCallback((priority: PostPriority) => {
|
||||
updateDraftPriority(serverUrl, channelId, rootId, priority);
|
||||
}, [serverUrl, rootId]);
|
||||
}, [serverUrl, channelId, rootId]);
|
||||
|
||||
const doSubmitMessage = useCallback(() => {
|
||||
const postFiles = files.filter((f) => !f.failed);
|
||||
const post = {
|
||||
user_id: currentUserId,
|
||||
channel_id: channelId,
|
||||
root_id: rootId,
|
||||
message: value,
|
||||
} as Post;
|
||||
|
||||
if (!rootId && (
|
||||
postPriority.priority ||
|
||||
postPriority.requested_ack ||
|
||||
postPriority.persistent_notifications)
|
||||
) {
|
||||
post.metadata = {
|
||||
priority: postPriority,
|
||||
};
|
||||
}
|
||||
|
||||
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, postPriority]);
|
||||
|
||||
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
|
||||
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere);
|
||||
const cancel = () => {
|
||||
setSendingMessage(false);
|
||||
};
|
||||
|
||||
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
|
||||
}, [intl, channelTimezoneCount, doSubmitMessage]);
|
||||
|
||||
const sendCommand = useCallback(async () => {
|
||||
if (value.trim().startsWith('/call')) {
|
||||
const {handled, error} = await handleCallsSlashCommand(value.trim(), serverUrl, channelId, channelType ?? '', rootId, currentUserId, intl);
|
||||
if (handled) {
|
||||
setSendingMessage(false);
|
||||
clearDraft();
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setSendingMessage(false);
|
||||
DraftUtils.alertSlashCommandFailed(intl, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 = getFullErrorMessage(error);
|
||||
DraftUtils.alertSlashCommandFailed(intl, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
clearDraft();
|
||||
|
||||
if (data?.goto_location && !value.startsWith('/leave')) {
|
||||
handleGotoLocation(serverUrl, intl, data.goto_location);
|
||||
}
|
||||
}, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId]);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
|
||||
const toHere = DraftUtils.textContainsAtHere(value);
|
||||
|
||||
if (value.indexOf('/') === 0) {
|
||||
sendCommand();
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
|
||||
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel);
|
||||
} else {
|
||||
doSubmitMessage();
|
||||
}
|
||||
}, [
|
||||
const {handleSendMessage, canSend} = useHandleSendMessage({
|
||||
value,
|
||||
channelId,
|
||||
rootId,
|
||||
files,
|
||||
maxMessageLength,
|
||||
customEmojis,
|
||||
enableConfirmNotificationsToChannel,
|
||||
useChannelMentions,
|
||||
value,
|
||||
channelTimezoneCount,
|
||||
sendCommand,
|
||||
showSendToAllOrChannelOrHereAlert,
|
||||
doSubmitMessage,
|
||||
]);
|
||||
membersCount,
|
||||
userIsOutOfOffice,
|
||||
currentUserId,
|
||||
channelType,
|
||||
postPriority,
|
||||
clearDraft,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => {
|
||||
setChannelTimezoneCount(channelTimezones?.length || 0);
|
||||
});
|
||||
}, [serverUrl, channelId]);
|
||||
if (isFromDraftView) {
|
||||
return (
|
||||
<SendDraft
|
||||
channelId={channelId}
|
||||
rootId={rootId}
|
||||
channelType={channelType}
|
||||
currentUserId={currentUserId}
|
||||
channelName={channelName}
|
||||
channelDisplayName={channelDisplayName}
|
||||
enableConfirmNotificationsToChannel={enableConfirmNotificationsToChannel}
|
||||
maxMessageLength={maxMessageLength}
|
||||
membersCount={membersCount}
|
||||
useChannelMentions={useChannelMentions}
|
||||
userIsOutOfOffice={userIsOutOfOffice}
|
||||
customEmojis={customEmojis}
|
||||
bottomSheetId={bottomSheetId}
|
||||
value={value}
|
||||
files={files}
|
||||
postPriority={postPriority}
|
||||
persistentNotificationInterval={persistentNotificationInterval}
|
||||
persistentNotificationMaxRecipients={persistentNotificationMaxRecipients}
|
||||
draftReceiverUserName={draftReceiverUserName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DraftInput
|
||||
|
|
@ -284,7 +156,7 @@ export default function SendHandler({
|
|||
addFiles={addFiles}
|
||||
uploadFileError={uploadFileError}
|
||||
sendMessage={handleSendMessage}
|
||||
canSend={canSend()}
|
||||
canSend={canSend}
|
||||
maxMessageLength={maxMessageLength}
|
||||
updatePostInputTop={updatePostInputTop}
|
||||
postPriority={postPriority}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,11 @@ exports[`Thread item in the channel list Threads Component should match snapshot
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
|
|
@ -174,7 +178,11 @@ exports[`Thread item in the channel list Threads Component should match snapshot
|
|||
"lineHeight": 24,
|
||||
},
|
||||
{
|
||||
"color": "rgba(255,255,255,0.72)",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "OpenSans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "400",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {TouchableOpacity, View} from 'react-native';
|
||||
import {DeviceEventEmitter, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import {switchToGlobalThreads} from '@actions/local/thread';
|
||||
import Badge from '@components/badge';
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
} from '@components/channel_item/channel_item';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Events} from '@constants';
|
||||
import {THREAD} from '@constants/screens';
|
||||
import {HOME_PADDING} from '@constants/view';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
|
@ -65,6 +67,7 @@ const ThreadsButton = ({
|
|||
const customStyles = getStyleSheet(theme);
|
||||
|
||||
const handlePress = useCallback(preventDoubleTap(() => {
|
||||
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, THREAD);
|
||||
if (onPress) {
|
||||
onPress();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -31,4 +31,6 @@ export default keyMirror({
|
|||
SEND_TO_POST_DRAFT: null,
|
||||
CRT_TOGGLED: null,
|
||||
JOIN_CALL_BAR_VISIBLE: null,
|
||||
DRAFT_SWIPEABLE: null,
|
||||
ACTIVE_SCREEN: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
|
|||
export const CREATE_TEAM = 'CreateTeam';
|
||||
export const CUSTOM_STATUS = 'CustomStatus';
|
||||
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
|
||||
export const DRAFT = 'Draft';
|
||||
export const DRAFT_OPTIONS = 'DraftOptions';
|
||||
export const EDIT_POST = 'EditPost';
|
||||
export const EDIT_PROFILE = 'EditProfile';
|
||||
export const EDIT_SERVER = 'EditServer';
|
||||
|
|
@ -28,6 +30,7 @@ export const EMOJI_PICKER = 'EmojiPicker';
|
|||
export const FIND_CHANNELS = 'FindChannels';
|
||||
export const FORGOT_PASSWORD = 'ForgotPassword';
|
||||
export const GALLERY = 'Gallery';
|
||||
export const GLOBAL_DRAFTS = 'GlobalDrafts';
|
||||
export const GLOBAL_THREADS = 'GlobalThreads';
|
||||
export const HOME = 'Home';
|
||||
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
|
||||
|
|
@ -100,6 +103,7 @@ export default {
|
|||
CREATE_TEAM,
|
||||
CUSTOM_STATUS,
|
||||
CUSTOM_STATUS_CLEAR_AFTER,
|
||||
DRAFT_OPTIONS,
|
||||
EDIT_POST,
|
||||
EDIT_PROFILE,
|
||||
EDIT_SERVER,
|
||||
|
|
@ -107,6 +111,7 @@ export default {
|
|||
FIND_CHANNELS,
|
||||
FORGOT_PASSWORD,
|
||||
GALLERY,
|
||||
GLOBAL_DRAFTS,
|
||||
GLOBAL_THREADS,
|
||||
HOME,
|
||||
INTEGRATION_SELECTOR,
|
||||
|
|
@ -183,6 +188,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
|
|||
|
||||
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||
BOTTOM_SHEET,
|
||||
DRAFT_OPTIONS,
|
||||
EMOJI_PICKER,
|
||||
POST_OPTIONS,
|
||||
POST_PRIORITY_PICKER,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
export const MULTI_SERVER = 'multiServerTutorial';
|
||||
export const PROFILE_LONG_PRESS = 'profileLongPressTutorial';
|
||||
export const EMOJI_SKIN_SELECTOR = 'emojiSkinSelectorTutorial';
|
||||
export const DRAFTS = 'draftsTutorial';
|
||||
|
||||
export default {
|
||||
MULTI_SERVER,
|
||||
PROFILE_LONG_PRESS,
|
||||
EMOJI_SKIN_SELECTOR,
|
||||
DRAFTS,
|
||||
};
|
||||
|
|
|
|||
223
app/hooks/handle_send_message.ts
Normal file
223
app/hooks/handle_send_message.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {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 {handleCallsSlashCommand} from '@calls/actions';
|
||||
import {Events, Screens} from '@constants';
|
||||
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import DraftUploadManager from '@managers/draft_upload_manager';
|
||||
import * as DraftUtils from '@utils/draft';
|
||||
import {isReactionMatch} from '@utils/emoji/helpers';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {confirmOutOfOfficeDisabled} from '@utils/user';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
maxMessageLength: number;
|
||||
files: FileInfo[];
|
||||
customEmojis: CustomEmojiModel[];
|
||||
enableConfirmNotificationsToChannel?: boolean;
|
||||
useChannelMentions: boolean;
|
||||
membersCount: number;
|
||||
userIsOutOfOffice: boolean;
|
||||
currentUserId: string;
|
||||
channelType: ChannelType | undefined;
|
||||
postPriority: PostPriority;
|
||||
clearDraft: () => void;
|
||||
}
|
||||
|
||||
export const useHandleSendMessage = ({
|
||||
value,
|
||||
channelId,
|
||||
rootId,
|
||||
files,
|
||||
maxMessageLength,
|
||||
customEmojis,
|
||||
enableConfirmNotificationsToChannel,
|
||||
useChannelMentions,
|
||||
membersCount = 0,
|
||||
userIsOutOfOffice,
|
||||
currentUserId,
|
||||
channelType,
|
||||
postPriority,
|
||||
clearDraft,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const [sendingMessage, setSendingMessage] = useState(false);
|
||||
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
|
||||
|
||||
const canSend = useMemo(() => {
|
||||
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,
|
||||
} as Post;
|
||||
|
||||
if (!rootId && (
|
||||
postPriority.priority ||
|
||||
postPriority.requested_ack ||
|
||||
postPriority.persistent_notifications)
|
||||
) {
|
||||
post.metadata = {
|
||||
priority: postPriority,
|
||||
};
|
||||
}
|
||||
|
||||
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, postPriority, serverUrl, clearDraft]);
|
||||
|
||||
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
|
||||
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere);
|
||||
const cancel = () => {
|
||||
setSendingMessage(false);
|
||||
};
|
||||
|
||||
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
|
||||
}, [intl, channelTimezoneCount, doSubmitMessage]);
|
||||
|
||||
const sendCommand = useCallback(async () => {
|
||||
if (value.trim().startsWith('/call')) {
|
||||
const {handled, error} = await handleCallsSlashCommand(value.trim(), serverUrl, channelId, channelType ?? '', rootId, currentUserId, intl);
|
||||
if (handled) {
|
||||
setSendingMessage(false);
|
||||
clearDraft();
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setSendingMessage(false);
|
||||
DraftUtils.alertSlashCommandFailed(intl, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 = getFullErrorMessage(error);
|
||||
DraftUtils.alertSlashCommandFailed(intl, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
clearDraft();
|
||||
|
||||
if (data?.goto_location && !value.startsWith('/leave')) {
|
||||
handleGotoLocation(serverUrl, intl, data.goto_location);
|
||||
}
|
||||
}, [value, userIsOutOfOffice, serverUrl, intl, channelId, rootId, clearDraft, channelType, currentUserId]);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
|
||||
const toHere = DraftUtils.textContainsAtHere(value);
|
||||
|
||||
if (value.indexOf('/') === 0) {
|
||||
sendCommand();
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
|
||||
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel);
|
||||
} else {
|
||||
doSubmitMessage();
|
||||
}
|
||||
}, [enableConfirmNotificationsToChannel, useChannelMentions, value, membersCount, sendCommand, showSendToAllOrChannelOrHereAlert, 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(() => {
|
||||
getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => {
|
||||
setChannelTimezoneCount(channelTimezones?.length || 0);
|
||||
});
|
||||
}, [serverUrl, channelId]);
|
||||
|
||||
return {
|
||||
handleSendMessage,
|
||||
canSend,
|
||||
};
|
||||
};
|
||||
41
app/hooks/persistent_notification_props.ts
Normal file
41
app/hooks/persistent_notification_props.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useMemo} from 'react';
|
||||
|
||||
import {General} from '@constants';
|
||||
import {MENTIONS_REGEX} from '@constants/autocomplete';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
channelType: ChannelType | undefined;
|
||||
postPriority: PostPriority;
|
||||
}
|
||||
|
||||
export const usePersistentNotificationProps = ({
|
||||
value,
|
||||
channelType,
|
||||
postPriority,
|
||||
}: Props) => {
|
||||
const persistentNotificationsEnabled = postPriority.persistent_notifications && postPriority.priority === PostPriorityType.URGENT;
|
||||
const {noMentionsError, mentionsList} = useMemo(() => {
|
||||
let error = false;
|
||||
let mentions: string[] = [];
|
||||
if (
|
||||
channelType !== General.DM_CHANNEL &&
|
||||
persistentNotificationsEnabled
|
||||
) {
|
||||
mentions = (value.match(MENTIONS_REGEX) || []);
|
||||
error = mentions.length === 0;
|
||||
}
|
||||
|
||||
return {noMentionsError: error, mentionsList: mentions};
|
||||
}, [channelType, persistentNotificationsEnabled, value]);
|
||||
|
||||
return {
|
||||
noMentionsError,
|
||||
mentionsList,
|
||||
persistentNotificationsEnabled,
|
||||
};
|
||||
};
|
||||
|
|
@ -5,10 +5,9 @@ import {Database, Q} from '@nozbe/watermelondb';
|
|||
import {of as of$} from 'rxjs';
|
||||
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
const {SERVER: {DRAFT}} = MM_TABLES;
|
||||
const {SERVER: {DRAFT, CHANNEL}} = MM_TABLES;
|
||||
|
||||
export const getDraft = async (database: Database, channelId: string, rootId = '') => {
|
||||
const record = await queryDraft(database, channelId, rootId).fetch();
|
||||
|
|
@ -30,3 +29,27 @@ export const queryDraft = (database: Database, channelId: string, rootId = '') =
|
|||
export function observeFirstDraft(v: DraftModel[]) {
|
||||
return v[0]?.observe() || of$(undefined);
|
||||
}
|
||||
|
||||
export const queryDraftsForTeam = (database: Database, teamId: string) => {
|
||||
return database.collections.get<DraftModel>(DRAFT).query(
|
||||
Q.on(CHANNEL,
|
||||
Q.and(
|
||||
Q.or(
|
||||
Q.where('team_id', teamId), // Channels associated with the given team
|
||||
Q.where('type', 'D'), // Direct Message
|
||||
Q.where('type', 'G'), // Group Message
|
||||
),
|
||||
Q.where('delete_at', 0), // Ensure the channel is not deleted
|
||||
),
|
||||
),
|
||||
Q.sortBy('update_at', Q.desc),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeDraftsForTeam = (database: Database, teamId: string) => {
|
||||
return queryDraftsForTeam(database, teamId).observeWithColumns(['update_at']);
|
||||
};
|
||||
|
||||
export const observeDraftCount = (database: Database, teamId: string) => {
|
||||
return queryDraftsForTeam(database, teamId).observeCount();
|
||||
};
|
||||
|
|
|
|||
81
app/screens/draft_options/delete_draft.tsx
Normal file
81
app/screens/draft_options/delete_draft.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {deleteDraftConfirmation} from '@utils/draft';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
bottomSheetId: AvailableScreens;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
draftOptions: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
}));
|
||||
|
||||
const DeleteDraft: React.FC<Props> = ({
|
||||
bottomSheetId,
|
||||
channelId,
|
||||
rootId,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const intl = useIntl();
|
||||
|
||||
const draftDeleteHandler = async () => {
|
||||
await dismissBottomSheet(bottomSheetId);
|
||||
deleteDraftConfirmation({
|
||||
intl,
|
||||
serverUrl,
|
||||
channelId,
|
||||
rootId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
type={'opacity'}
|
||||
style={style.draftOptions}
|
||||
onPress={draftDeleteHandler}
|
||||
testID='delete_draft'
|
||||
>
|
||||
<CompassIcon
|
||||
name='trash-can-outline'
|
||||
size={ICON_SIZE}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
/>
|
||||
<FormattedText
|
||||
id='draft.options.delete.title'
|
||||
defaultMessage={'Delete draft'}
|
||||
style={style.title}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteDraft;
|
||||
80
app/screens/draft_options/edit_draft.tsx
Normal file
80
app/screens/draft_options/edit_draft.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {switchToThread} from '@actions/local/thread';
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
bottomSheetId: AvailableScreens;
|
||||
channel: ChannelModel;
|
||||
rootId: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
draftOptions: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
}));
|
||||
|
||||
const EditDraft: React.FC<Props> = ({
|
||||
bottomSheetId,
|
||||
channel,
|
||||
rootId,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const editHandler = async () => {
|
||||
await dismissBottomSheet(bottomSheetId);
|
||||
if (rootId) {
|
||||
switchToThread(serverUrl, rootId, false);
|
||||
return;
|
||||
}
|
||||
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
type={'opacity'}
|
||||
style={style.draftOptions}
|
||||
onPress={editHandler}
|
||||
testID='edit_draft'
|
||||
>
|
||||
<CompassIcon
|
||||
name='pencil-outline'
|
||||
size={ICON_SIZE}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
/>
|
||||
<FormattedText
|
||||
id='draft.options.edit.title'
|
||||
defaultMessage={'Edit draft'}
|
||||
style={style.title}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditDraft;
|
||||
113
app/screens/draft_options/index.tsx
Normal file
113
app/screens/draft_options/index.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {Platform, View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SendHandler from '@components/post_draft/send_handler/';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import BottomSheet from '@screens/bottom_sheet';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import DeleteDraft from './delete_draft';
|
||||
import EditDraft from './edit_draft';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
rootId: string;
|
||||
draft: DraftModel;
|
||||
draftReceiverUserName: string | undefined;
|
||||
}
|
||||
|
||||
export const DRAFT_OPTIONS_BUTTON = 'close-post-options';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
header: {
|
||||
...typography('Heading', 600, 'SemiBold'),
|
||||
display: 'flex',
|
||||
paddingBottom: 4,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const TITLE_HEIGHT = 54;
|
||||
const ITEM_HEIGHT = 48;
|
||||
|
||||
const DraftOptions: React.FC<Props> = ({
|
||||
channel,
|
||||
rootId,
|
||||
draft,
|
||||
draftReceiverUserName,
|
||||
}) => {
|
||||
const isTablet = useIsTablet();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const snapPoints = useMemo(() => {
|
||||
const bottomSheetAdjust = Platform.select({ios: 5, default: 20});
|
||||
const COMPONENT_HIEGHT = TITLE_HEIGHT + (3 * ITEM_HEIGHT) + bottomSheetAdjust;
|
||||
return [1, COMPONENT_HIEGHT];
|
||||
}, []);
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View>
|
||||
{!isTablet &&
|
||||
<FormattedText
|
||||
id='draft.option.header'
|
||||
defaultMessage='Draft actions'
|
||||
style={styles.header}
|
||||
/>}
|
||||
<EditDraft
|
||||
bottomSheetId={Screens.DRAFT_OPTIONS}
|
||||
channel={channel}
|
||||
rootId={rootId}
|
||||
/>
|
||||
<SendHandler
|
||||
bottomSheetId={Screens.DRAFT_OPTIONS}
|
||||
channelId={channel.id}
|
||||
rootId={rootId}
|
||||
files={draft.files}
|
||||
value={draft.message}
|
||||
draftReceiverUserName={draftReceiverUserName}
|
||||
isFromDraftView={true}
|
||||
uploadFileError={null}
|
||||
cursorPosition={0}
|
||||
/* eslint-disable no-empty-function */
|
||||
clearDraft={() => {}}
|
||||
updateCursorPosition={() => {}}
|
||||
updatePostInputTop={() => {}}
|
||||
addFiles={() => {}}
|
||||
setIsFocused={() => {}}
|
||||
updateValue={() => {}}
|
||||
/* eslint-enable no-empty-function */
|
||||
/>
|
||||
<DeleteDraft
|
||||
bottomSheetId={Screens.DRAFT_OPTIONS}
|
||||
channelId={channel.id}
|
||||
rootId={rootId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
componentId={Screens.DRAFT_OPTIONS}
|
||||
renderContent={renderContent}
|
||||
closeButtonId={DRAFT_OPTIONS_BUTTON}
|
||||
snapPoints={snapPoints}
|
||||
testID='draft_options'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftOptions;
|
||||
166
app/screens/draft_options/send_draft.tsx
Normal file
166
app/screens/draft_options/send_draft.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {removeDraft} from '@actions/local/draft';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {General} from '@constants';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useHandleSendMessage} from '@hooks/handle_send_message';
|
||||
import {usePersistentNotificationProps} from '@hooks/persistent_notification_props';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {persistentNotificationsConfirmation, sendMessageWithAlert} from '@utils/post';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
channelType: ChannelType | undefined;
|
||||
currentUserId: string;
|
||||
channelName: string | undefined;
|
||||
channelDisplayName?: string;
|
||||
enableConfirmNotificationsToChannel?: boolean;
|
||||
maxMessageLength: number;
|
||||
membersCount?: number;
|
||||
useChannelMentions: boolean;
|
||||
userIsOutOfOffice: boolean;
|
||||
customEmojis: CustomEmojiModel[];
|
||||
bottomSheetId?: AvailableScreens;
|
||||
value: string;
|
||||
files: FileInfo[];
|
||||
postPriority: PostPriority;
|
||||
persistentNotificationInterval: number;
|
||||
persistentNotificationMaxRecipients: number;
|
||||
draftReceiverUserName?: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
draftOptions: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
disabled: {
|
||||
color: 'red',
|
||||
},
|
||||
}));
|
||||
|
||||
const SendDraft: React.FC<Props> = ({
|
||||
channelId,
|
||||
channelName,
|
||||
channelDisplayName,
|
||||
rootId,
|
||||
channelType,
|
||||
bottomSheetId,
|
||||
currentUserId,
|
||||
enableConfirmNotificationsToChannel,
|
||||
maxMessageLength,
|
||||
membersCount = 0,
|
||||
useChannelMentions,
|
||||
userIsOutOfOffice,
|
||||
customEmojis,
|
||||
value,
|
||||
files,
|
||||
postPriority,
|
||||
persistentNotificationInterval,
|
||||
persistentNotificationMaxRecipients,
|
||||
draftReceiverUserName,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const clearDraft = () => {
|
||||
removeDraft(serverUrl, channelId, rootId);
|
||||
};
|
||||
|
||||
const {persistentNotificationsEnabled, mentionsList} = usePersistentNotificationProps({
|
||||
value,
|
||||
channelType,
|
||||
postPriority,
|
||||
});
|
||||
|
||||
const {handleSendMessage} = useHandleSendMessage({
|
||||
value,
|
||||
channelId,
|
||||
rootId,
|
||||
files,
|
||||
maxMessageLength,
|
||||
customEmojis,
|
||||
enableConfirmNotificationsToChannel,
|
||||
useChannelMentions,
|
||||
membersCount,
|
||||
userIsOutOfOffice,
|
||||
currentUserId,
|
||||
channelType,
|
||||
postPriority,
|
||||
clearDraft,
|
||||
});
|
||||
|
||||
const draftSendHandler = async () => {
|
||||
await dismissBottomSheet(bottomSheetId);
|
||||
if (persistentNotificationsEnabled) {
|
||||
persistentNotificationsConfirmation(serverUrl, value, mentionsList, intl, handleSendMessage, persistentNotificationMaxRecipients, persistentNotificationInterval, currentUserId, channelName, channelType);
|
||||
} else {
|
||||
let receivingChannel = channelName;
|
||||
switch (channelType) {
|
||||
case General.DM_CHANNEL:
|
||||
receivingChannel = draftReceiverUserName;
|
||||
break;
|
||||
case General.GM_CHANNEL:
|
||||
receivingChannel = channelDisplayName;
|
||||
break;
|
||||
default:
|
||||
receivingChannel = channelName;
|
||||
break;
|
||||
}
|
||||
sendMessageWithAlert({
|
||||
title: intl.formatMessage({
|
||||
id: 'send_message.confirm.title',
|
||||
defaultMessage: 'Send message now',
|
||||
}),
|
||||
intl,
|
||||
channelName: receivingChannel || '',
|
||||
sendMessageHandler: handleSendMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
type={'opacity'}
|
||||
style={[style.draftOptions]}
|
||||
onPress={draftSendHandler}
|
||||
testID='send_draft_button'
|
||||
>
|
||||
<CompassIcon
|
||||
name='send'
|
||||
size={ICON_SIZE}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
/>
|
||||
<FormattedText
|
||||
id='draft.options.send.title'
|
||||
defaultMessage='Send draft'
|
||||
style={style.title}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendDraft;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
const draft_message_image = require('@assets/images/Draft_Message.png');
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
image: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
...typography('Heading', 400, 'SemiBold'),
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
subtitle: {
|
||||
...typography('Body'),
|
||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const DraftEmptyComponent = () => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID='draft_empty_component'
|
||||
>
|
||||
<Image
|
||||
source={draft_message_image}
|
||||
/>
|
||||
<FormattedText
|
||||
id='drafts.empty.title'
|
||||
defaultMessage={'No drafts at the moment'}
|
||||
style={styles.title}
|
||||
/>
|
||||
<FormattedText
|
||||
id='drafts.empty.subtitle'
|
||||
defaultMessage={'Any message you have started will show here.'}
|
||||
style={styles.subtitle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftEmptyComponent;
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
// 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 {DeviceEventEmitter, Pressable, View} from 'react-native';
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler';
|
||||
import ReanimatedSwipeable, {type SwipeableMethods} from 'react-native-gesture-handler/ReanimatedSwipeable';
|
||||
import Reanimated, {useAnimatedStyle, useSharedValue, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import Draft from '@components/draft';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Events} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {deleteDraftConfirmation} from '@utils/draft';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
type Props = {
|
||||
item: DraftModel;
|
||||
location: string;
|
||||
layoutWidth: number;
|
||||
}
|
||||
|
||||
const getStyles = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
deleteContainer: {
|
||||
backgroundColor: theme.dndIndicator,
|
||||
},
|
||||
pressableContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
height: '100%',
|
||||
},
|
||||
deleteText: {
|
||||
color: theme.sidebarText,
|
||||
...typography('Body'),
|
||||
},
|
||||
deleteIcon: {
|
||||
color: theme.sidebarText,
|
||||
...typography('Heading'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function RightAction({deleteDraft, drag}: { deleteDraft: () => void; drag: SharedValue<number> }) {
|
||||
const theme = useTheme();
|
||||
const styles1 = getStyles(theme);
|
||||
const containerWidth = useSharedValue(0);
|
||||
const [isReady, setIsReady] = useState(false); // flag is use to prevent the jerky animation before calculating the container width
|
||||
const styleAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{translateX: drag.value + containerWidth.value}],
|
||||
opacity: isReady ? 1 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const handleLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
|
||||
const width = event.nativeEvent.layout.width;
|
||||
containerWidth.value = width;
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Reanimated.View
|
||||
style={[styleAnimation, styles1.deleteContainer]}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<Pressable
|
||||
onPress={deleteDraft}
|
||||
>
|
||||
<View
|
||||
style={styles1.pressableContainer}
|
||||
>
|
||||
<CompassIcon
|
||||
color={theme.sidebarText}
|
||||
name='trash-can-outline'
|
||||
size={18}
|
||||
onPress={deleteDraft}
|
||||
/>
|
||||
<FormattedText
|
||||
id='draft.options.delete.title'
|
||||
defaultMessage={'Delete draft'}
|
||||
style={styles1.deleteText}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Reanimated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const DraftSwipeActions: React.FC<Props> = ({
|
||||
item,
|
||||
location,
|
||||
layoutWidth,
|
||||
}) => {
|
||||
const swipeable = useRef<SwipeableMethods>(null);
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const onSwipeableOpenStartDrag = useCallback(() => {
|
||||
DeviceEventEmitter.emit(Events.DRAFT_SWIPEABLE, item.id);
|
||||
}, [item.id]);
|
||||
|
||||
const deleteDraft = useCallback(() => {
|
||||
deleteDraftConfirmation({
|
||||
intl,
|
||||
serverUrl,
|
||||
channelId: item.channelId,
|
||||
rootId: item.rootId,
|
||||
swipeable,
|
||||
});
|
||||
}, [intl, item.channelId, item.rootId, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.DRAFT_SWIPEABLE, (draftId: string) => {
|
||||
if (item.id !== draftId) {
|
||||
swipeable.current?.close();
|
||||
}
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, [item.id]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView>
|
||||
<ReanimatedSwipeable
|
||||
childrenContainerStyle={{flex: 1}}
|
||||
rightThreshold={20}
|
||||
renderRightActions={(_, drag) => (
|
||||
<RightAction
|
||||
deleteDraft={deleteDraft}
|
||||
drag={drag}
|
||||
/>
|
||||
)}
|
||||
ref={swipeable}
|
||||
onSwipeableOpenStartDrag={onSwipeableOpenStartDrag}
|
||||
testID='draft_swipeable'
|
||||
>
|
||||
<Draft
|
||||
key={item.id}
|
||||
channelId={item.channelId}
|
||||
draft={item}
|
||||
location={location}
|
||||
layoutWidth={layoutWidth}
|
||||
/>
|
||||
</ReanimatedSwipeable>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftSwipeActions;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React from 'react';
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Preferences} from '@constants';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const longPressGestureHandLogo = require('@assets/images/emojis/swipe.png');
|
||||
|
||||
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
close: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
descriptionContainer: {
|
||||
marginBottom: 24,
|
||||
marginTop: 12,
|
||||
},
|
||||
description: {
|
||||
color: Preferences.THEMES.denim.centerChannelColor,
|
||||
...typography('Heading', 200),
|
||||
textAlign: 'center',
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
color: Preferences.THEMES.denim.centerChannelColor,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
image: {
|
||||
height: 69,
|
||||
width: 68,
|
||||
},
|
||||
});
|
||||
|
||||
const DraftTooltip = ({onClose}: Props) => {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
source={longPressGestureHandLogo}
|
||||
style={styles.image}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.close}
|
||||
hitSlop={hitSlop}
|
||||
onPress={onClose}
|
||||
testID='draft.tooltip.close.button'
|
||||
>
|
||||
<CompassIcon
|
||||
color={changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.56)}
|
||||
name='close'
|
||||
size={18}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.descriptionContainer}>
|
||||
<FormattedText
|
||||
id='draft.tooltip.description'
|
||||
defaultMessage='Long-press on an item to see draft actions'
|
||||
style={styles.description}
|
||||
testID='draft.tooltip.description'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraftTooltip;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {InteractionManager, StyleSheet, View, type LayoutChangeEvent, type ListRenderItemInfo} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import Tooltip from 'react-native-walkthrough-tooltip';
|
||||
|
||||
import {storeDraftsTutorial} from '@actions/app/global';
|
||||
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG} from '@components/post_list/config';
|
||||
import {Screens} from '@constants';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
|
||||
import DraftEmptyComponent from '../draft_empty_component';
|
||||
|
||||
import DraftSwipeActions from './draft_swipe_actions';
|
||||
import DraftTooltip from './draft_tooltip';
|
||||
|
||||
import type DraftModel from '@typings/database/models/servers/draft';
|
||||
|
||||
type Props = {
|
||||
allDrafts: DraftModel[];
|
||||
location: string;
|
||||
tutorialWatched: boolean;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tooltipStyle: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowRadius: 2,
|
||||
shadowOpacity: 0.16,
|
||||
},
|
||||
swippeableContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
tooltipContentStyle: {
|
||||
borderRadius: 8,
|
||||
width: 247,
|
||||
padding: 16,
|
||||
height: 160,
|
||||
},
|
||||
});
|
||||
|
||||
const AnimatedFlatList = Animated.FlatList;
|
||||
const keyExtractor = (item: DraftModel) => item.id;
|
||||
|
||||
const GlobalDraftsList: React.FC<Props> = ({
|
||||
allDrafts,
|
||||
location,
|
||||
tutorialWatched,
|
||||
}) => {
|
||||
const [layoutWidth, setLayoutWidth] = useState(0);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
if (location === Screens.GLOBAL_DRAFTS) {
|
||||
setLayoutWidth(e.nativeEvent.layout.width - 40); // 40 is the padding of the container
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const firstDraftId = allDrafts.length ? allDrafts[0].id : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (tutorialWatched) {
|
||||
return;
|
||||
}
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setTooltipVisible(true);
|
||||
});
|
||||
|
||||
// This effect is intended to run only on the first mount, so dependencies are omitted intentionally.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
popTopScreen(Screens.GLOBAL_DRAFTS);
|
||||
}, []);
|
||||
|
||||
useAndroidHardwareBackHandler(Screens.GLOBAL_DRAFTS, collapse);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setTooltipVisible(false);
|
||||
storeDraftsTutorial();
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<DraftModel>) => {
|
||||
if (item.id === firstDraftId && !tutorialWatched) {
|
||||
return (
|
||||
<Tooltip
|
||||
isVisible={tooltipVisible}
|
||||
useInteractionManager={true}
|
||||
contentStyle={styles.tooltipContentStyle}
|
||||
placement={'bottom'}
|
||||
content={<DraftTooltip onClose={close}/>}
|
||||
onClose={close}
|
||||
tooltipStyle={styles.tooltipStyle}
|
||||
>
|
||||
<View
|
||||
style={styles.swippeableContainer}
|
||||
>
|
||||
<DraftSwipeActions
|
||||
item={item}
|
||||
location={location}
|
||||
layoutWidth={layoutWidth}
|
||||
/>
|
||||
</View>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DraftSwipeActions
|
||||
item={item}
|
||||
location={location}
|
||||
layoutWidth={layoutWidth}
|
||||
/>
|
||||
);
|
||||
}, [close, firstDraftId, layoutWidth, location, tooltipVisible, tutorialWatched]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={onLayout}
|
||||
testID='global_drafts_list'
|
||||
>
|
||||
<AnimatedFlatList
|
||||
data={allDrafts}
|
||||
keyExtractor={keyExtractor}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER + 5}
|
||||
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
|
||||
contentContainerStyle={!allDrafts.length && styles.empty}
|
||||
maxToRenderPerBatch={10}
|
||||
nativeID={location}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={DraftEmptyComponent}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalDraftsList;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
|
||||
import {Tutorial} from '@constants';
|
||||
import {observeTutorialWatched} from '@queries/app/global';
|
||||
import {observeDraftsForTeam} from '@queries/servers/drafts';
|
||||
import {observeCurrentTeamId} from '@queries/servers/system';
|
||||
|
||||
import GlobalDraftsList from './global_drafts_list';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
teamId: observeCurrentTeamId(database),
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
teamId: string;
|
||||
} & WithDatabaseArgs;
|
||||
|
||||
const enhanced = withObservables(['teamId'], ({database, teamId}: Props) => {
|
||||
const allDrafts = observeDraftsForTeam(database, teamId);
|
||||
const tutorialWatched = observeTutorialWatched(Tutorial.DRAFTS);
|
||||
|
||||
return {
|
||||
allDrafts,
|
||||
tutorialWatched,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withTeamId(enhanced(GlobalDraftsList)));
|
||||
99
app/screens/global_drafts/index.tsx
Normal file
99
app/screens/global_drafts/index.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, StyleSheet, View} from 'react-native';
|
||||
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
|
||||
|
||||
import NavigationHeader from '@components/navigation_header';
|
||||
import OtherMentionsBadge from '@components/other_mentions_badge';
|
||||
import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import {Screens} from '@constants';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {useTeamSwitch} from '@hooks/team_switch';
|
||||
|
||||
import {popTopScreen} from '../navigation';
|
||||
|
||||
import GlobalDraftsList from './components/global_drafts_list';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
const edges: Edge[] = ['left', 'right'];
|
||||
|
||||
type Props = {
|
||||
componentId?: AvailableScreens;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const GlobalDrafts = ({componentId}: Props) => {
|
||||
const intl = useIntl();
|
||||
const switchingTeam = useTeamSwitch();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const defaultHeight = useDefaultHeaderHeight();
|
||||
|
||||
const headerLeftComponent = useMemo(() => {
|
||||
if (isTablet) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (<OtherMentionsBadge channelId={Screens.GLOBAL_DRAFTS}/>);
|
||||
}, [isTablet]);
|
||||
|
||||
const contextStyle = useMemo(() => ({
|
||||
top: defaultHeight,
|
||||
}), [defaultHeight]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const marginTop = defaultHeight;
|
||||
return {flex: 1, marginTop};
|
||||
}, [defaultHeight]);
|
||||
|
||||
const onBackPress = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
if (!isTablet) {
|
||||
popTopScreen(componentId);
|
||||
}
|
||||
}, [componentId, isTablet]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
edges={edges}
|
||||
mode='margin'
|
||||
style={styles.flex}
|
||||
testID='global_drafts.screen'
|
||||
>
|
||||
<NavigationHeader
|
||||
showBackButton={!isTablet}
|
||||
isLargeTitle={false}
|
||||
onBackPress={onBackPress}
|
||||
title={
|
||||
intl.formatMessage({
|
||||
id: 'drafts',
|
||||
defaultMessage: 'Drafts',
|
||||
})
|
||||
}
|
||||
leftComponent={headerLeftComponent}
|
||||
/>
|
||||
<View style={contextStyle}>
|
||||
<RoundedHeaderContext/>
|
||||
</View>
|
||||
{!switchingTeam &&
|
||||
<View style={containerStyle}>
|
||||
<GlobalDraftsList
|
||||
location={Screens.GLOBAL_DRAFTS}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalDrafts;
|
||||
|
|
@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
|
|||
|
||||
import {Navigation, Screens} from '@constants';
|
||||
import Channel from '@screens/channel';
|
||||
import GlobalDrafts from '@screens/global_drafts';
|
||||
import GlobalThreads from '@screens/global_threads';
|
||||
|
||||
type SelectedView = {
|
||||
|
|
@ -22,6 +23,7 @@ type Props = {
|
|||
const ComponentsList: Record<string, React.ComponentType<any>> = {
|
||||
[Screens.CHANNEL]: Channel,
|
||||
[Screens.GLOBAL_THREADS]: GlobalThreads,
|
||||
[Screens.GLOBAL_DRAFTS]: GlobalDrafts,
|
||||
};
|
||||
|
||||
const channelScreen: SelectedView = {id: Screens.CHANNEL, Component: Channel};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||
import {FlatList} from 'react-native';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {DeviceEventEmitter, FlatList} from 'react-native';
|
||||
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {fetchDirectChannelsInfo} from '@actions/remote/channel';
|
||||
import ChannelItem from '@components/channel_item';
|
||||
import {ROW_HEIGHT as CHANNEL_ROW_HEIGHT} from '@components/channel_item/channel_item';
|
||||
import {Events} from '@constants';
|
||||
import {DRAFT, THREAD} from '@constants/screens';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {isDMorGM} from '@utils/channel';
|
||||
|
||||
|
|
@ -26,6 +28,18 @@ const extractKey = (item: ChannelModel) => item.id;
|
|||
|
||||
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const [isChannelScreenActive, setChannelScreenActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => {
|
||||
setChannelScreenActive(screen !== DRAFT && screen !== THREAD);
|
||||
});
|
||||
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ids = useMemo(() => {
|
||||
const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels;
|
||||
|
||||
|
|
@ -47,12 +61,12 @@ const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChan
|
|||
onPress={onChannelSwitch}
|
||||
key={item.id}
|
||||
testID={`channel_list.category.${category.displayName.replace(/ /g, '_').toLocaleLowerCase()}.channel_item`}
|
||||
shouldHighlightActive={true}
|
||||
shouldHighlightActive={isChannelScreenActive}
|
||||
shouldHighlightState={true}
|
||||
isOnHome={true}
|
||||
/>
|
||||
);
|
||||
}, [onChannelSwitch]);
|
||||
}, [category.displayName, isChannelScreenActive, onChannelSwitch]);
|
||||
|
||||
const sharedValue = useSharedValue(category.collapsed);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {FlatList, StyleSheet, View} from 'react-native';
|
||||
import {DeviceEventEmitter, FlatList, StyleSheet, View} from 'react-native';
|
||||
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import Loading from '@components/loading';
|
||||
import {Events} from '@constants';
|
||||
import {CHANNEL} from '@constants/screens';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useTeamSwitch} from '@hooks/team_switch';
|
||||
|
|
@ -69,6 +71,7 @@ const Categories = ({
|
|||
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
|
||||
|
||||
const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => {
|
||||
DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, CHANNEL);
|
||||
PerformanceMetricsManager.startMetric('mobile_channel_switch');
|
||||
switchToChannelById(serverUrl, c.id);
|
||||
}, [serverUrl]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {DeviceEventEmitter, useWindowDimensions} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import DraftsButton from '@components/drafts_buttton/drafts_button';
|
||||
import ThreadsButton from '@components/threads_button';
|
||||
import {Events} from '@constants';
|
||||
import {CHANNEL, DRAFT, THREAD} from '@constants/screens';
|
||||
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import Categories from './categories';
|
||||
import ChannelListHeader from './header';
|
||||
import LoadChannelsError from './load_channels_error';
|
||||
import SubHeader from './subheader';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.sidebarBg,
|
||||
paddingTop: 10,
|
||||
},
|
||||
}));
|
||||
|
||||
type ChannelListProps = {
|
||||
hasChannels: boolean;
|
||||
iconPad?: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
moreThanOneTeam: boolean;
|
||||
draftsCount: number;
|
||||
};
|
||||
|
||||
const getTabletWidth = (moreThanOneTeam: boolean) => {
|
||||
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0);
|
||||
};
|
||||
|
||||
type ScreenType = typeof DRAFT | typeof THREAD | typeof CHANNEL;
|
||||
|
||||
const CategoriesList = ({
|
||||
hasChannels,
|
||||
iconPad,
|
||||
isCRTEnabled,
|
||||
moreThanOneTeam,
|
||||
draftsCount,
|
||||
}: ChannelListProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {width} = useWindowDimensions();
|
||||
const isTablet = useIsTablet();
|
||||
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(moreThanOneTeam) : 0);
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenType>(CHANNEL);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTablet) {
|
||||
tabletWidth.value = getTabletWidth(moreThanOneTeam);
|
||||
}
|
||||
|
||||
// tabletWidth is a sharedValue, so it's safe to ignore this warning
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTablet, moreThanOneTeam]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => {
|
||||
if (screen === DRAFT || screen === THREAD) {
|
||||
setActiveScreen(screen);
|
||||
} else {
|
||||
setActiveScreen(CHANNEL);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabletStyle = useAnimatedStyle(() => {
|
||||
if (!isTablet) {
|
||||
return {
|
||||
maxWidth: width,
|
||||
};
|
||||
}
|
||||
|
||||
return {maxWidth: withTiming(tabletWidth.value, {duration: 350})};
|
||||
}, [isTablet, width]);
|
||||
|
||||
const threadButtonComponent = useMemo(() => {
|
||||
if (!isCRTEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadsButton
|
||||
isOnHome={true}
|
||||
shouldHighlighActive={activeScreen === THREAD}
|
||||
/>
|
||||
);
|
||||
}, [activeScreen, isCRTEnabled]);
|
||||
|
||||
const draftsButtonComponent = useMemo(() => {
|
||||
if (draftsCount > 0) {
|
||||
return (
|
||||
<DraftsButton
|
||||
draftsCount={draftsCount}
|
||||
shouldHighlightActive={activeScreen === DRAFT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [activeScreen, draftsCount]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!hasChannels) {
|
||||
return (<LoadChannelsError/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubHeader/>
|
||||
{threadButtonComponent}
|
||||
{draftsButtonComponent}
|
||||
<Categories/>
|
||||
</>
|
||||
);
|
||||
}, [draftsButtonComponent, hasChannels, threadButtonComponent]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, tabletStyle]}>
|
||||
<ChannelListHeader iconPad={iconPad}/>
|
||||
{content}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesList;
|
||||
|
|
@ -9,7 +9,7 @@ import {getTeamById} from '@queries/servers/team';
|
|||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import CategoriesList from '.';
|
||||
import CategoriesList from './categories_list';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type Database from '@nozbe/watermelondb/Database';
|
||||
|
|
@ -35,6 +35,7 @@ describe('components/categories_list', () => {
|
|||
<CategoriesList
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
draftsCount={0}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
|
@ -48,6 +49,7 @@ describe('components/categories_list', () => {
|
|||
isCRTEnabled={true}
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
draftsCount={0}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
|
@ -58,6 +60,19 @@ describe('components/categories_list', () => {
|
|||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render channel list with Draft menu', () => {
|
||||
const wrapper = renderWithEverything(
|
||||
<CategoriesList
|
||||
isCRTEnabled={true}
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
draftsCount={1}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
expect(wrapper.getByText('Drafts')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render team error', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: ''}],
|
||||
|
|
@ -69,6 +84,7 @@ describe('components/categories_list', () => {
|
|||
<CategoriesList
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
draftsCount={0}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
|
@ -91,6 +107,7 @@ describe('components/categories_list', () => {
|
|||
<CategoriesList
|
||||
moreThanOneTeam={true}
|
||||
hasChannels={false}
|
||||
draftsCount={0}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,88 +1,22 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {useWindowDimensions} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import ThreadsButton from '@components/threads_button';
|
||||
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {observeDraftCount} from '@queries/servers/drafts';
|
||||
import {observeCurrentTeamId} from '@queries/servers/system';
|
||||
|
||||
import Categories from './categories';
|
||||
import ChannelListHeader from './header';
|
||||
import LoadChannelsError from './load_channels_error';
|
||||
import SubHeader from './subheader';
|
||||
import CategoriesList from './categories_list';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.sidebarBg,
|
||||
paddingTop: 10,
|
||||
},
|
||||
}));
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
type ChannelListProps = {
|
||||
hasChannels: boolean;
|
||||
iconPad?: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
moreThanOneTeam: boolean;
|
||||
};
|
||||
const enchanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const currentTeamId = observeCurrentTeamId(database);
|
||||
const draftsCount = currentTeamId.pipe(switchMap((teamId) => observeDraftCount(database, teamId))); // Observe draft count
|
||||
return {
|
||||
draftsCount,
|
||||
};
|
||||
});
|
||||
|
||||
const getTabletWidth = (moreThanOneTeam: boolean) => {
|
||||
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0);
|
||||
};
|
||||
|
||||
const CategoriesList = ({hasChannels, iconPad, isCRTEnabled, moreThanOneTeam}: ChannelListProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {width} = useWindowDimensions();
|
||||
const isTablet = useIsTablet();
|
||||
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(moreThanOneTeam) : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTablet) {
|
||||
tabletWidth.value = getTabletWidth(moreThanOneTeam);
|
||||
}
|
||||
}, [isTablet, moreThanOneTeam]);
|
||||
|
||||
const tabletStyle = useAnimatedStyle(() => {
|
||||
if (!isTablet) {
|
||||
return {
|
||||
maxWidth: width,
|
||||
};
|
||||
}
|
||||
|
||||
return {maxWidth: withTiming(tabletWidth.value, {duration: 350})};
|
||||
}, [isTablet, width]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!hasChannels) {
|
||||
return (<LoadChannelsError/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubHeader/>
|
||||
{isCRTEnabled &&
|
||||
<ThreadsButton
|
||||
isOnHome={true}
|
||||
shouldHighlighActive={true}
|
||||
/>
|
||||
}
|
||||
<Categories/>
|
||||
</>
|
||||
);
|
||||
}, [isCRTEnabled]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, tabletStyle]}>
|
||||
<ChannelListHeader iconPad={iconPad}/>
|
||||
{content}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesList;
|
||||
export default withDatabase(enchanced(CategoriesList));
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
|||
case Screens.CHANNEL_ADD_MEMBERS:
|
||||
screen = withServerDatabase(require('@screens/channel_add_members').default);
|
||||
break;
|
||||
case Screens.DRAFT_OPTIONS:
|
||||
screen = withServerDatabase(require('@screens/draft_options').default);
|
||||
break;
|
||||
case Screens.EDIT_POST:
|
||||
screen = withServerDatabase(require('@screens/edit_post').default);
|
||||
break;
|
||||
|
|
@ -135,6 +138,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
|||
case Screens.GENERIC_OVERLAY:
|
||||
screen = withServerDatabase(require('@screens/overlay').default);
|
||||
break;
|
||||
case Screens.GLOBAL_DRAFTS:
|
||||
screen = withServerDatabase(require('@screens/global_drafts').default);
|
||||
break;
|
||||
case Screens.GLOBAL_THREADS:
|
||||
screen = withServerDatabase(require('@screens/global_threads').default);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Alert, type AlertButton} from 'react-native';
|
||||
import {type SwipeableMethods} from 'react-native-gesture-handler/ReanimatedSwipeable';
|
||||
|
||||
import {parseMarkdownImages, removeDraft, updateDraftMarkdownImageMetadata, updateDraftMessage} from '@actions/local/draft';
|
||||
import {General} from '@constants';
|
||||
import {CODE_REGEX} from '@constants/autocomplete';
|
||||
import {t} from '@i18n';
|
||||
|
|
@ -170,3 +172,69 @@ export function alertSlashCommandFailed(intl: IntlShape, error: string) {
|
|||
error,
|
||||
);
|
||||
}
|
||||
|
||||
export const handleDraftUpdate = async ({
|
||||
serverUrl,
|
||||
channelId,
|
||||
rootId,
|
||||
value,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
value: string;
|
||||
}) => {
|
||||
await updateDraftMessage(serverUrl, channelId, rootId, value);
|
||||
const imageMetadata: Dictionary<PostImage | undefined> = {};
|
||||
await parseMarkdownImages(value, imageMetadata);
|
||||
|
||||
if (Object.keys(imageMetadata).length !== 0) {
|
||||
updateDraftMarkdownImageMetadata({serverUrl, channelId, rootId, imageMetadata});
|
||||
}
|
||||
};
|
||||
|
||||
export function deleteDraftConfirmation({intl, serverUrl, channelId, rootId, swipeable}: {
|
||||
intl: IntlShape;
|
||||
serverUrl: string;
|
||||
channelId: string;
|
||||
rootId: string;
|
||||
swipeable?: React.RefObject<SwipeableMethods>;
|
||||
}) {
|
||||
const deleteDraft = async () => {
|
||||
removeDraft(serverUrl, channelId, rootId);
|
||||
};
|
||||
|
||||
const onDismiss = () => {
|
||||
if (swipeable?.current) {
|
||||
swipeable.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'draft.options.delete.title',
|
||||
defaultMessage: 'Delete draft',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'draft.options.delete.confirmation',
|
||||
defaultMessage: 'Are you sure you want to delete this draft?',
|
||||
}),
|
||||
[
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'draft.options.delete.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
style: 'cancel',
|
||||
onPress: onDismiss,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'draft.options.delete.confirm',
|
||||
defaultMessage: 'Delete',
|
||||
}),
|
||||
onPress: deleteDraft,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,3 +218,37 @@ export async function persistentNotificationsConfirmation(serverUrl: string, val
|
|||
buttons,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendMessageWithAlert({title, channelName, intl, sendMessageHandler}: {
|
||||
title: string;
|
||||
channelName: string;
|
||||
intl: IntlShape;
|
||||
sendMessageHandler: () => void;
|
||||
}) {
|
||||
const buttons: AlertButton[] = [{
|
||||
text: intl.formatMessage({
|
||||
id: 'send_message.confirm.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
style: 'cancel',
|
||||
}, {
|
||||
text: intl.formatMessage({
|
||||
id: 'send_message.confirm.send',
|
||||
defaultMessage: 'Send',
|
||||
}),
|
||||
onPress: sendMessageHandler,
|
||||
}];
|
||||
|
||||
const description = intl.formatMessage({
|
||||
id: 'send_message.confirm.description',
|
||||
defaultMessage: 'Are you sure you want to send this message to {channelName} now?',
|
||||
}, {
|
||||
channelName,
|
||||
});
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
description,
|
||||
buttons,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {safeDecodeURIComponent} from './index';
|
||||
import {isParsableUrl, safeDecodeURIComponent} from './index';
|
||||
|
||||
describe('safeDecodeURIComponent', () => {
|
||||
test('should decode a valid URI component', () => {
|
||||
|
|
@ -27,3 +27,39 @@ describe('safeDecodeURIComponent', () => {
|
|||
expect(decoded).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isParsableUrl', () => {
|
||||
it('should return true for valid URLs', () => {
|
||||
expect(isParsableUrl('http://example.com')).toBe(true);
|
||||
expect(isParsableUrl('https://example.com')).toBe(true);
|
||||
expect(isParsableUrl('https://example.com/path')).toBe(true);
|
||||
expect(isParsableUrl('https://example.com:8080/path?query=1')).toBe(true);
|
||||
expect(isParsableUrl('https://sub.domain.example.com')).toBe(true);
|
||||
expect(isParsableUrl('ftp://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid URLs', () => {
|
||||
expect(isParsableUrl('example')).toBe(false);
|
||||
expect(isParsableUrl('example.com')).toBe(false); // Missing protocol
|
||||
expect(isParsableUrl('://example.com')).toBe(false);
|
||||
expect(isParsableUrl('http//example.com')).toBe(false);
|
||||
expect(isParsableUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-URL strings', () => {
|
||||
expect(isParsableUrl('plain text')).toBe(false);
|
||||
expect(isParsableUrl('12345')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle URLs with special characters correctly', () => {
|
||||
expect(isParsableUrl('https://example.com/path?query=value&other=value')).toBe(true);
|
||||
expect(isParsableUrl('https://example.com/path#hash')).toBe(true);
|
||||
expect(isParsableUrl('https://example.com:3000/path?query=1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
expect(isParsableUrl(' ')).toBe(false);
|
||||
expect(isParsableUrl(null as unknown as string)).toBe(false);
|
||||
expect(isParsableUrl(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ export function isValidUrl(url = '') {
|
|||
return regex.test(url);
|
||||
}
|
||||
|
||||
export function isParsableUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return Boolean(parsedUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeUrl(url: string, useHttp = false) {
|
||||
let preUrl = urlParse(url, true);
|
||||
let protocol = useHttp ? 'http:' : preUrl.protocol;
|
||||
|
|
|
|||
|
|
@ -305,6 +305,18 @@
|
|||
"display_settings.tz.auto": "Auto",
|
||||
"display_settings.tz.manual": "Manual",
|
||||
"download.error": "Unable to download the file. Try again later",
|
||||
"draft.option.header": "Draft actions",
|
||||
"draft.options.delete.cancel": "Cancel",
|
||||
"draft.options.delete.confirm": "Delete",
|
||||
"draft.options.delete.confirmation": "Are you sure you want to delete this draft?",
|
||||
"draft.options.delete.title": "Delete draft",
|
||||
"draft.options.edit.title": "Edit draft",
|
||||
"draft.options.send.title": "Send draft",
|
||||
"draft.options.title": "Draft Options",
|
||||
"draft.tooltip.description": "Long-press on an item to see draft actions",
|
||||
"drafts": "Drafts",
|
||||
"drafts.empty.subtitle": "Any message you have started will show here.",
|
||||
"drafts.empty.title": "No drafts at the moment",
|
||||
"edit_post.editPost": "Edit the post...",
|
||||
"edit_post.save": "Save",
|
||||
"edit_server.description": "Specify a display name for this server",
|
||||
|
|
@ -1010,6 +1022,10 @@
|
|||
"select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.",
|
||||
"select_team.no_team.title": "No teams are available to join",
|
||||
"select_team.title": "Select a team",
|
||||
"send_message.confirm.cancel": "Cancel",
|
||||
"send_message.confirm.description": "Are you sure you want to send this message to {channelName} now?",
|
||||
"send_message.confirm.send": "Send",
|
||||
"send_message.confirm.title": "Send message now",
|
||||
"server_list.push_proxy_error": "Notifications cannot be received from this server because of its configuration. Contact your system admin.",
|
||||
"server_list.push_proxy_unknown": "Notifications could not be received from this server because of its configuration. Log out and Log in again to retry.",
|
||||
"server_upgrade.alert_description": "Your server, {serverDisplayName}, is running an unsupported server version. Users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {supportedServerVersion} or later is required.",
|
||||
|
|
|
|||
BIN
assets/base/images/Draft_Message.png
Normal file
BIN
assets/base/images/Draft_Message.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/base/images/emojis/swipe.png
Normal file
BIN
assets/base/images/emojis/swipe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -18,7 +18,7 @@
|
|||
"@formatjs/intl-pluralrules": "5.2.14",
|
||||
"@gorhom/bottom-sheet": "4.6.4",
|
||||
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
|
||||
"@mattermost/compass-icons": "0.1.45",
|
||||
"@mattermost/compass-icons": "0.1.48",
|
||||
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
|
||||
"@mattermost/react-native-emm": "1.5.0",
|
||||
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client#ad7b88412f41c5a1024420a4f4c7461883cd0e63",
|
||||
|
|
@ -5872,9 +5872,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mattermost/compass-icons": {
|
||||
"version": "0.1.45",
|
||||
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.45.tgz",
|
||||
"integrity": "sha512-xNuQG6FpmIYh+7ZAP2Qs/kAgS/O23IWOMEymaVJHFvQq8buCLdQz/b/2WgJZSLeoJjdfqhRMDDJmgaG2UEQD1w=="
|
||||
"version": "0.1.48",
|
||||
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.48.tgz",
|
||||
"integrity": "sha512-z36LlyZryL+Ssc1pbCGE1bidRmdcYlHGkVtcFP9qpd7+Jpk/sRiv0OjCMzcBHDnCiVT8jIfSmtNrTd+TJdVnmQ=="
|
||||
},
|
||||
"node_modules/@mattermost/hardware-keyboard": {
|
||||
"resolved": "libraries/@mattermost/hardware-keyboard",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"@formatjs/intl-pluralrules": "5.2.14",
|
||||
"@gorhom/bottom-sheet": "4.6.4",
|
||||
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
|
||||
"@mattermost/compass-icons": "0.1.45",
|
||||
"@mattermost/compass-icons": "0.1.48",
|
||||
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
|
||||
"@mattermost/react-native-emm": "1.5.0",
|
||||
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client#ad7b88412f41c5a1024420a4f4c7461883cd0e63",
|
||||
|
|
|
|||
Loading…
Reference in a new issue