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:
Rajat Dabade 2025-01-13 18:40:03 +05:30 committed by GitHub
parent ffbca2889a
commit 84eded1bde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2768 additions and 306 deletions

View file

@ -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);
};

View file

@ -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);
});
});

View file

@ -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 ![alt text](image url)
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;
}
});
}

View file

@ -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,

View file

@ -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,

View 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;

View 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));

View 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;

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import 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;

View 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),
),
),
),
),
);

View 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;

View 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));

View 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;

View 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;

View file

@ -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;
}

View file

@ -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) {

View file

@ -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]);

View file

@ -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,

View file

@ -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}

View file

@ -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,

View file

@ -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 {

View file

@ -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,
});

View file

@ -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,

View file

@ -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,
};

View 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,
};
};

View 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,
};
};

View file

@ -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();
};

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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)));

View 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;

View file

@ -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};

View file

@ -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);

View file

@ -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]);

View file

@ -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;

View file

@ -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},
);

View file

@ -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));

View file

@ -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;

View file

@ -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,
},
],
);
}

View file

@ -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,
);
}

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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.",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

8
package-lock.json generated
View file

@ -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",

View file

@ -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",